diff --git a/main.py b/main.py index df811d8..25dfdea 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ import base64 +import hashlib import json import logging import mimetypes @@ -78,6 +79,7 @@ class HistoryEntry(TypedDict, total=False): instructions: str model: str status: str + summary: str RUNS_DIR.mkdir(parents=True, exist_ok=True) @@ -106,6 +108,7 @@ def _append_history_entry(run_dir: Path, entry: HistoryEntry) -> None: "instructions": entry.get("instructions", ""), "model": entry.get("model", ""), "status": entry.get("status", ""), + "summary": entry.get("summary", ""), } path = _history_path(run_dir) with path.open("a", encoding="utf-8") as f: @@ -140,6 +143,7 @@ def _load_history_for_ip(run_dir: Path, ip: str) -> list[HistoryEntry]: "instructions": obj.get("instructions", ""), "model": obj.get("model", ""), "status": obj.get("status", ""), + "summary": obj.get("summary", ""), } ) except Exception: @@ -271,10 +275,11 @@ CONVERT_PROMPT = ( EDIT_SYSTEM_PROMPT = ( "You are an expert LaTeX/TikZ editor. " - "Given an existing standalone LaTeX/TikZ document and an edit request, return EXACTLY ONE updated complete LaTeX document. " - "Return ONLY raw LaTeX source: no markdown, no code fences, no commentary, no extra text. " - "The output must compile with pdflatex. " - "Wrap LaTeX to roughly 80 characters per line, keep formatting tidy, and add brief LaTeX comments to clarify sections when helpful." + "Given an existing standalone LaTeX/TikZ document and an edit request, return EXACTLY TWO elements: " + "a complete LaTeX document wrapped in ... and a very generic summary (less than 7 words, all lowercase) wrapped in .... " + "Return ONLY these tags and their contents: no markdown, no code fences, no commentary, no extra text. " + "The LaTeX must compile with pdflatex. " + "Wrap LaTeX to roughly 80 characters per line, keep formatting tidy, and add brief LaTeX comments where helpful." ) EDIT_PROMPT_TEMPLATE = ( @@ -282,7 +287,7 @@ EDIT_PROMPT_TEMPLATE = ( "Rules:\n" "- Return a complete standalone LaTeX document (\\documentclass{{standalone}} ... \\end{{document}}).\n" "- Make minimal changes necessary to satisfy the instructions.\n" - "- Do not add external files or assets.\n\n" + "- Do not add external files or assets.\n" "INSTRUCTIONS:\n{instructions}\n\n" "LATEX:\n{latex}\n" ) @@ -326,6 +331,22 @@ def _extract_text_from_litellm(resp: Any) -> str: return str(resp).strip() +def _parse_latex_and_summary(raw_text: str) -> tuple[str, str]: + """Extracts ... and ... from raw_text. + + Falls back to treating the whole raw_text as LaTeX if tags are missing. + """ + latex_re = re.compile(r"(.*?)", re.DOTALL | re.IGNORECASE) + summary_re = re.compile(r"(.*?)", re.DOTALL | re.IGNORECASE) + + latex_match = latex_re.search(raw_text or "") + summary_match = summary_re.search(raw_text or "") + + latex = (latex_match.group(1) if latex_match else (raw_text or "")).strip() + summary = (summary_match.group(1) if summary_match else "").strip() + return latex, summary + + def _litellm_call(*, model: str, messages: list[dict[str, Any]], **kwargs: Any) -> Any: return completion(model=model, messages=messages, **kwargs) @@ -439,6 +460,46 @@ def _render_png_with_magick( return None +COMPILE_COOLDOWN_SECS = 3 +COMPILE_STATE_FILE = "compile_state.json" + + +def _load_compile_state(run_dir: Path) -> dict[str, Any]: + path = run_dir / COMPILE_STATE_FILE + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8", errors="replace")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _save_compile_state(run_dir: Path, state: dict[str, Any]) -> None: + path = run_dir / COMPILE_STATE_FILE + path.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _tex_digest(text: str) -> str: + return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest() + + +def _should_compile( + run_dir: Path, tex_text: str, *, cooldown_s: int = COMPILE_COOLDOWN_SECS +) -> tuple[bool, str, str]: + state = _load_compile_state(run_dir) + last_ts = float(state.get("last_ts", 0.0) or 0.0) + last_hash = state.get("last_hash") if isinstance(state.get("last_hash"), str) else "" + now = time.time() + digest = _tex_digest(tex_text) + + if now - last_ts < cooldown_s: + return False, "cooldown", digest + if last_hash and last_hash == digest: + return False, "unchanged", digest + return True, "ok", digest + + def _render_svg_with_pdf2svg( run_dir: Path, pdf_path: Path, run_logger: RunLogger, section_title: str, svg_name: str = "diagram.svg" ) -> Optional[Path]: @@ -468,7 +529,27 @@ def _compile_latex_to_png( def _compile_phase( run_dir: Path, tex_path: Path, run_log: RunLogger, phase: str, start_msg: str = "compile.start" -) -> tuple[Optional[Path], Optional[Path], Optional[Path], int]: +) -> tuple[Optional[Path], Optional[Path], Optional[Path], int, str]: + try: + tex_text = tex_path.read_text(encoding="utf-8", errors="replace") + except Exception: + tex_text = "" + + allowed, reason, digest = _should_compile(run_dir, tex_text, cooldown_s=COMPILE_COOLDOWN_SECS) + + pdf_path_existing = run_dir / Path(tex_path.name).with_suffix(".pdf").name + png_path_existing = run_dir / "preview.png" + svg_path_existing = run_dir / "diagram.svg" + + if not allowed: + pdf_path = pdf_path_existing if pdf_path_existing.exists() else None + png_path = png_path_existing if png_path_existing.exists() else None + svg_path = svg_path_existing if svg_path_existing.exists() else None + run_log.line( + f"{start_msg}.skip reason={reason} has_pdf={pdf_path is not None} has_png={png_path is not None} has_svg={svg_path is not None}" + ) + return pdf_path, png_path, svg_path, 0, reason + run_log.line(start_msg) started = time.monotonic() pdf_path, png_path = _compile_latex_to_png(run_dir, tex_path, run_log, phase=phase) @@ -478,17 +559,38 @@ def _compile_phase( else None ) elapsed_ms = int((time.monotonic() - started) * 1000) + + _save_compile_state( + run_dir, + { + "last_ts": time.time(), + "last_hash": digest, + }, + ) + run_log.line( f"compile.end elapsed_ms={elapsed_ms} ok_pdf={pdf_path is not None} ok_png={png_path is not None} ok_svg={svg_path is not None}" ) - return pdf_path, png_path, svg_path, elapsed_ms + return pdf_path, png_path, svg_path, elapsed_ms, "compiled" + + +BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +RUN_ID_RE = re.compile(r"(\d{4}-\d{2}-\d{2}-[0-9a-f]{32}|[0-9A-Za-z]{22})") -RUN_ID_RE = re.compile(r"\d{4}-\d{2}-\d{2}-[0-9a-f]{32}") +def _uuid_to_base62(u: uuid.UUID) -> str: + n: int = int.from_bytes(u.bytes, "big", signed=False) + base = len(BASE62_ALPHABET) + chars: list[str] = [] + while n > 0: + n, rem = divmod(n, base) + chars.append(BASE62_ALPHABET[rem]) + encoded = "".join(reversed(chars or [BASE62_ALPHABET[0]])) + return encoded.rjust(22, BASE62_ALPHABET[0]) def _new_run_id() -> str: - return f"{time.strftime('%Y-%m-%d')}-{uuid.uuid4().hex}" + return _uuid_to_base62(uuid.uuid4()) def _write_run_meta(run_dir: Path, meta: dict[str, Any]) -> None: @@ -674,8 +776,11 @@ async def convert(request: Request, image: UploadFile = File(...), notes: str = png_path: Optional[Path] = None svg_path: Optional[Path] = None + cp_elapsed_ms = 0 + cp_reason = "" + if tex: - pdf_path, png_path, svg_path, cp_elapsed_ms = _compile_phase( + pdf_path, png_path, svg_path, cp_elapsed_ms, cp_reason = _compile_phase( run_dir, tex_path, run_log, phase="convert" ) logger.info( @@ -724,11 +829,12 @@ async def convert(request: Request, image: UploadFile = File(...), notes: str = f"convert.end elapsed_ms={elapsed_ms} ok_pdf={pdf_path is not None} ok_png={png_path is not None}", ) - return RedirectResponse(url=f"{BASE_PATH}/{run_id}", status_code=303) + warn_qs = f"?warn={cp_reason}" if cp_elapsed_ms == 0 and cp_reason else "" + return RedirectResponse(url=f"{BASE_PATH}/d/{run_id}{warn_qs}", status_code=303) -@app.get("/{run_id}", response_class=HTMLResponse) -async def get_run_result(request: Request, run_id: str): +@app.get("/d/{run_id}", response_class=HTMLResponse) +async def get_run_result(request: Request, run_id: str, warn: str | None = None): run_dir = _run_dir_for_id(run_id) if run_dir is None: return HTMLResponse("Not found", status_code=404) @@ -765,6 +871,15 @@ async def get_run_result(request: Request, run_id: str): download_pdf_url = file_url(pdf_path.name) if pdf_path.exists() else file_url("diagram.pdf") download_svg_url = file_url(svg_path.name) if svg_path.exists() else file_url("diagram.svg") + def _warn_text(reason: str | None) -> str: + if not reason: + return "" + if reason == "cooldown": + return "Please wait a few seconds before compiling again." + if reason == "unchanged": + return "Source unchanged; compile skipped." + return "Compilation skipped." + return templates.TemplateResponse( "edit.html", { @@ -783,11 +898,12 @@ async def get_run_result(request: Request, run_id: str): "base_path": BASE_PATH, "edit_remaining": rate_status["edit_remaining"], "edit_limit": rate_status["edit_limit"], + "compile_warning": _warn_text(warn), }, ) -@app.post("/{run_id}", response_class=HTMLResponse) +@app.post("/d/{run_id}", response_class=HTMLResponse) async def edit_tex(request: Request, run_id: str, instructions: str = Form(...), latex: str = Form(...)): run_dir = _run_dir_for_id(run_id) if run_dir is None: @@ -800,7 +916,7 @@ async def edit_tex(request: Request, run_id: str, instructions: str = Form(...), instructions = (instructions or "").strip() if not instructions: _write_run_error(run_dir, "Edit instructions are empty.") - return RedirectResponse(url=f"{BASE_PATH}/{run_id}", status_code=303) + return RedirectResponse(url=f"{BASE_PATH}/d/{run_id}", status_code=303) tex_path = run_dir / "diagram.tex" current_tex = latex @@ -825,6 +941,7 @@ async def edit_tex(request: Request, run_id: str, instructions: str = Form(...), raw_text = "" new_tex = "" used_model = "" + summary_text = "" try: llm_started = time.monotonic() run_log.line("llm.start") @@ -836,7 +953,7 @@ async def edit_tex(request: Request, run_id: str, instructions: str = Form(...), ], run_log=run_log, ) - new_tex = (raw_text or "").strip() + new_tex, summary_text = _parse_latex_and_summary(raw_text or "") llm_elapsed_ms = int((time.monotonic() - llm_started) * 1000) logger.info( "edit.llm.ok run_id=%s elapsed_ms=%s raw_chars=%s tex_chars=%s", @@ -861,8 +978,13 @@ async def edit_tex(request: Request, run_id: str, instructions: str = Form(...), png_path: Optional[Path] = None svg_path: Optional[Path] = None + cp_elapsed_ms = 0 + cp_reason = "" + if new_tex: - pdf_path, png_path, svg_path, cp_elapsed_ms = _compile_phase(run_dir, tex_path, run_log, phase="edit") + pdf_path, png_path, svg_path, cp_elapsed_ms, cp_reason = _compile_phase( + run_dir, tex_path, run_log, phase="edit" + ) logger.info( "edit.compile.done run_id=%s elapsed_ms=%s ok_pdf=%s ok_png=%s ok_svg=%s", run_id, @@ -872,19 +994,21 @@ async def edit_tex(request: Request, run_id: str, instructions: str = Form(...), svg_path is not None, ) - history_status = "ok" if pdf_path else "error" - _append_history_entry( - run_dir, - { - "ts": time.time(), - "ip": client_ip, - "action": "edit", - "latex": new_tex, - "instructions": instructions, - "model": used_model or (EDIT_MODELS[0] if EDIT_MODELS else ""), - "status": history_status, - }, - ) + if cp_elapsed_ms > 0: + history_status = "ok" if pdf_path else "error" + _append_history_entry( + run_dir, + { + "ts": time.time(), + "ip": client_ip, + "action": "edit", + "latex": new_tex, + "instructions": instructions, + "model": used_model or (EDIT_MODELS[0] if EDIT_MODELS else ""), + "status": history_status, + "summary": summary_text, + }, + ) run_log.section("edit.llm_raw", raw_text or "") @@ -941,10 +1065,11 @@ async def edit_tex(request: Request, run_id: str, instructions: str = Form(...), f"edit.end elapsed_ms={elapsed_ms} ok_pdf={pdf_path is not None} ok_png={png_path is not None}", ) - return RedirectResponse(url=f"{BASE_PATH}/{run_id}", status_code=303) + warn_qs = f"?warn={cp_reason}" if cp_elapsed_ms == 0 and cp_reason else "" + return RedirectResponse(url=f"{BASE_PATH}/d/{run_id}{warn_qs}", status_code=303) -@app.post("/{run_id}/compile", response_class=HTMLResponse) +@app.post("/d/{run_id}/compile", response_class=HTMLResponse) async def compile_tex(request: Request, run_id: str, latex: str = Form(...)): run_dir = _run_dir_for_id(run_id) if run_dir is None: @@ -964,8 +1089,11 @@ async def compile_tex(request: Request, run_id: str, latex: str = Form(...)): png_path: Optional[Path] = None svg_path: Optional[Path] = None + cp_elapsed_ms = 0 + cp_reason = "" + if tex.strip(): - pdf_path, png_path, svg_path, cp_elapsed_ms = _compile_phase( + pdf_path, png_path, svg_path, cp_elapsed_ms, cp_reason = _compile_phase( run_dir, tex_path, run_log, phase="compile", start_msg="compile.invoke" ) logger.info( @@ -977,19 +1105,20 @@ async def compile_tex(request: Request, run_id: str, latex: str = Form(...)): svg_path is not None, ) - history_status = "ok" if pdf_path else "error" - _append_history_entry( - run_dir, - { - "ts": time.time(), - "ip": client_ip, - "action": "compile", - "latex": tex, - "instructions": "", - "model": "", - "status": history_status, - }, - ) + if cp_elapsed_ms > 0: + history_status = "ok" if pdf_path else "error" + _append_history_entry( + run_dir, + { + "ts": time.time(), + "ip": client_ip, + "action": "compile", + "latex": tex, + "instructions": "", + "model": "", + "status": history_status, + }, + ) meta = _read_run_meta(run_dir) model_label = _get_meta_string(meta, "model") @@ -1028,10 +1157,11 @@ async def compile_tex(request: Request, run_id: str, latex: str = Form(...)): logger.info("compile.end run_id=%s elapsed_ms=%s", run_id, elapsed_ms) run_log.line(f"compile.finish elapsed_ms={elapsed_ms}") - return RedirectResponse(url=f"{BASE_PATH}/{run_id}", status_code=303) + warn_qs = f"?warn={cp_reason}" if cp_elapsed_ms == 0 and cp_reason else "" + return RedirectResponse(url=f"{BASE_PATH}/d/{run_id}{warn_qs}", status_code=303) -@app.get("/{run_id}/history") +@app.get("/d/{run_id}/history") async def get_history(request: Request, run_id: str): run_dir = _run_dir_for_id(run_id) if run_dir is None: @@ -1051,6 +1181,7 @@ async def get_history(request: Request, run_id: str): "instructions": entry.get("instructions", ""), "model": entry.get("model", ""), "status": entry.get("status", ""), + "summary": entry.get("summary", ""), } ) diff --git a/public/styles.css b/public/styles.css index aa91949..b99a2b9 100644 --- a/public/styles.css +++ b/public/styles.css @@ -6,155 +6,152 @@ *::after { box-sizing: border-box; } - - html, + html { + height: 100%; + color-scheme: light dark; + } body { height: 100%; } - body { margin: 0; } - img { max-width: 100%; height: auto; display: block; } - - textarea { + textarea, + input, + select, + button { font: inherit; } } @layer base { :root { - color-scheme: light dark; - - --border: 1px solid rgba(127, 127, 127, 0.35); - --border-danger: 1px solid rgba(255, 0, 0, 0.35); - - --radius-lg: 0.75rem; - --radius-md: 0.625rem; - - --pad-1: 0.5rem; - --pad-2: 0.75rem; - --pad-3: 1rem; - --pad-4: 1.5rem; - - --text-dim: 0.78; - - --btn-bg: #f2f3f5; - --btn-bg-hover: #e7e8ec; - --btn-bg-active: #d9dbe0; - --btn-border: #d0d4db; - --btn-text: #20242c; - - --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - --sans: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, sans-serif; + --bg: #e0e0e0; + --fg: #121212; + --border-color: #000000; + + /* Neobrutalist Palette */ + --c-primary: #a390e4; /* Purple */ + --c-secondary: #90e4c1; /* Mint */ + --c-accent: #ffc900; /* Yellow */ + --c-danger: #ff6b6b; /* Red */ + --c-surface: #ffffff; + + --border-width: 2px; + --border: var(--border-width) solid var(--border-color); + --shadow: 4px 4px 0 0 var(--border-color); + --shadow-sm: 2px 2px 0 0 var(--border-color); + --shadow-hover: 6px 6px 0 0 var(--border-color); + + --pad-xs: 0.25rem; + --pad-sm: 0.5rem; + --pad-md: 0.75rem; + --pad-lg: 1.5rem; + + --font-sans: "Inter", system-ui, -apple-system, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", monospace; + --checkerboard-color: light-dark(rgb(from #000 r g b / 0.2), rgb(from #fff r g b / 0.2)); } @media (prefers-color-scheme: dark) { :root { - --btn-bg: #2d3138; - --btn-bg-hover: #343944; - --btn-bg-active: #2a2e35; - --btn-border: #3c414c; - --btn-text: #e8ecf2; + /* Gruvbox Dark Palette */ + --bg: #1d2021; + --fg: #ebdbb2; + --border-color: #504945; + + --c-primary: #d3869b; /* Gruvbox Purple */ + --c-secondary: #8ec07c; /* Gruvbox Green */ + --c-accent: #fabd2f; /* Gruvbox Yellow */ + --c-danger: #fb4934; /* Gruvbox Red */ + --c-surface: #282828; } } body { - font-family: var(--sans); - min-height: 100vh; + font-family: var(--font-sans); + background-color: var(--bg); + color: var(--fg); + line-height: 1.4; + /* Dot pattern background */ + background-image: radial-gradient(rgb(from var(--border-color) r g b / 0.5) 1px, transparent 1px); + background-size: 20px 20px; } - code { - font-family: var(--mono); - font-size: 0.95em; + code, + pre, + textarea, + .mono { + font-family: var(--font-mono); + } + + h1, + h2, + h3 { + margin: 0; + font-weight: 900; + text-transform: uppercase; + letter-spacing: -0.02em; } } @layer components { .wrap { - max-width: 54rem; - margin-inline: auto; - padding: var(--pad-4); - } - - .label { - font-size: 0.75rem; - opacity: var(--text-dim); - font-weight: 600; - } - - .hint { - font-size: 0.75rem; - opacity: var(--text-dim); + max-width: 50rem; + margin: 0 auto; + padding: var(--pad-lg); } .card { + background: var(--c-surface); border: var(--border); - border-radius: var(--radius-lg); - padding: var(--pad-3); - } - - .field { + box-shadow: var(--shadow); + padding: var(--pad-md); display: grid; - grid-template-rows: auto 1fr; - gap: 0.4rem; - - label { - font-size: 0.875rem; - opacity: 0.9; - } - - input[type="file"], - input[type="text"] { - width: 100%; - } - - input[type="text"] { - padding: var(--pad-2); - border-radius: var(--radius-lg); - border: var(--border); - background: transparent; - color: inherit; - } - - textarea { - width: 100%; - min-height: 6rem; - padding: var(--pad-2); - border-radius: var(--radius-lg); - border: var(--border); - background: transparent; - color: inherit; - resize: vertical; - } + gap: var(--pad-md); } + .btn, button, - a.button, - .btn { + a.button { display: inline-flex; align-items: center; justify-content: center; - padding: 0.625rem 0.875rem; - border-radius: var(--radius-md); - border: 1px solid var(--btn-border); - background: var(--btn-bg); - cursor: pointer; + padding: var(--pad-sm) var(--pad-md); + background: var(--c-primary); + color: oklch(from var(--c-primary) calc(1 - l) c h); + border: var(--border); + box-shadow: var(--shadow-sm); + font-weight: 800; text-decoration: none; - color: var(--btn-text); - gap: 0.4rem; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), inset 0 -1px 0 rgba(0, 0, 0, 0.125); + cursor: pointer; + transition: all 0.1s; + gap: 0.5rem; + font-size: 0.85rem; + text-transform: uppercase; + &:hover { - background: var(--btn-bg-hover); + transform: translate(-2px, -2px); + box-shadow: var(--shadow); + filter: brightness(1.1); } &:active { - background: var(--btn-bg-active); + transform: translate(2px, 2px); + box-shadow: 0 0 0 0 var(--border-color); + } + + &[disabled] { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; + transform: none; + filter: grayscale(1); } .icon { @@ -163,54 +160,164 @@ } } + /* Variant buttons */ + .btn-accent { + background: var(--c-accent); + } + .btn-secondary { + background: var(--c-secondary); + } + .btn-danger { + background: var(--c-danger); + color: oklch(from var(--c-danger) calc(1 - l) c h); + } + + input[type="text"], + input[type="file"], + textarea, + select { + width: 100%; + padding: var(--pad-sm); + border: var(--border); + background: var(--c-surface); + border-radius: 0; + outline: none; + font-size: 0.9rem; + transition: box-shadow 0.1s; + + &:focus { + box-shadow: var(--shadow-sm); + background: var(--c-surface); + } + } + + textarea { + resize: vertical; + min-height: 4rem; + } + + .label { + font-weight: 800; + text-transform: uppercase; + font-size: 0.7rem; + margin-bottom: 0.25rem; + display: block; + background: var(--c-accent); + color: oklch(from var(--c-accent) calc(1 - l) c h); + display: inline-block; + padding: 0.1rem 0.3rem; + border: var(--border); + box-shadow: 2px 2px 0 0 var(--border-color); + } + + .hint { + font-size: 0.75rem; + font-weight: 600; + opacity: 0.7; + margin-top: 0.25rem; + } + .error { - border: var(--border-danger); - border-radius: var(--radius-lg); - padding: var(--pad-3); + background: var(--c-danger); + color: var(--fg); + border: var(--border); + padding: var(--pad-sm); + font-weight: bold; + box-shadow: var(--shadow); white-space: pre-wrap; } + + .field { + display: grid; + gap: 0.25rem; + + label { + font-size: 0.875rem; + font-weight: bold; + } + } + + /* Visually hidden but accessible (for native input) */ + .sr-only { + position: absolute; + width: 1px !important; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + /* Dropzone styling (applies to label or div) */ + .dropzone { + cursor: pointer; + position: relative; + padding: 2rem; + text-align: center; + border: 2px dashed var(--border-color); + background: var(--bg); + transition: all 0.2s; + + &:hover { + background: oklch(from var(--bg) calc(l - 0.05) c h); + } + + &.drag-over { + background: var(--c-accent); + transform: scale(1.01); + } + } } @layer pages { /* Index page */ .page-index { + display: grid; + place-items: center; + min-height: 100vh; + .wrap { - display: grid; - gap: 1rem; + width: 100%; } h1 { - font-size: 1.25rem; - margin: 0; + background: var(--c-secondary); + border: var(--border); + box-shadow: var(--shadow); + padding: var(--pad-sm); + text-align: center; + margin-bottom: var(--pad-md); + font-size: 1.5rem; } p { - margin: 0; - opacity: 0.88; - line-height: 1.45; - } - - form { - display: grid; - gap: 0.9rem; + background: var(--c-surface); + border: var(--border); + padding: var(--pad-sm); + margin-bottom: var(--pad-md); + font-size: 0.9rem; + font-weight: 500; } .preview { display: none; - margin-top: 0.75rem; - padding: var(--pad-2); - border-radius: var(--radius-lg); + margin-top: var(--pad-sm); + padding: var(--pad-sm); border: var(--border); + background: var(--bg); img { - border-radius: var(--radius-md); + border: var(--border); } .meta { - margin-top: 0.5rem; + margin-top: var(--pad-xs); + font-family: var(--font-mono); font-size: 0.75rem; - opacity: var(--text-dim); - word-break: break-word; + border-top: var(--border); + padding-top: var(--pad-xs); } } } @@ -219,146 +326,172 @@ .page-result { display: grid; grid-template-rows: auto 1fr; + min-height: 100vh; .topbar { display: flex; - gap: 0.75rem; + gap: var(--pad-md); align-items: center; - padding: var(--pad-2) var(--pad-3); + padding: var(--pad-sm) var(--pad-md); border-bottom: var(--border); + background: var(--c-surface); + z-index: 10; a { text-decoration: none; + font-weight: 800; + text-transform: uppercase; + color: var(--fg); + &:hover { + text-decoration: underline; + } + } + + .label { + margin: 0; + box-shadow: none; + background: var(--c-secondary); + color: oklch(from var(--c-secondary) calc(1 - l) c h); } } .container { - min-height: 0; display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 40% 60%; + height: 100%; } .pane { - min-width: 0; - padding: var(--pad-3); - } + padding: var(--pad-md); + overflow-y: auto; + display: flex; + flex-direction: column; + align-items: start; + gap: var(--pad-md); + height: 100%; - .left { - border-right: var(--border); - display: grid; - grid-template-rows: auto 1fr; - gap: 0.75rem; + &.left { + border-right: var(--border); + background: var(--c-surface); + } + + &.right { + background: var(--bg); + } } .edit-form { + display: flex; + flex-direction: column; + gap: var(--pad-md); + flex: 1; + width: 100%; + height: 100%; min-height: 0; - display: grid; - grid-template-rows: auto 1fr; - gap: 0.75rem; - .prompt-row { + .field { display: flex; - gap: 0.5rem; - align-items: stretch; + flex-direction: column; + gap: 0.25rem; - input[type="text"] { + &:has(textarea) { flex: 1; - min-width: 0; - } - - .btn { - white-space: nowrap; + min-height: 0; } } - .actions { + .prompt-row { display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: center; + gap: var(--pad-sm); + + input { + flex: 1; + } + button { + white-space: nowrap; + } } textarea { - min-height: 10rem; + flex: 1; + min-height: 0; + font-family: var(--font-mono); + font-size: 0.8rem; + line-height: 1.5; + white-space: pre; + border: var(--border); + box-shadow: inset 2px 2px 0 0 rgba(0, 0, 0, 0.05); } - } - - /* Fallback: ensure layout even without CSS nesting support */ - .edit-form .prompt-row { - display: flex; - gap: 0.5rem; - align-items: stretch; - } - - .edit-form .prompt-row input[type="text"] { - flex: 1; - min-width: 0; - } - - .edit-form .prompt-row .btn { - white-space: nowrap; - } - - .right { - display: grid; - grid-template-rows: auto 1fr auto auto; - gap: 0.75rem; - } - textarea { - width: 100%; - height: 100%; - resize: none; - font-family: var(--mono); - font-size: 0.85rem; - line-height: 1.45; - padding: var(--pad-2); - border-radius: var(--radius-lg); - border: var(--border); - white-space: pre; - overflow: auto; + .actions { + display: flex; + gap: var(--pad-sm); + } } .preview { - min-height: 15rem; display: grid; place-items: center; - border-radius: var(--radius-lg); border: var(--border); + background: var(--c-surface); + background-image: linear-gradient( + 45deg, + var(--checkerboard-color) 25%, + transparent 25%, + transparent 75%, + var(--checkerboard-color) 75%, + var(--checkerboard-color) + ), + linear-gradient( + 45deg, + var(--checkerboard-color) 25%, + transparent 25%, + transparent 75%, + var(--checkerboard-color) 75%, + var(--checkerboard-color) + ); + background-size: 12px 12px; + background-position: 0 0, 6px 6px; + padding: var(--pad-md); overflow: auto; + box-shadow: var(--shadow); + + img { + max-width: 100%; + } } .downloads { display: flex; flex-wrap: wrap; - gap: 0.5rem; - } + gap: var(--pad-sm); - .error { - font-family: var(--mono); - font-size: 0.85rem; + .button { + font-size: 0.75rem; + padding: var(--pad-xs) var(--pad-sm); + background: var(--c-surface); + color: oklch(from var(--c-surface) calc(1 - l) c h); + &:hover { + background: var(--c-secondary); + color: oklch(from var(--c-secondary) calc(1 - l) c h); + } + } } @media (max-width: 60rem) { .container { grid-template-columns: 1fr; + grid-template-rows: 1fr auto; } - - .left { + .pane.left { border-right: none; border-bottom: var(--border); } - } - @media (max-width: 40rem) { .topbar { - flex-wrap: wrap; - gap: 0.5rem; - padding: var(--pad-2) var(--pad-2); - } - - .topbar .label { - display: none; + .label { + display: none; + } } } } diff --git a/templates/edit.html b/templates/edit.html index 19b3448..84748a1 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -17,7 +17,7 @@
Standalone LaTeX/TikZ
-
+
@@ -50,10 +50,13 @@
- + {% if compile_warning %} +
{{ compile_warning }}
+ {% endif %}
@@ -69,11 +72,26 @@
- Download original image - Download .tex - Download .pdf - Download .svg - Download .png + + + Original Image + + + + LaTeX (.tex) + + + + PDF + + + + SVG + + + + PNG +
{% if error %} @@ -89,6 +107,7 @@ const runId = "{{ run_id }}" const historySelect = document.getElementById("history-select") const latexArea = document.getElementById("latex-source") + const compileWarning = document.getElementById("compile-warning") const currentTex = latexArea ? latexArea.value : "" let historyEntries = [] @@ -110,7 +129,8 @@ const opt = document.createElement("option") opt.value = String(idx) const statusLabel = entry.status === "error" ? " (error)" : "" - opt.textContent = `${entry.action || "edit"} @ ${formatTs(entry.ts)}${statusLabel}` + const label = entry.summary && entry.summary.trim() ? entry.summary.trim() : entry.action || "edit" + opt.textContent = `${label} @ ${formatTs(entry.ts)}${statusLabel}` frag.appendChild(opt) }) @@ -121,7 +141,7 @@ const loadHistory = async () => { if (!historySelect) return try { - const resp = await fetch(`${basePath}/${runId}/history`) + const resp = await fetch(`${basePath}/d/${runId}/history`) if (!resp.ok) return const data = await resp.json() historyEntries = Array.isArray(data.entries) ? data.entries : [] @@ -148,6 +168,12 @@ loadHistory() } + + if (compileWarning) { + setTimeout(() => { + compileWarning.style.display = "none" + }, 3500) + } })() diff --git a/templates/index.html b/templates/index.html index 36ea54e..9207bf5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -21,11 +21,11 @@
- -
- Accepted: any format your browser can upload (PNG/JPG recommended). You can also paste an image - from the clipboard (Ctrl/Cmd+V). -
+ + +
Accepted: any format your browser can upload (PNG/JPG recommended).
Daily convert quota: {{ convert_remaining }}/{{ convert_limit }} left today.
@@ -57,6 +57,7 @@ const submitStatus = document.getElementById("submit-status") const convertBtn = document.getElementById("convert-btn") const input = document.getElementById("image") + const dropzone = document.getElementById("dropzone") const preview = document.getElementById("preview") const img = document.getElementById("preview-img") const meta = document.getElementById("preview-meta") @@ -107,6 +108,40 @@ showFile(file) }) + // Drag and drop support on label dropzone + dropzone.addEventListener("dragenter", e => { + e.preventDefault() + e.stopPropagation() + dropzone.classList.add("drag-over") + }) + + dropzone.addEventListener("dragleave", e => { + e.preventDefault() + e.stopPropagation() + dropzone.classList.remove("drag-over") + }) + + dropzone.addEventListener("dragover", e => { + e.preventDefault() + e.stopPropagation() + }) + + dropzone.addEventListener("drop", e => { + e.preventDefault() + e.stopPropagation() + dropzone.classList.remove("drag-over") + + const files = e.dataTransfer.files + if (!files || files.length === 0) return + + for (const file of files) { + if (file.type && file.type.startsWith("image/")) { + setInputFile(file) + return + } + } + }) + window.addEventListener("paste", e => { const cd = e.clipboardData if (!cd || !cd.items) return