diff options
| author | kj_sh604 | 2026-05-31 11:29:58 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-05-31 11:29:58 -0400 |
| commit | 2b2e511a15414f6c28f15990b75442424f9cc3b9 (patch) | |
| tree | 99a6cc4ad40dd2309bb4a0b2c875e1719dbfe449 /src | |
| parent | 4626e9f40c4678622ef53cc0d9d3fa89768c08f0 (diff) | |
refactor: initial hardening tweaks
store images in localStorage
Diffstat (limited to 'src')
| -rw-r--r-- | src/.gitignore | 1 | ||||
| -rw-r--r-- | src/app.py | 111 | ||||
| -rw-r--r-- | src/static/main.js | 527 | ||||
| -rw-r--r-- | src/templates/index.html | 27 | ||||
| -rw-r--r-- | src/templates/partials/upload_error.html | 4 | ||||
| -rw-r--r-- | src/templates/partials/upload_result.html | 12 |
6 files changed, 505 insertions, 177 deletions
diff --git a/src/.gitignore b/src/.gitignore index d2ba364..3ca4078 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -3,5 +3,4 @@ __pycache__/ .venv/ likha-pdf generated/ -uploads/ launch-likha-ssb @@ -25,16 +25,15 @@ from werkzeug.middleware.proxy_fix import ProxyFix APP_NAME = "likha-pdf" DEFAULT_HOST = "0.0.0.0" DEFAULT_PORT = 5001 +DEFAULT_MAX_CONTENT_LENGTH = 512 * 1024 * 1024 +DEFAULT_MAX_FORM_MEMORY_SIZE = DEFAULT_MAX_CONTENT_LENGTH BASE_DIR = Path(__file__).resolve().parent GENERATED_DIR = BASE_DIR / "generated" -UPLOADS_DIR = BASE_DIR / "uploads" TEMPLATES_DIR = BASE_DIR / "templates" PARTIALS_DIR = TEMPLATES_DIR / "partials" STATIC_DIR = BASE_DIR / "static" -ALLOWED_IMAGE_EXTS = {"png", "jpg", "jpeg", "gif", "webp", "svg"} - VALID_PAPER_SIZES = { "a0paper", "a1paper", @@ -133,7 +132,6 @@ def env_bool(name, default=False): def ensure_runtime_dirs(): GENERATED_DIR.mkdir(parents=True, exist_ok=True) - UPLOADS_DIR.mkdir(parents=True, exist_ok=True) def random_hex(length=32): @@ -144,26 +142,6 @@ def pick_option(value, fallback, valid): return value if value in valid else fallback -def sanitize_filename(name): - """keep only safe characters in a filename""" - name = os.path.basename(name.replace("\\", "/")) - out = [] - for ch in name: - if ch.isalnum() or ch in "-_.": - out.append(ch) - elif ch == " ": - out.append("_") - return "".join(out) - - -def is_allowed_image(filename): - dot = filename.rfind(".") - if dot < 1 or dot == len(filename) - 1: - return False - ext = filename[dot + 1 :].lower() - return ext in ALLOWED_IMAGE_EXTS - - def is_safe_relative_path(path_part): if not path_part or "\\" in path_part: return False @@ -186,6 +164,20 @@ def tail_text(value, max_len=1200): return value[-max_len:] +def format_bytes(num_bytes): + if num_bytes < 1024: + return f"{num_bytes} B" + + units = ["KB", "MB", "GB", "TB"] + value = float(num_bytes) + for unit in units: + value /= 1024.0 + if value < 1024.0: + return f"{value:.2f} {unit}" + + return f"{value:.2f} PB" + + # pdf stylesheet generator def build_pdf_css( paper_size, @@ -612,9 +604,15 @@ def create_app(): static_url_path="/static", ) - app.config["MAX_CONTENT_LENGTH"] = int( - os.getenv("MAX_CONTENT_LENGTH", str(64 * 1024 * 1024)) + max_content_length = int( + os.getenv("MAX_CONTENT_LENGTH", str(DEFAULT_MAX_CONTENT_LENGTH)) ) + max_form_memory_size = int( + os.getenv("MAX_FORM_MEMORY_SIZE", str(DEFAULT_MAX_FORM_MEMORY_SIZE)) + ) + + app.config["MAX_CONTENT_LENGTH"] = max_content_length + app.config["MAX_FORM_MEMORY_SIZE"] = max_form_memory_size if env_bool("TRUST_PROXY", default=True): app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) @@ -631,11 +629,21 @@ def create_app(): @app.errorhandler(413) def payload_too_large(_err): + content_limit = int(app.config.get("MAX_CONTENT_LENGTH") or 0) + form_limit = int(app.config.get("MAX_FORM_MEMORY_SIZE") or 0) + content_limit_text = ( + format_bytes(content_limit) if content_limit else "configured limit" + ) + form_limit_text = format_bytes(form_limit) if form_limit else "unlimited" return ( read_partial( - "upload_error.html", + "error.html", { - "{{ message }}": "request body too large.", + "{{ message }}": ( + "request body too large. " + f"max request size is {content_limit_text}; " + f"max form field memory is {form_limit_text}." + ), }, ), 413, @@ -728,53 +736,6 @@ def create_app(): }, ) - @app.route("/upload-image", methods=["POST"]) - def upload_image(): - uploaded = request.files.get("image") - if not uploaded or not uploaded.filename or not uploaded.filename.strip(): - return ( - read_partial( - "upload_error.html", - { - "{{ message }}": "image file is required.", - }, - ), - 400, - ) - - original = sanitize_filename(uploaded.filename) - if not original or not is_allowed_image(original): - return ( - read_partial( - "upload_error.html", - { - "{{ message }}": "unsupported image type.", - }, - ), - 400, - ) - - ext = original.rsplit(".", 1)[-1].lower() - stored_name = f"img_{int(time.time())}_{random_hex()}.{ext}" - image_path = UPLOADS_DIR / stored_name - uploaded.save(str(image_path)) - - snippet = f"" - return read_partial( - "upload_result.html", - { - "{{ filename }}": str(escape(stored_name)), - "{{ markdown_snippet }}": str(escape(snippet)), - "{{ preview_url }}": f"/uploads/{stored_name}", - }, - ) - - @app.route("/uploads/<path:filename>") - def serve_upload(filename): - if not is_safe_relative_path(filename): - abort(400) - return send_from_directory(str(UPLOADS_DIR), filename, conditional=True) - @app.route("/download/<path:filename>") def download(filename): if not is_safe_relative_path(filename): diff --git a/src/static/main.js b/src/static/main.js index 9d217cb..180bb91 100644 --- a/src/static/main.js +++ b/src/static/main.js @@ -2,99 +2,501 @@ const convertButton = document.getElementById("convert-button"); const uploadButton = document.getElementById("upload-button"); const markdownInput = document.getElementById("markdown"); const imageInput = document.getElementById("image"); -const mdFileInput = document.getElementById("md-file"); +const convertForm = document.getElementById("convert-form"); +const loadingIndicator = document.getElementById("loading"); +const resultContainer = document.getElementById("result"); +const uploadResultContainer = document.getElementById("upload-result"); +const PERSISTENCE_KEY = "likha-pdf:form-state:v1"; +const IMAGE_DB_NAME = "likha-pdf:image-db:v1"; +const IMAGE_DB_VERSION = 1; +const IMAGE_STORE_NAME = "images"; +const LOCAL_IMAGE_SCHEME = "local-image://"; +const MAX_IMAGE_BYTES = 25 * 1024 * 1024; +const MAX_STORAGE_BYTES = 25 * 1024 * 1024 * 1024; +const MAX_CONVERT_REQUEST_BYTES = 512 * 1024 * 1024; +const LOCAL_IMAGE_TOKEN_PATTERN = /local-image:\/\/([a-zA-Z0-9-]+)/g; +const ALLOWED_IMAGE_EXT_PATTERN = /\.(png|jpe?g|gif|webp|svg)$/i; -// stack overflow session state js -const convertForm=document.getElementById("convert-form"),PERSISTENCE_KEY="likha-pdf:form-state:v1";function readPersistedState(){try{const e=localStorage.getItem(PERSISTENCE_KEY);return e?JSON.parse(e):null}catch{return null}}function writePersistedState(e){try{localStorage.setItem(PERSISTENCE_KEY,JSON.stringify(e))}catch{}}function collectFormState(){if(!(convertForm instanceof HTMLFormElement))return;const e={},t=Array.from(convertForm.elements);for(const n of t){if(!(n instanceof HTMLElement))continue;const t=n.getAttribute("name");t&&"markdown"!==t&&(n instanceof HTMLInputElement?"radio"===n.type?n.checked&&(e[t]=n.value):"checkbox"===n.type&&(e[t]=n.checked):n instanceof HTMLSelectElement&&(e[t]=n.value))}writePersistedState({markdown:markdownInput?.value??"",fields:e})}function restoreFormState(){if(!(convertForm instanceof HTMLFormElement))return;const e=readPersistedState();if(!e||"object"!=typeof e)return;markdownInput&&"string"==typeof e.markdown&&(markdownInput.value=e.markdown,markdownInput.readOnly=!1),imageInput&&(imageInput.disabled=!1),uploadButton&&(uploadButton.disabled=!1);const t=e.fields;if(t&&"object"==typeof t)for(const[e,n]of Object.entries(t)){const t=convertForm.elements.namedItem(e);t&&(t instanceof RadioNodeList?t.value=String(n):t instanceof HTMLElement&&(t instanceof HTMLInputElement&&"checkbox"===t.type?t.checked=Boolean(n):(t instanceof HTMLInputElement||t instanceof HTMLSelectElement)&&(t.value=String(n))))}}restoreFormState(); +function readPersistedState() { + try { + const raw = localStorage.getItem(PERSISTENCE_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function writePersistedState(value) { + try { + localStorage.setItem(PERSISTENCE_KEY, JSON.stringify(value)); + } catch { + // ignore persistence failures in restricted storage contexts + } +} -document.body.addEventListener("htmx:beforeRequest", (event) => { - const elt = event.detail?.elt; - if (!elt) { +function collectFormState() { + if (!(convertForm instanceof HTMLFormElement)) { return; } - if (elt.id === "convert-form" && convertButton) { - convertButton.disabled = true; - convertButton.textContent = "generating..."; + const fields = {}; + const elements = Array.from(convertForm.elements); + + for (const element of elements) { + if (!(element instanceof HTMLElement)) { + continue; + } + + const name = element.getAttribute("name"); + if (!name || name === "markdown") { + continue; + } + + if (element instanceof HTMLInputElement) { + if (element.type === "radio") { + if (element.checked) { + fields[name] = element.value; + } + } else if (element.type === "checkbox") { + fields[name] = element.checked; + } + continue; + } + + if (element instanceof HTMLSelectElement) { + fields[name] = element.value; + } } - if (elt.id === "upload-button" && uploadButton) { - uploadButton.disabled = true; - uploadButton.textContent = "uploading..."; + writePersistedState({ + markdown: markdownInput?.value ?? "", + fields, + }); +} + +function restoreFormState() { + if (!(convertForm instanceof HTMLFormElement)) { + return; } -}); -document.body.addEventListener("htmx:afterRequest", (event) => { - const elt = event.detail?.elt; - if (!elt) { + const state = readPersistedState(); + if (!state || typeof state !== "object") { return; } - if (elt.id === "convert-form" && convertButton) { - convertButton.disabled = false; - convertButton.textContent = "generate pdf"; + if (markdownInput && typeof state.markdown === "string") { + markdownInput.value = state.markdown; + markdownInput.readOnly = false; + } + + if (imageInput) { + imageInput.disabled = false; } - if (elt.id === "upload-button" && uploadButton) { + if (uploadButton) { uploadButton.disabled = false; - uploadButton.textContent = "upload image"; } -}); -if (convertForm instanceof HTMLFormElement) { - convertForm.addEventListener("input", collectFormState); - convertForm.addEventListener("change", collectFormState); + const fields = state.fields; + if (!fields || typeof fields !== "object") { + return; + } + + for (const [name, value] of Object.entries(fields)) { + const element = convertForm.elements.namedItem(name); + if (!element) { + continue; + } + + if (element instanceof RadioNodeList) { + element.value = String(value); + continue; + } + + if ( + element instanceof HTMLInputElement && + element.type === "checkbox" + ) { + element.checked = Boolean(value); + continue; + } + + if (element instanceof HTMLInputElement || element instanceof HTMLSelectElement) { + element.value = String(value); + } + } } -document.body.addEventListener("htmx:afterSwap", (event) => { - const target = event.detail?.target; - const requestElt = event.detail?.requestConfig?.elt; +function setConvertLoadingState(isLoading) { + if (convertButton instanceof HTMLButtonElement) { + convertButton.disabled = isLoading; + convertButton.textContent = isLoading ? "generating..." : "generate pdf"; + } - if (!(target instanceof HTMLElement) || target.id !== "result") { - return; + if (loadingIndicator instanceof HTMLElement) { + loadingIndicator.hidden = !isLoading; } +} - if (!(requestElt instanceof HTMLElement) || requestElt.id !== "convert-form") { - return; +function setUploadLoadingState(isLoading) { + if (uploadButton instanceof HTMLButtonElement) { + uploadButton.disabled = isLoading; + uploadButton.textContent = isLoading ? "preparing..." : "insert image"; } +} - target.scrollIntoView({ behavior: "smooth", block: "start" }); -}); +function escapeHtml(value) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} -if (mdFileInput) { - mdFileInput.addEventListener("change", () => { - const file = mdFileInput.files?.[0]; +function buildLocalImageSnippet(name, id) { + const cleanName = String(name || "image").replace(/[\]\r\n]/g, ""); + return ``; +} - if (file) { - const reader = new FileReader(); - reader.onload = (e) => { - if (markdownInput) { - markdownInput.value = /** @type {string} */ (e.target.result); - markdownInput.readOnly = true; - } - if (imageInput) { - imageInput.disabled = true; +function isAllowedImageFile(file) { + if (file.type.startsWith("image/")) { + return true; + } + + return ALLOWED_IMAGE_EXT_PATTERN.test(file.name || ""); +} + +function makeImageId() { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + return `img-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; +} + +function requestToPromise(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error("indexeddb request failed")); + }); +} + +function transactionDone(transaction) { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => + reject(transaction.error || new Error("indexeddb transaction failed")); + transaction.onabort = () => + reject(transaction.error || new Error("indexeddb transaction aborted")); + }); +} + +function openImageDb() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(IMAGE_DB_NAME, IMAGE_DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(IMAGE_STORE_NAME)) { + db.createObjectStore(IMAGE_STORE_NAME, { keyPath: "id" }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error || new Error("failed to open indexeddb")); + }); +} + +async function saveImageRecord(record) { + const db = await openImageDb(); + + try { + const transaction = db.transaction(IMAGE_STORE_NAME, "readwrite"); + transaction.objectStore(IMAGE_STORE_NAME).put(record); + await transactionDone(transaction); + } finally { + db.close(); + } +} + +async function getImageRecord(imageId) { + const db = await openImageDb(); + + try { + const transaction = db.transaction(IMAGE_STORE_NAME, "readonly"); + const request = transaction.objectStore(IMAGE_STORE_NAME).get(imageId); + const record = await requestToPromise(request); + await transactionDone(transaction); + return record; + } finally { + db.close(); + } +} + +async function getImageUsageStats() { + const db = await openImageDb(); + + try { + const transaction = db.transaction(IMAGE_STORE_NAME, "readonly"); + const store = transaction.objectStore(IMAGE_STORE_NAME); + + let count = 0; + let totalBytes = 0; + + await new Promise((resolve, reject) => { + const request = store.openCursor(); + + request.onsuccess = () => { + const cursor = request.result; + if (!cursor) { + resolve(); + return; } - if (uploadButton) { - uploadButton.disabled = true; + + count += 1; + let sizeBytes = Number(cursor.value?.sizeBytes); + if (!Number.isFinite(sizeBytes) || sizeBytes < 0) { + sizeBytes = Number(cursor.value?.blob?.size) || 0; } - collectFormState(); + totalBytes += sizeBytes; + cursor.continue(); }; - reader.readAsText(file); - } else { - if (markdownInput) { - markdownInput.value = ""; - markdownInput.readOnly = false; - } - if (imageInput) { - imageInput.disabled = false; - } - if (uploadButton) { - uploadButton.disabled = false; - } - collectFormState(); + + request.onerror = () => reject(request.error || new Error("failed to read image usage")); + }); + + await transactionDone(transaction); + return { count, totalBytes }; + } finally { + db.close(); + } +} + +function getUniqueLocalImageIds(markdown) { + const ids = new Set(); + let match = null; + + while ((match = LOCAL_IMAGE_TOKEN_PATTERN.exec(markdown)) !== null) { + const imageId = match[1]; + if (imageId) { + ids.add(imageId); + } + } + + LOCAL_IMAGE_TOKEN_PATTERN.lastIndex = 0; + return Array.from(ids); +} + +function blobToDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(String(reader.result || "")); + reader.onerror = () => reject(reader.error || new Error("failed to read image data")); + reader.readAsDataURL(blob); + }); +} + +async function resolveLocalImageTokens(markdown) { + const ids = getUniqueLocalImageIds(markdown); + if (ids.length === 0) { + return { resolvedMarkdown: markdown, missingIds: [] }; + } + + let resolvedMarkdown = markdown; + const missingIds = []; + + for (const imageId of ids) { + const record = await getImageRecord(imageId); + if (!record || !(record.blob instanceof Blob)) { + missingIds.push(imageId); + continue; + } + + const dataUrl = await blobToDataUrl(record.blob); + resolvedMarkdown = resolvedMarkdown + .split(`${LOCAL_IMAGE_SCHEME}${imageId}`) + .join(dataUrl); + } + + return { resolvedMarkdown, missingIds }; +} + +let activePreviewUrl = ""; + +function showUploadError(message) { + if (!(uploadResultContainer instanceof HTMLElement)) { + return; + } + + uploadResultContainer.innerHTML = ` + <article> + <h4>image upload failed</h4> + <pre>${escapeHtml(message)}</pre> + </article> + `; +} + +function showUploadResult(record) { + if (!(uploadResultContainer instanceof HTMLElement)) { + return; + } + + if (activePreviewUrl) { + URL.revokeObjectURL(activePreviewUrl); + activePreviewUrl = ""; + } + + const snippet = buildLocalImageSnippet(record.name, record.id); + activePreviewUrl = URL.createObjectURL(record.blob); + + uploadResultContainer.innerHTML = ` + <article> + <h4>image ready for insert</h4> + <p><a href="${escapeHtml(activePreviewUrl)}" target="_blank" rel="noreferrer">${escapeHtml(record.name)}</a></p> + <p><code id="uploaded-markdown">${escapeHtml(snippet)}</code></p> + <button type="button" data-insert-markdown="${escapeHtml(snippet)}" class="insert-markdown">insert into markdown</button> + </article> + `; +} + +async function handleInsertImage() { + if (!(imageInput instanceof HTMLInputElement)) { + return; + } + + const file = imageInput.files?.[0]; + if (!file) { + showUploadError("image file is required."); + return; + } + + if (!isAllowedImageFile(file)) { + showUploadError("unsupported image type."); + return; + } + + if (file.size > MAX_IMAGE_BYTES) { + showUploadError("image is too large. maximum size per image is 25MB."); + return; + } + + setUploadLoadingState(true); + + try { + const { totalBytes } = await getImageUsageStats(); + if (totalBytes + file.size > MAX_STORAGE_BYTES) { + showUploadError("image upload limit reached. maximum total image capacity is 25GB."); + return; + } + + const record = { + id: makeImageId(), + name: file.name || "image", + mimeType: file.type || "application/octet-stream", + sizeBytes: file.size, + createdAt: Date.now(), + blob: file, + }; + + await saveImageRecord(record); + showUploadResult(record); + imageInput.value = ""; + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : "image upload failed."; + showUploadError(message); + } finally { + setUploadLoadingState(false); + } +} + +function showConvertError(message) { + if (!(resultContainer instanceof HTMLElement)) { + return; + } + + resultContainer.innerHTML = ` + <article> + <h3>Conversion failed</h3> + <pre>${escapeHtml(message)}</pre> + </article> + `; + resultContainer.scrollIntoView({ behavior: "smooth", block: "start" }); +} + +async function handleConvertSubmit(event) { + event.preventDefault(); + + if (!(convertForm instanceof HTMLFormElement)) { + return; + } + + const formData = new FormData(convertForm); + const markdownValue = formData.get("markdown"); + const markdown = typeof markdownValue === "string" ? markdownValue : ""; + + if (!markdown.trim()) { + showConvertError("Markdown content is required."); + return; + } + + setConvertLoadingState(true); + + try { + const { resolvedMarkdown, missingIds } = await resolveLocalImageTokens(markdown); + if (missingIds.length > 0) { + showConvertError("one or more local images are missing from browser storage."); + return; + } + + const markdownBytes = new Blob([resolvedMarkdown]).size; + if (markdownBytes > MAX_CONVERT_REQUEST_BYTES) { + showConvertError( + "resolved markdown is too large to send for conversion. reduce inserted images or set a higher MAX_CONTENT_LENGTH on the server." + ); + return; } + + formData.set("markdown", resolvedMarkdown); + + const response = await fetch("/convert", { + method: "POST", + body: formData, + }); + + const responseHtml = await response.text(); + if (resultContainer instanceof HTMLElement) { + resultContainer.innerHTML = responseHtml; + resultContainer.scrollIntoView({ behavior: "smooth", block: "start" }); + } + } catch (error) { + const message = + error instanceof Error && error.message + ? error.message + : "failed to generate pdf."; + showConvertError(message); + } finally { + setConvertLoadingState(false); + } +} + +restoreFormState(); + +if (convertForm instanceof HTMLFormElement) { + convertForm.addEventListener("input", collectFormState); + convertForm.addEventListener("change", collectFormState); + convertForm.addEventListener("submit", (event) => { + void handleConvertSubmit(event); + }); +} + +if (uploadButton instanceof HTMLButtonElement) { + uploadButton.addEventListener("click", () => { + void handleInsertImage(); }); } @@ -120,3 +522,4 @@ document.body.addEventListener("click", (event) => { markdownInput.focus(); collectFormState(); }); + diff --git a/src/templates/index.html b/src/templates/index.html index 194a2af..3af58ad 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -7,17 +7,6 @@ <title>likha-pdf</title> <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/kj-sh604/noir.css@latest/out/noir.min.css" /> - <script src="https://unpkg.com/htmx.org@1.9.12"></script> - <style> - .htmx-indicator { - display: none; - } - - .htmx-request .htmx-indicator, - .htmx-request.htmx-indicator { - display: inline; - } - </style> </head> <body> @@ -25,7 +14,7 @@ <h1>likha-pdf</h1> <p>simple markdown to pdf export.</p> - <form id="convert-form" hx-post="/convert" hx-target="#result" hx-swap="innerHTML" hx-indicator="#loading"> + <form id="convert-form" action="/convert" method="post"> <label for="markdown"> <h3>textarea</h3> </label> @@ -34,19 +23,11 @@ <section id="image-upload-section"> <label for="image"><small>image file</small></label> - <input id="image" name="image" type="file" accept="image/*" /> - <button id="upload-button" type="button" hx-post="/upload-image" hx-target="#upload-result" hx-swap="innerHTML" - hx-encoding="multipart/form-data" hx-include="#image" hx-indicator="#uploading">upload image</button> - <small id="uploading" class="htmx-indicator">uploading…</small> + <input id="image" type="file" accept="image/*" /> + <button id="upload-button" type="button">insert image</button> <div id="upload-result" aria-live="polite"></div> </section> - <section id="md-file-section"> - <h3>or upload a markdown file</h3> - <label for="md-file"><small><em>(overrides textarea)</em></small></label> - <input id="md-file" type="file" accept=".md,.markdown,text/markdown,text/plain" /> - </section> - <section> <h3>pdf options</h3> @@ -133,7 +114,7 @@ </section> <button id="convert-button" type="submit">generate pdf</button> - <small id="loading" class="htmx-indicator">converting…</small> + <small id="loading" hidden>converting...</small> </form> <section id="result" aria-live="polite"></section> diff --git a/src/templates/partials/upload_error.html b/src/templates/partials/upload_error.html deleted file mode 100644 index d219783..0000000 --- a/src/templates/partials/upload_error.html +++ /dev/null @@ -1,4 +0,0 @@ -<article> - <h4>upload failed</h4> - <pre>{{ message }}</pre> -</article> diff --git a/src/templates/partials/upload_result.html b/src/templates/partials/upload_result.html deleted file mode 100644 index 116c74c..0000000 --- a/src/templates/partials/upload_result.html +++ /dev/null @@ -1,12 +0,0 @@ -<article> - <h4>image uploaded</h4> - <p><a href="{{ preview_url }}" target="_blank" rel="noreferrer">{{ filename }}</a></p> - <p><code id="uploaded-markdown">{{ markdown_snippet }}</code></p> - <button - type="button" - data-insert-markdown="{{ markdown_snippet }}" - class="insert-markdown" - > - insert into markdown - </button> -</article> |
