- Add Projects page with Sprint Board and Backlog views - Copy SprintBoard and BacklogView components to components/gantt/ - Copy useTaskStore for project/task/sprint management - Add API routes for task persistence with SQLite - Add UI components: dialog, select, table, textarea - Add avatar and attachment utilities - Update sidebar with Projects navigation link - Remove static export config to support API routes - Add dist to .gitignore
505 lines
17 KiB
TypeScript
505 lines
17 KiB
TypeScript
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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
}
|
|
|
|
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 "<"
|
|
if (char === ">") return ">"
|
|
if (char === "&") return "&"
|
|
return """
|
|
})
|
|
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, "<").replace(/>/g, ">");
|
|
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" }))
|
|
}
|