Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>

This commit is contained in:
OpenClaw Bot 2026-02-20 12:56:05 -06:00
parent 5ba0edd856
commit dc6722cd3f
4 changed files with 671 additions and 35 deletions

View File

@ -4,9 +4,19 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
## Current Product Behavior
### Feb 20, 2026 updates
- Added task attachments with SQLite persistence.
- Added URL-based task detail pages (`/tasks/{taskId}`) so tasks can be opened/shared by link.
- Replaced flat notes/comments with threaded comments.
- Added unlimited nested replies (reply to comment, reply to reply, no depth limit).
- Updated UI wording from "notes" to "comments".
- Improved attachment opening/rendering by coercing MIME types (including `.md` as text) and using blob URLs for reliable in-browser viewing.
### Data model and status rules
- Tasks use labels (`tags: string[]`) and can have multiple labels.
- Tasks support attachments (`attachments: TaskAttachment[]`).
- There is no active `backlog` status in workflow logic.
- A task is considered in Backlog when `sprintId` is empty.
- Current status values:
@ -26,13 +36,44 @@ Task and sprint board built with Next.js + Zustand and SQLite-backed API persist
- Project selection UI for tasks was removed in favor of labels.
- You can add/remove labels inline in:
- New Task modal
- Task Detail modal
- Task Detail page
- Label entry supports:
- Enter/comma to add
- Existing-label suggestions
- Quick-add chips
- Case-insensitive de-duplication
### Task detail pages
- Clicking a task from Kanban or Backlog opens a dedicated route:
- `/tasks/{taskId}`
- Task detail is no longer popup-only behavior.
- Each task now has a shareable deep link.
### Attachments
- Task detail page supports adding multiple attachments per task.
- Attachments are stored with each task in SQLite and survive refresh/restart.
- Attachment UI supports:
- Upload multiple files
- Open/view file
- Markdown (`.md`) preview rendering in browser tab
- Text/code preview for common file types including iOS project/code files (`.swift`, `.plist`, `.pbxproj`, `.storyboard`, `.xib`, `.xcconfig`, `.entitlements`)
- Syntax-highlighted source rendering (highlight.js) with copy-source action
- CSV table preview and sandboxed HTML preview with source view
- Download file
- Remove attachment
### Threaded comments
- Comments are now threaded, not flat.
- You can:
- Add top-level comments
- Reply to any comment
- Reply to replies recursively (unlimited depth)
- Delete any comment or reply from the thread
- Thread data is persisted in SQLite through the existing `/api/tasks` sync flow.
### Backlog drag and drop
Backlog view supports moving tasks between:
@ -85,6 +126,7 @@ npm run dev
```
Open `http://localhost:3000`.
Task URLs follow `http://localhost:3000/tasks/{taskId}`.
## Scripts

View File

@ -22,6 +22,15 @@ import { Badge } from "@/components/ui/badge"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import {
blobFromDataUrl,
coerceDataUrlMimeType,
inferAttachmentMimeType,
isMarkdownAttachment,
isTextPreviewAttachment,
markdownPreviewObjectUrl,
textPreviewObjectUrl,
} from "@/lib/attachments"
import { useTaskStore, Task, TaskType, TaskStatus, Priority, TaskAttachment } from "@/stores/useTaskStore"
import { BacklogView } from "@/components/BacklogView"
import { Plus, MessageSquare, Calendar, Trash2, X, LayoutGrid, ListTodo, GripVertical, Paperclip, Download } from "lucide-react"
@ -552,14 +561,19 @@ export default function Home() {
try {
const uploadedAt = new Date().toISOString()
const attachments = await Promise.all(
files.map(async (file) => ({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: file.name,
type: file.type || "application/octet-stream",
size: file.size,
dataUrl: await readFileAsDataUrl(file),
uploadedAt,
}))
files.map(async (file) => {
const type = inferAttachmentMimeType(file.name, file.type)
const rawDataUrl = await readFileAsDataUrl(file)
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: file.name,
type,
size: file.size,
dataUrl: coerceDataUrlMimeType(rawDataUrl, type),
uploadedAt,
}
})
)
setEditedTask((prev) => {
@ -576,6 +590,38 @@ export default function Home() {
}
}
const openAttachment = async (attachment: TaskAttachment) => {
try {
const mimeType = inferAttachmentMimeType(attachment.name, attachment.type)
const objectUrl = isMarkdownAttachment(attachment.name, mimeType)
? await markdownPreviewObjectUrl(attachment.name, attachment.dataUrl)
: isTextPreviewAttachment(attachment.name, mimeType)
? await textPreviewObjectUrl(attachment.name, attachment.dataUrl, mimeType)
: URL.createObjectURL(await blobFromDataUrl(attachment.dataUrl, mimeType))
window.open(objectUrl, "_blank", "noopener,noreferrer")
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
} catch (error) {
console.error("Failed to open attachment:", error)
}
}
const downloadAttachment = async (attachment: TaskAttachment) => {
try {
const mimeType = inferAttachmentMimeType(attachment.name, attachment.type)
const blob = await blobFromDataUrl(attachment.dataUrl, mimeType)
const objectUrl = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = objectUrl
link.download = attachment.name
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
} catch (error) {
console.error("Failed to download attachment:", error)
}
}
return (
<div className="min-h-screen bg-slate-950 text-slate-100">
{/* Header */}
@ -1109,27 +1155,26 @@ export default function Home() {
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-800 bg-slate-800/50"
>
<div className="min-w-0">
<a
href={attachment.dataUrl}
target="_blank"
rel="noreferrer"
<button
type="button"
onClick={() => openAttachment(attachment)}
className="text-sm text-blue-300 hover:text-blue-200 truncate block"
>
{attachment.name}
</a>
</button>
<p className="text-xs text-slate-500 mt-1">
{formatBytes(attachment.size)} · {new Date(attachment.uploadedAt).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<a
href={attachment.dataUrl}
download={attachment.name}
<button
type="button"
onClick={() => downloadAttachment(attachment)}
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700"
title="Download attachment"
>
<Download className="w-3.5 h-3.5" />
</a>
</button>
<button
type="button"
onClick={() =>

View File

@ -7,6 +7,15 @@ import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
blobFromDataUrl,
coerceDataUrlMimeType,
inferAttachmentMimeType,
isMarkdownAttachment,
isTextPreviewAttachment,
markdownPreviewObjectUrl,
textPreviewObjectUrl,
} from "@/lib/attachments"
import {
useTaskStore,
type Comment as TaskComment,
@ -203,14 +212,19 @@ export default function TaskDetailPage() {
try {
const uploadedAt = new Date().toISOString()
const attachments = await Promise.all(
files.map(async (file) => ({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: file.name,
type: file.type || "application/octet-stream",
size: file.size,
dataUrl: await readFileAsDataUrl(file),
uploadedAt,
}))
files.map(async (file) => {
const type = inferAttachmentMimeType(file.name, file.type)
const rawDataUrl = await readFileAsDataUrl(file)
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: file.name,
type,
size: file.size,
dataUrl: coerceDataUrlMimeType(rawDataUrl, type),
uploadedAt,
}
})
)
setEditedTask((prev) => {
@ -270,6 +284,38 @@ export default function TaskDetailPage() {
setIsSaving(false)
}
const openAttachment = async (attachment: TaskAttachment) => {
try {
const mimeType = inferAttachmentMimeType(attachment.name, attachment.type)
const objectUrl = isMarkdownAttachment(attachment.name, mimeType)
? await markdownPreviewObjectUrl(attachment.name, attachment.dataUrl)
: isTextPreviewAttachment(attachment.name, mimeType)
? await textPreviewObjectUrl(attachment.name, attachment.dataUrl, mimeType)
: URL.createObjectURL(await blobFromDataUrl(attachment.dataUrl, mimeType))
window.open(objectUrl, "_blank", "noopener,noreferrer")
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
} catch (error) {
console.error("Failed to open attachment:", error)
}
}
const downloadAttachment = async (attachment: TaskAttachment) => {
try {
const mimeType = inferAttachmentMimeType(attachment.name, attachment.type)
const blob = await blobFromDataUrl(attachment.dataUrl, mimeType)
const objectUrl = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = objectUrl
link.download = attachment.name
document.body.appendChild(link)
link.click()
link.remove()
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
} catch (error) {
console.error("Failed to download attachment:", error)
}
}
const handleDelete = () => {
if (!editedTask) return
if (!window.confirm("Delete this task?")) return
@ -554,27 +600,26 @@ export default function TaskDetailPage() {
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-800 bg-slate-800/50"
>
<div className="min-w-0">
<a
href={attachment.dataUrl}
target="_blank"
rel="noreferrer"
<button
type="button"
onClick={() => openAttachment(attachment)}
className="text-sm text-blue-300 hover:text-blue-200 truncate block"
>
{attachment.name}
</a>
</button>
<p className="text-xs text-slate-500 mt-1">
{formatBytes(attachment.size)} · {new Date(attachment.uploadedAt).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<a
href={attachment.dataUrl}
download={attachment.name}
<button
type="button"
onClick={() => downloadAttachment(attachment)}
className="p-1.5 rounded text-slate-400 hover:text-slate-200 hover:bg-slate-700"
title="Download attachment"
>
<Download className="w-3.5 h-3.5" />
</a>
</button>
<button
type="button"
onClick={() =>

504
src/lib/attachments.ts Normal file
View File

@ -0,0 +1,504 @@
export function inferAttachmentMimeType(fileName: string, declaredType?: string): string {
const safeDeclaredType = typeof declaredType === "string" ? declaredType.trim() : ""
if (safeDeclaredType && safeDeclaredType !== "application/octet-stream") {
return safeDeclaredType
}
const extension = fileName.toLowerCase().split(".").pop() || ""
const extensionToMime: Record<string, string> = {
md: "text/plain",
markdown: "text/plain",
txt: "text/plain",
log: "text/plain",
json: "application/json",
csv: "text/csv",
yml: "text/plain",
yaml: "text/plain",
html: "text/html",
htm: "text/html",
xml: "application/xml",
js: "text/javascript",
mjs: "text/javascript",
cjs: "text/javascript",
ts: "text/plain",
tsx: "text/plain",
jsx: "text/plain",
swift: "text/plain",
m: "text/plain",
mm: "text/plain",
pbxproj: "text/plain",
xcconfig: "text/plain",
strings: "text/plain",
plist: "application/xml",
stringsdict: "application/xml",
entitlements: "application/xml",
storyboard: "application/xml",
xib: "application/xml",
metal: "text/plain",
css: "text/css",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
pdf: "application/pdf",
}
return extensionToMime[extension] || "application/octet-stream"
}
export function isMarkdownAttachment(fileName: string, mimeType?: string): boolean {
const extension = fileName.toLowerCase().split(".").pop() || ""
if (extension === "md" || extension === "markdown") return true
return (mimeType || "").toLowerCase() === "text/markdown"
}
export function isTextPreviewAttachment(fileName: string, mimeType?: string): boolean {
const extension = fileName.toLowerCase().split(".").pop() || ""
const textLikeExtensions = new Set([
"txt",
"log",
"json",
"csv",
"xml",
"yml",
"yaml",
"ini",
"cfg",
"conf",
"env",
"toml",
"properties",
"sql",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"py",
"rb",
"php",
"go",
"rs",
"java",
"kt",
"swift",
"pbxproj",
"plist",
"strings",
"stringsdict",
"xcconfig",
"entitlements",
"storyboard",
"xib",
"m",
"mm",
"metal",
"c",
"h",
"cpp",
"hpp",
"cs",
"js",
"mjs",
"cjs",
"ts",
"tsx",
"jsx",
"css",
"scss",
"sass",
"less",
"vue",
"svelte",
"html",
"htm",
])
const safeMimeType = (mimeType || "").toLowerCase()
if (textLikeExtensions.has(extension)) return true
if (safeMimeType.startsWith("text/")) return true
if (safeMimeType === "application/json" || safeMimeType === "application/xml") return true
return false
}
export function coerceDataUrlMimeType(dataUrl: string, mimeType: string): string {
if (!dataUrl.startsWith("data:")) return dataUrl
const commaIndex = dataUrl.indexOf(",")
if (commaIndex === -1) return dataUrl
const meta = dataUrl.slice(0, commaIndex)
const payload = dataUrl.slice(commaIndex + 1)
const hasBase64 = meta.includes(";base64")
const encodingSuffix = hasBase64 ? ";base64" : ""
return `data:${mimeType}${encodingSuffix},${payload}`
}
export async function blobFromDataUrl(dataUrl: string, mimeTypeOverride?: string): Promise<Blob> {
const response = await fetch(dataUrl)
const blob = await response.blob()
if (!mimeTypeOverride || blob.type === mimeTypeOverride) return blob
const buffer = await blob.arrayBuffer()
return new Blob([buffer], { type: mimeTypeOverride })
}
export async function textFromDataUrl(dataUrl: string): Promise<string> {
const response = await fetch(dataUrl)
return response.text()
}
function safeScriptString(value: string): string {
return JSON.stringify(value).replace(/<\/script/gi, "<\\/script")
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
function getExtension(fileName: string): string {
return fileName.toLowerCase().split(".").pop() || ""
}
function isJsonAttachment(fileName: string, mimeType?: string): boolean {
const extension = getExtension(fileName)
return extension === "json" || (mimeType || "").toLowerCase() === "application/json"
}
function isCsvAttachment(fileName: string, mimeType?: string): boolean {
const extension = getExtension(fileName)
return extension === "csv" || (mimeType || "").toLowerCase() === "text/csv"
}
function isHtmlAttachment(fileName: string, mimeType?: string): boolean {
const extension = getExtension(fileName)
const safeMimeType = (mimeType || "").toLowerCase()
return extension === "html" || extension === "htm" || safeMimeType === "text/html"
}
function inferCodeLanguage(fileName: string, mimeType?: string): string {
const extension = getExtension(fileName)
const safeMimeType = (mimeType || "").toLowerCase()
const extensionToLanguage: Record<string, string> = {
swift: "swift",
m: "objectivec",
mm: "objectivec",
h: "objectivec",
pbxproj: "ini",
xcconfig: "ini",
strings: "ini",
stringsdict: "xml",
plist: "xml",
entitlements: "xml",
storyboard: "xml",
xib: "xml",
metal: "cpp",
json: "json",
csv: "plaintext",
yml: "yaml",
yaml: "yaml",
xml: "xml",
html: "xml",
htm: "xml",
js: "javascript",
mjs: "javascript",
cjs: "javascript",
ts: "typescript",
tsx: "typescript",
jsx: "javascript",
css: "css",
scss: "scss",
sass: "scss",
less: "less",
sh: "bash",
bash: "bash",
zsh: "bash",
fish: "bash",
ps1: "powershell",
py: "python",
rb: "ruby",
php: "php",
go: "go",
rs: "rust",
java: "java",
kt: "kotlin",
c: "c",
cpp: "cpp",
hpp: "cpp",
cs: "csharp",
sql: "sql",
vue: "xml",
svelte: "xml",
md: "markdown",
markdown: "markdown",
txt: "plaintext",
log: "plaintext",
ini: "ini",
cfg: "ini",
conf: "ini",
env: "ini",
toml: "ini",
properties: "ini",
}
const fromExtension = extensionToLanguage[extension]
if (fromExtension) return fromExtension
if (safeMimeType === "application/json") return "json"
if (safeMimeType === "application/xml" || safeMimeType === "text/xml") return "xml"
if (safeMimeType === "text/html") return "xml"
if (safeMimeType === "text/css") return "css"
if (safeMimeType === "text/javascript") return "javascript"
return "plaintext"
}
function parseCsvLine(line: string): string[] {
const cells: string[] = []
let current = ""
let index = 0
let inQuotes = false
while (index < line.length) {
const char = line[index]
if (char === '"') {
const next = line[index + 1]
if (inQuotes && next === '"') {
current += '"'
index += 2
continue
}
inQuotes = !inQuotes
index += 1
continue
}
if (char === "," && !inQuotes) {
cells.push(current)
current = ""
index += 1
continue
}
current += char
index += 1
}
cells.push(current)
return cells
}
function csvTableHtml(content: string): string {
const lines = content.split(/\r?\n/).filter((line) => line.trim().length > 0).slice(0, 200)
if (lines.length === 0) return '<p class="muted">No CSV rows to preview.</p>'
const rows = lines.map(parseCsvLine)
const maxColumns = rows.reduce((max, row) => Math.max(max, row.length), 0)
const normalizedRows = rows.map((row) => [...row, ...Array.from({ length: maxColumns - row.length }, () => "")])
const header = normalizedRows[0]
const body = normalizedRows.slice(1)
const headerCells = header.map((cell) => `<th>${escapeHtml(cell || "Column")}</th>`).join("")
const bodyRows = body
.map((row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}</tr>`)
.join("")
return `<table><thead><tr>${headerCells}</tr></thead><tbody>${bodyRows}</tbody></table>`
}
function prettyJsonOrRaw(content: string): string {
try {
return JSON.stringify(JSON.parse(content), null, 2)
} catch {
return content
}
}
export function createTextPreviewHtml(fileName: string, content: string, mimeType?: string): string {
const safeTitle = escapeHtml(fileName)
const safeMimeType = (mimeType || "").toLowerCase()
const isHtml = isHtmlAttachment(fileName, safeMimeType)
const isJson = isJsonAttachment(fileName, safeMimeType)
const isCsv = isCsvAttachment(fileName, safeMimeType)
const language = inferCodeLanguage(fileName, safeMimeType)
const displayedText = isJson ? prettyJsonOrRaw(content) : content
const sourceLiteral = safeScriptString(displayedText)
const htmlLiteral = safeScriptString(content)
const renderedCsv = isCsv ? csvTableHtml(content) : ""
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${safeTitle}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/styles/github-dark.min.css" />
<style>
body { margin: 0; background: #0f172a; color: #e2e8f0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
.container { max-width: 1100px; margin: 0 auto; padding: 24px; }
.header { color: #94a3b8; font-size: 13px; margin-bottom: 14px; }
.panel { border: 1px solid #1e293b; background: #020617; border-radius: 10px; padding: 14px; margin-bottom: 14px; overflow: auto; }
.panel-toolbar { display: flex; align-items: center; justify-content: space-between; margin: 6px 0 8px; }
.copy-btn { border: 1px solid #334155; background: #111827; color: #e2e8f0; border-radius: 7px; font-size: 12px; padding: 4px 8px; cursor: pointer; }
.copy-btn:hover { border-color: #475569; }
.source-meta { color: #64748b; font-size: 12px; }
.code { margin: 0; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace; font-size: 13px; line-height: 1.55; }
.hljs { background: #020617 !important; border-radius: 8px; padding: 0 !important; }
.muted { color: #94a3b8; font-size: 13px; }
table { border-collapse: collapse; width: 100%; font-size: 13px; }
th, td { border: 1px solid #334155; padding: 6px 8px; text-align: left; vertical-align: top; }
th { background: #111827; color: #f8fafc; position: sticky; top: 0; }
.iframe-wrap { border: 1px solid #1e293b; border-radius: 10px; overflow: hidden; background: #ffffff; }
iframe { display: block; width: 100%; min-height: 360px; border: 0; background: #fff; }
details { border: 1px solid #1e293b; border-radius: 10px; background: #020617; padding: 10px 12px; }
summary { cursor: pointer; color: #cbd5e1; font-size: 13px; }
</style>
</head>
<body>
<main class="container">
<div class="header">Attachment Preview: ${safeTitle}${safeMimeType ? ` · ${escapeHtml(safeMimeType)}` : ""}</div>
${isHtml ? `<div class="iframe-wrap"><iframe sandbox="" id="html-frame"></iframe></div>` : ""}
${isCsv ? `<div class="panel">${renderedCsv}</div>` : ""}
${isHtml ? '<details><summary>View source</summary>' : ""}
<div class="panel-toolbar">
<button id="copy-source" type="button" class="copy-btn">Copy source</button>
<span id="source-meta" class="source-meta"></span>
</div>
<div class="panel">
<pre class="code"><code id="source-code" class="language-${language}"></code></pre>
</div>
${isHtml ? '</details>' : ""}
</main>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/lib/common.min.js"></script>
<script>
const sourceText = ${sourceLiteral};
const rawHtml = ${htmlLiteral};
const sourceCodeElement = document.getElementById("source-code");
if (sourceCodeElement) sourceCodeElement.textContent = sourceText;
const sourceMetaElement = document.getElementById("source-meta");
if (sourceMetaElement) {
const lineCount = sourceText.length === 0 ? 0 : sourceText.split(/\\r?\\n/).length;
sourceMetaElement.textContent = "${escapeHtml(language)} · " + lineCount + " lines";
}
if (window.hljs && sourceCodeElement) {
try {
window.hljs.highlightElement(sourceCodeElement);
} catch (_error) {
// no-op fallback to plain text
}
}
const copyButton = document.getElementById("copy-source");
if (copyButton && navigator.clipboard && navigator.clipboard.writeText) {
copyButton.addEventListener("click", async () => {
const originalLabel = copyButton.textContent || "Copy source";
try {
await navigator.clipboard.writeText(sourceText);
copyButton.textContent = "Copied";
setTimeout(() => { copyButton.textContent = originalLabel; }, 1200);
} catch (_error) {
copyButton.textContent = "Copy failed";
setTimeout(() => { copyButton.textContent = originalLabel; }, 1500);
}
});
}
const htmlFrame = document.getElementById("html-frame");
if (htmlFrame) htmlFrame.srcdoc = rawHtml;
</script>
</body>
</html>`
}
export function createMarkdownPreviewHtml(fileName: string, markdown: string): string {
const safeTitle = fileName.replace(/[<>&"]/g, (char) => {
if (char === "<") return "&lt;"
if (char === ">") return "&gt;"
if (char === "&") return "&amp;"
return "&quot;"
})
const markdownLiteral = safeScriptString(markdown)
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${safeTitle}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/styles/github-dark.min.css" />
<style>
body { margin: 0; background: #0f172a; color: #e2e8f0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
.container { max-width: 900px; margin: 0 auto; padding: 24px; }
.header { margin-bottom: 16px; color: #94a3b8; font-size: 14px; }
.markdown { line-height: 1.7; color: #e2e8f0; }
.markdown h1, .markdown h2, .markdown h3, .markdown h4 { color: #f8fafc; margin-top: 1.3em; margin-bottom: 0.4em; }
.markdown pre { background: #020617; border: 1px solid #1e293b; border-radius: 8px; padding: 12px; overflow: auto; }
.markdown code { background: #020617; border: 1px solid #1e293b; border-radius: 6px; padding: 0.1em 0.35em; }
.markdown pre code { background: transparent; border: 0; padding: 0; }
.markdown a { color: #60a5fa; }
.markdown blockquote { border-left: 3px solid #334155; margin: 0.8em 0; padding-left: 12px; color: #cbd5e1; }
.markdown table { border-collapse: collapse; width: 100%; }
.markdown th, .markdown td { border: 1px solid #334155; padding: 6px 8px; }
.markdown hr { border: 0; border-top: 1px solid #334155; margin: 1.5em 0; }
.fallback { white-space: pre-wrap; background: #020617; border: 1px solid #1e293b; border-radius: 8px; padding: 12px; color: #e2e8f0; }
</style>
</head>
<body>
<main class="container">
<div class="header">Markdown Preview: ${safeTitle}</div>
<article id="root" class="markdown"></article>
</main>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.11.1/lib/common.min.js"></script>
<script>
const rawMarkdown = ${markdownLiteral};
const markdown = rawMarkdown.replace(/</g, "&lt;").replace(/>/g, "&gt;");
const root = document.getElementById("root");
const fallback = () => {
const pre = document.createElement("pre");
pre.className = "fallback";
pre.textContent = rawMarkdown;
root.innerHTML = "";
root.appendChild(pre);
};
try {
if (window.marked && typeof window.marked.parse === "function") {
root.innerHTML = window.marked.parse(markdown);
if (window.hljs) {
root.querySelectorAll("pre code").forEach((block) => {
try {
window.hljs.highlightElement(block);
} catch (_error) {
// no-op fallback to plain rendered code block
}
});
}
} else {
fallback();
}
} catch (_error) {
fallback();
}
</script>
</body>
</html>`;
}
export async function markdownPreviewObjectUrl(fileName: string, dataUrl: string): Promise<string> {
const markdown = await textFromDataUrl(dataUrl)
const html = createMarkdownPreviewHtml(fileName, markdown)
return URL.createObjectURL(new Blob([html], { type: "text/html" }))
}
export async function textPreviewObjectUrl(fileName: string, dataUrl: string, mimeType?: string): Promise<string> {
const content = await textFromDataUrl(dataUrl)
const html = createTextPreviewHtml(fileName, content, mimeType)
return URL.createObjectURL(new Blob([html], { type: "text/html" }))
}