File
Blob: src/worker/dav/html-listing.tsx
| 1 | import type { ReactElement } from "react"; |
| 2 | |
| 3 | import type { AppContext } from "@/worker/types"; |
| 4 | |
| 5 | export type DavListingKind = |
| 6 | | "collection" |
| 7 | | "file" |
| 8 | | "calendar" |
| 9 | | "addressbook" |
| 10 | | "calendar-object" |
| 11 | | "address-object" |
| 12 | | "principal"; |
| 13 | |
| 14 | export interface DavListingEntry { |
| 15 | href: string; |
| 16 | name: string; |
| 17 | kind: DavListingKind; |
| 18 | description?: string | null; |
| 19 | size?: number | null; |
| 20 | modifiedAtMs?: number | null; |
| 21 | } |
| 22 | |
| 23 | export interface DavListingInput { |
| 24 | title: string; |
| 25 | href: string; |
| 26 | entries: DavListingEntry[]; |
| 27 | parentHref?: string | null; |
| 28 | } |
| 29 | |
| 30 | const KIND_LABEL: Record<DavListingKind, string> = { |
| 31 | collection: "Folder", |
| 32 | file: "File", |
| 33 | calendar: "Calendar", |
| 34 | addressbook: "Address book", |
| 35 | "calendar-object": "Calendar object", |
| 36 | "address-object": "Address object", |
| 37 | principal: "Principal", |
| 38 | }; |
| 39 | |
| 40 | const KIND_ORDER: Record<DavListingKind, number> = { |
| 41 | collection: 0, |
| 42 | calendar: 1, |
| 43 | addressbook: 1, |
| 44 | principal: 1, |
| 45 | file: 2, |
| 46 | "calendar-object": 2, |
| 47 | "address-object": 2, |
| 48 | }; |
| 49 | |
| 50 | const LISTING_STYLE = ` |
| 51 | :root { |
| 52 | color-scheme: dark; |
| 53 | --canvas: #221f21; |
| 54 | --zinc-50: #fafaf9; |
| 55 | --zinc-100: #f5f4f4; |
| 56 | --zinc-200: #e5e4e5; |
| 57 | --zinc-300: #d6d4d7; |
| 58 | --zinc-400: #a3a1a8; |
| 59 | --zinc-500: #747178; |
| 60 | --zinc-800: #2a2729; |
| 61 | --zinc-900: #1b181a; |
| 62 | --accent-400: #f06da6; |
| 63 | --accent-500: #db3a8b; |
| 64 | font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; |
| 65 | background: var(--canvas); |
| 66 | color: var(--zinc-100); |
| 67 | } |
| 68 | body { margin: 0; min-height: 100vh; background: var(--canvas); font-weight: 450; -webkit-font-smoothing: antialiased; } |
| 69 | .container { max-width: 80rem; margin: 0 auto; padding: 32px 16px; } |
| 70 | .path { margin-bottom: 16px; max-width: 42rem; color: var(--zinc-400); font-family: monospace; font-size: 0.875rem; overflow-wrap: anywhere; } |
| 71 | .path-prefix { color: var(--zinc-500); } |
| 72 | .table-wrap { overflow: hidden; border: 1px solid rgb(66 63 66 / 0.72); } |
| 73 | table { width: 100%; border-collapse: collapse; font-size: 0.875rem; } |
| 74 | thead { background: rgb(27 24 26 / 0.6); } |
| 75 | th { padding: 8px 16px; color: var(--zinc-500); font-size: 0.75rem; text-transform: uppercase; font-weight: 650; letter-spacing: 0.08em; text-align: left; } |
| 76 | td { padding: 10px 16px; border-top: 1px solid rgb(66 63 66 / 0.72); color: var(--zinc-400); vertical-align: top; } |
| 77 | tbody tr { transition: background-color 75ms; } |
| 78 | tbody tr:hover { background: rgb(42 39 41 / 0.65); } |
| 79 | a { color: var(--zinc-200); text-decoration: none; font-weight: 650; } |
| 80 | a:hover { color: var(--accent-400); } |
| 81 | .muted { margin-top: 4px; color: var(--zinc-500); font-size: 0.8125rem; font-weight: 450; } |
| 82 | .kind { color: var(--zinc-300); } |
| 83 | .empty { color: var(--zinc-500); text-align: center; padding: 48px 24px; } |
| 84 | ::selection { background: rgb(219 58 139 / 0.28); color: #fdf2f8; } |
| 85 | @media (min-width: 640px) { .container { padding-left: 24px; padding-right: 24px; } } |
| 86 | @media (max-width: 680px) { th:nth-child(3), td:nth-child(3), th:nth-child(4), td:nth-child(4) { display: none; } } |
| 87 | `; |
| 88 | |
| 89 | function formatBytes(bytes: number | null | undefined): string { |
| 90 | if (bytes == null) return ""; |
| 91 | if (bytes < 1024) return `${bytes} B`; |
| 92 | const units = ["KB", "MB", "GB", "TB"]; |
| 93 | let value = bytes / 1024; |
| 94 | let unitIndex = 0; |
| 95 | while (value >= 1024 && unitIndex < units.length - 1) { |
| 96 | value /= 1024; |
| 97 | unitIndex += 1; |
| 98 | } |
| 99 | return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`; |
| 100 | } |
| 101 | |
| 102 | function formatDate(ms: number | null | undefined): string { |
| 103 | return ms == null ? "" : new Date(ms).toISOString().replace(".000Z", "Z"); |
| 104 | } |
| 105 | |
| 106 | function sortEntries(entries: DavListingEntry[]): DavListingEntry[] { |
| 107 | return [...entries].sort((a, b) => { |
| 108 | const kind = KIND_ORDER[a.kind] - KIND_ORDER[b.kind]; |
| 109 | if (kind !== 0) return kind; |
| 110 | return a.name.localeCompare(b.name, "en", { sensitivity: "base" }); |
| 111 | }); |
| 112 | } |
| 113 | |
| 114 | function tableRow(entry: DavListingEntry, index: number): ReactElement { |
| 115 | return ( |
| 116 | <tr key={`${entry.href}:${index}`}> |
| 117 | <td> |
| 118 | <a href={entry.href}>{entry.name}</a> |
| 119 | {entry.description ? <div className="muted">{entry.description}</div> : null} |
| 120 | </td> |
| 121 | <td className="kind">{KIND_LABEL[entry.kind]}</td> |
| 122 | <td>{formatBytes(entry.size)}</td> |
| 123 | <td>{formatDate(entry.modifiedAtMs)}</td> |
| 124 | </tr> |
| 125 | ); |
| 126 | } |
| 127 | |
| 128 | function emptyRow(entries: DavListingEntry[]): ReactElement | null { |
| 129 | if (entries.length > 0) return null; |
| 130 | return ( |
| 131 | <tr key="empty"> |
| 132 | <td colSpan={4} className="empty"> |
| 133 | This collection is empty. |
| 134 | </td> |
| 135 | </tr> |
| 136 | ); |
| 137 | } |
| 138 | |
| 139 | function parentRow(parentHref: string | null | undefined): ReactElement | null { |
| 140 | if (!parentHref) return null; |
| 141 | return ( |
| 142 | <tr key="parent"> |
| 143 | <td> |
| 144 | <a href={parentHref}>Parent directory</a> |
| 145 | </td> |
| 146 | <td className="kind">Folder</td> |
| 147 | <td /> |
| 148 | <td /> |
| 149 | </tr> |
| 150 | ); |
| 151 | } |
| 152 | |
| 153 | function ListingPage(input: DavListingInput): ReactElement { |
| 154 | const entries = sortEntries(input.entries); |
| 155 | return ( |
| 156 | <html lang="en"> |
| 157 | <head> |
| 158 | <meta charSet="utf-8" /> |
| 159 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 160 | <title>{input.title}</title> |
| 161 | <style>{LISTING_STYLE}</style> |
| 162 | </head> |
| 163 | <body> |
| 164 | <main> |
| 165 | <div className="container"> |
| 166 | <div className="path"> |
| 167 | <span className="path-prefix">dab </span> |
| 168 | {input.href} |
| 169 | </div> |
| 170 | <div className="table-wrap"> |
| 171 | <table> |
| 172 | <thead> |
| 173 | <tr> |
| 174 | <th>Name</th> |
| 175 | <th>Kind</th> |
| 176 | <th>Size</th> |
| 177 | <th>Modified</th> |
| 178 | </tr> |
| 179 | </thead> |
| 180 | <tbody> |
| 181 | {parentRow(input.parentHref)} |
| 182 | {entries.map(tableRow)} |
| 183 | {emptyRow(entries)} |
| 184 | </tbody> |
| 185 | </table> |
| 186 | </div> |
| 187 | </div> |
| 188 | </main> |
| 189 | </body> |
| 190 | </html> |
| 191 | ); |
| 192 | } |
| 193 | |
| 194 | export async function davHtmlListingResponse(c: AppContext, input: DavListingInput, method: string): Promise<Response> { |
| 195 | c.header("Cache-Control", "no-store"); |
| 196 | c.header( |
| 197 | "Content-Security-Policy", |
| 198 | "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'", |
| 199 | ); |
| 200 | c.header("X-Content-Type-Options", "nosniff"); |
| 201 | const response = await c.render(<ListingPage {...input} />); |
| 202 | if (method !== "HEAD") return response; |
| 203 | return new Response(null, { status: response.status, headers: response.headers }); |
| 204 | } |