diff --git a/db.py b/db.py index c145203..1018cdb 100644 --- a/db.py +++ b/db.py @@ -12,6 +12,12 @@ def conn(): return c +def _add_column(c, table, col, decl): + cols = [r[1] for r in c.execute(f"PRAGMA table_info({table})").fetchall()] + if col not in cols: + c.execute(f"ALTER TABLE {table} ADD COLUMN {col} {decl}") + + def init(): DB_PATH.parent.mkdir(parents=True, exist_ok=True) with conn() as c: @@ -26,13 +32,15 @@ def init(): total INTEGER DEFAULT 0 ); CREATE TABLE IF NOT EXISTS cards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - deck_slug TEXT NOT NULL REFERENCES decks(slug) ON DELETE CASCADE, - name TEXT NOT NULL, - position INTEGER NOT NULL, - filename TEXT, - price_usd REAL DEFAULT 0, - fetch_status TEXT DEFAULT 'pending' + id INTEGER PRIMARY KEY AUTOINCREMENT, + deck_slug TEXT NOT NULL REFERENCES decks(slug) ON DELETE CASCADE, + name TEXT NOT NULL, + position INTEGER NOT NULL, + filename TEXT, + back_filename TEXT, + scry_url TEXT, + price_usd REAL DEFAULT 0, + fetch_status TEXT DEFAULT 'pending' ); CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -40,6 +48,8 @@ def init(): line TEXT NOT NULL ); """) + _add_column(c, "cards", "back_filename", "TEXT") + _add_column(c, "cards", "scry_url", "TEXT") def get_deck(slug: str): diff --git a/main.py b/main.py index ddee57e..d9ced66 100644 --- a/main.py +++ b/main.py @@ -82,6 +82,14 @@ app.include_router(cards_router, dependencies=[Depends(auth)]) app.include_router(public_router) # no auth — TTS app fetches card images directly +@app.get("/decks/{slug}/tts.json") +async def tts_json(slug: str): + path = OUTPUTS / slug / "deck.tts.json" + if not path.exists(): + raise HTTPException(404, "TTS file not found") + return FileResponse(path, media_type="application/json") + + @app.get("/") async def index(_=Depends(auth)): return FileResponse("static/index.html") @@ -92,4 +100,4 @@ async def card_back(): if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=PORT) + uvicorn.run(app, host="0.0.0.0", port=PORT) \ No newline at end of file diff --git a/routes/cards.py b/routes/cards.py index 3c5ec60..080c1bc 100644 --- a/routes/cards.py +++ b/routes/cards.py @@ -33,6 +33,9 @@ async def deck_cards(slug: str): "position": c["position"], "filename": c["filename"], "exists": bool(c["filename"] and (OUTPUTS / slug / c["filename"]).exists()), + "back_filename": c["back_filename"], + "back_exists": bool(c["back_filename"] and (OUTPUTS / slug / c["back_filename"]).exists()), + "scry_url": c["scry_url"], "fetch_status": c["fetch_status"], "price_usd": c["price_usd"], } @@ -94,6 +97,7 @@ async def search_cards(q: str = ""): "set_name": card.get("set_name", ""), "price": card.get("prices", {}).get("usd"), "image": img, + "scry_url": card.get("scryfall_uri", "").split("?")[0], }) return results @@ -106,4 +110,4 @@ async def serve_card(slug: str, filename: str): p = OUTPUTS / slug / filename if not p.exists(): raise HTTPException(404) - return FileResponse(p, media_type="image/png") + return FileResponse(p, media_type="image/png") \ No newline at end of file diff --git a/routes/decks.py b/routes/decks.py index ffca780..a290531 100644 --- a/routes/decks.py +++ b/routes/decks.py @@ -6,6 +6,7 @@ from fastapi import APIRouter, HTTPException, Request import db import worker from config import OUTPUTS +from worker import is_scry_url router = APIRouter() @@ -20,6 +21,13 @@ def valid_slug(slug: str) -> str: return slug +def line_to_entry(line: str) -> dict: + line = line.strip() + if is_scry_url(line): + return {"name": "", "scry_url": line} + return {"name": line, "scry_url": None} + + @router.get("/decks") async def list_decks(): return [dict(d) for d in db.get_decks()] @@ -40,20 +48,28 @@ async def create_deck(request: Request): name = body.get("deck_name", "").strip() card_text = body.get("cards", "").strip() commander = body.get("commander", "").strip() - if not name or not card_text: - raise HTTPException(400, "deck_name and cards required") + is_tokens = bool(body.get("is_tokens")) + + if not name: + raise HTTPException(400, "deck_name required") + + entries = [line_to_entry(l) for l in card_text.splitlines() if l.strip()] + if not is_tokens and not entries: + raise HTTPException(400, "cards required") + + if is_tokens: + commander = "" + else: + if not commander: + commander = next((e["name"] for e in entries if e["name"]), "") + if commander: + idx = next((i for i, e in enumerate(entries) if e["name"] == commander), None) + if idx is not None: + entries.insert(0, entries.pop(idx)) + else: + entries.insert(0, {"name": commander, "scry_url": None}) slug = slugify(name) - card_names = [l.strip() for l in card_text.splitlines() if l.strip()] - if not card_names: - raise HTTPException(400, "no cards") - if not commander: - commander = card_names[0] - - # Commander always at position 0; remove any existing occurrence first - card_names = [n for n in card_names if n != commander] - card_names.insert(0, commander) - existing = db.get_deck(slug) if existing and existing["status"] in ("queued", "running"): raise HTTPException(409, "deck already processing") @@ -61,14 +77,14 @@ async def create_deck(request: Request): with db.conn() as c: c.execute( "INSERT OR REPLACE INTO decks (slug, deck_name, status, commander, price_usd, done, total) VALUES (?,?,?,?,0,0,?)", - (slug, name, "queued", commander, len(card_names)) + (slug, name, "queued", commander or None, len(entries)) ) c.execute("DELETE FROM cards WHERE deck_slug=?", (slug,)) c.execute("DELETE FROM logs WHERE deck_slug=?", (slug,)) - for i, card_name in enumerate(card_names): + for i, e in enumerate(entries): c.execute( - "INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,?,'pending')", - (slug, card_name, i + 1) + "INSERT INTO cards (deck_slug, name, position, scry_url, fetch_status) VALUES (?,?,?,?,'pending')", + (slug, e["name"], i + 1, e["scry_url"]) ) await worker.queue.put(slug) @@ -86,41 +102,45 @@ async def edit_deck(slug: str, request: Request): body = await request.json() remove_ids: list[int] = body.get("remove", []) - add_names: list[str] = [n.strip() for n in body.get("add", []) if n.strip()] + add_entries = [line_to_entry(n) for n in body.get("add", []) if n.strip()] new_name: str = body.get("deck_name", deck["deck_name"]).strip() or deck["deck_name"] - new_commander: str = body.get("commander", deck["commander"]).strip() or deck["commander"] + + raw_cmd = body.get("commander", None) + if raw_cmd is not None: + new_commander = raw_cmd.strip() or (deck["commander"] or None) + else: + new_commander = deck["commander"] with db.conn() as c: for cid in remove_ids: row = c.execute( - "SELECT filename FROM cards WHERE id=? AND deck_slug=?", (cid, slug) + "SELECT filename, back_filename FROM cards WHERE id=? AND deck_slug=?", (cid, slug) ).fetchone() - if row and row["filename"]: - p = OUTPUTS / slug / row["filename"] - if p.exists(): - p.unlink() + if row: + for fn in (row["filename"], row["back_filename"]): + if fn and (OUTPUTS / slug / fn).exists(): + (OUTPUTS / slug / fn).unlink() c.execute("DELETE FROM cards WHERE id=? AND deck_slug=?", (cid, slug)) max_pos = c.execute( "SELECT COALESCE(MAX(position), 0) FROM cards WHERE deck_slug=?", (slug,) ).fetchone()[0] - for i, card_name in enumerate(add_names): + for i, e in enumerate(add_entries): c.execute( - "INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,?,'pending')", - (slug, card_name, max_pos + i + 1) + "INSERT INTO cards (deck_slug, name, position, scry_url, fetch_status) VALUES (?,?,?,?,'pending')", + (slug, e["name"], max_pos + i + 1, e["scry_url"]) ) - # Ensure commander has a card row; if not, insert at position 0 so it sorts first - existing_commander = c.execute( - "SELECT id FROM cards WHERE deck_slug=? AND name=? LIMIT 1", - (slug, new_commander) - ).fetchone() - if not existing_commander: - c.execute( - "INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,0,'pending')", - (slug, new_commander) - ) + if new_commander: + row = c.execute( + "SELECT id FROM cards WHERE deck_slug=? AND name=? LIMIT 1", (slug, new_commander) + ).fetchone() + if not row: + c.execute( + "INSERT INTO cards (deck_slug, name, position, fetch_status) VALUES (?,?,0,'pending')", + (slug, new_commander) + ) c.execute( "UPDATE decks SET deck_name=?, commander=?, status='queued' WHERE slug=?", diff --git a/static/index.html b/static/index.html index 4112ccd..f9602af 100644 --- a/static/index.html +++ b/static/index.html @@ -65,6 +65,12 @@ textarea { resize: vertical; } .card-label { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.82); color: #fff; font-size: 9px; padding: 3px 5px; opacity: 0; transition: opacity 0.15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .card-tile:hover .card-label { opacity: 1; } .commander-crown { position: absolute; top: 3px; left: 3px; font-size: 13px; line-height: 1; pointer-events: none; text-shadow: 0 1px 3px rgba(0,0,0,0.9); } +.token-toggle { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); margin-bottom: 6px; cursor: pointer; user-select: none; } +.token-toggle input { width: auto; margin: 0; } +.dfc-badge { position: absolute; bottom: 3px; left: 3px; background: rgba(0,0,0,0.78); color: var(--blue); font-size: 10px; line-height: 1; padding: 3px 5px; border-radius: 2px; cursor: pointer; z-index: 2; } +.dfc-badge:hover { background: rgba(20,28,40,0.95); } +.card-price { position: absolute; bottom: 0; right: 0; background: rgba(0,0,0,0.75); color: var(--green); font-size: 9px; padding: 2px 4px; line-height: 1.4; pointer-events: none; } +.card-price.free { color: var(--muted); } .card-overlay-btn { position: absolute; display: none; border: none; cursor: pointer; font-size: 13px; line-height: 1; padding: 3px 5px; border-radius: 2px; } .btn-remove-card { top: 3px; right: 3px; background: rgba(180,0,0,0.8); color: #fff; } .btn-remove-card:hover { background: rgba(220,0,0,0.95); } @@ -126,8 +132,9 @@ textarea { resize: vertical; }