Signed-off-by: OpenClaw Bot <ai-agent@topdoglabs.com>
This commit is contained in:
parent
5ba0edd856
commit
dc6722cd3f
44
README.md
44
README.md
@ -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
|
||||
|
||||
|
||||
@ -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={() =>
|
||||
|
||||
@ -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
504
src/lib/attachments.ts
Normal 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, "&")
|
||||
.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" }))
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user