Skip to content
File

Blob: src/worker/dav/html-listing.tsx

typescript205 lines
1import type { ReactElement } from "react";
2 
3import type { AppContext } from "@/worker/types";
4 
5export type DavListingKind =
6 | "collection"
7 | "file"
8 | "calendar"
9 | "addressbook"
10 | "calendar-object"
11 | "address-object"
12 | "principal";
13 
14export 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 
23export interface DavListingInput {
24 title: string;
25 href: string;
26 entries: DavListingEntry[];
27 parentHref?: string | null;
28}
29 
30const 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 
40const 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 
50const 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}
68body { 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); }
73table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
74thead { background: rgb(27 24 26 / 0.6); }
75th { padding: 8px 16px; color: var(--zinc-500); font-size: 0.75rem; text-transform: uppercase; font-weight: 650; letter-spacing: 0.08em; text-align: left; }
76td { padding: 10px 16px; border-top: 1px solid rgb(66 63 66 / 0.72); color: var(--zinc-400); vertical-align: top; }
77tbody tr { transition: background-color 75ms; }
78tbody tr:hover { background: rgb(42 39 41 / 0.65); }
79a { color: var(--zinc-200); text-decoration: none; font-weight: 650; }
80a: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 
89function 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 
102function formatDate(ms: number | null | undefined): string {
103 return ms == null ? "" : new Date(ms).toISOString().replace(".000Z", "Z");
104}
105 
106function 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 
114function 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 
128function 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 
139function 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 
153function 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 
194export 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}