@autono/open-pages 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/LICENSE +23 -0
  2. package/README.md +108 -0
  3. package/bin.js +2 -0
  4. package/dist/build-TP72kiw7.js +35 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +100 -0
  7. package/dist/config-C9or4m4J.d.ts +23 -0
  8. package/dist/config-L0IbktJ0.js +3135 -0
  9. package/dist/design-C13iz9_4.js +35 -0
  10. package/dist/dev-I2IyEh5W.js +97 -0
  11. package/dist/export-B-kBRWB1.js +4 -0
  12. package/dist/export-ic28osQP.js +131 -0
  13. package/dist/index.d.ts +56 -0
  14. package/dist/index.js +3 -0
  15. package/dist/locale/index.d.ts +24 -0
  16. package/dist/locale/index.js +881 -0
  17. package/dist/open-pages-plugin-D_DN_zjh.js +632 -0
  18. package/dist/preview-BrDuvgAN.js +21 -0
  19. package/dist/sync-Be_hxSOF.js +139 -0
  20. package/dist/sync-lKEXyPOh.js +3 -0
  21. package/dist/types-C5NCdDT-.d.ts +219 -0
  22. package/dist/update-CV62NhBT.js +37 -0
  23. package/dist/update-package-Cxt57pz7.js +152 -0
  24. package/dist/version-B6AiiGvT.js +26 -0
  25. package/dist/version-CeffgdfZ.js +3 -0
  26. package/dist/vite/index.d.ts +14 -0
  27. package/dist/vite/index.js +7 -0
  28. package/env.d.ts +59 -0
  29. package/package.json +106 -0
  30. package/skills/apply-comments/SKILL.md +86 -0
  31. package/skills/create-page/SKILL.md +92 -0
  32. package/skills/create-theme/SKILL.md +223 -0
  33. package/skills/current-page/SKILL.md +106 -0
  34. package/skills/page-authoring/SKILL.md +148 -0
  35. package/skills/page-authoring/references/assets-and-fonts.md +85 -0
  36. package/skills/page-authoring/references/html-pages.md +65 -0
  37. package/skills/page-authoring/references/interactivity.md +77 -0
  38. package/skills/page-authoring/references/layout-and-responsive.md +71 -0
  39. package/skills/page-authoring/references/typography-and-color.md +60 -0
  40. package/src/app/app.tsx +45 -0
  41. package/src/app/components/asset-view.tsx +1665 -0
  42. package/src/app/components/command/command-menu.tsx +227 -0
  43. package/src/app/components/command/command.tsx +142 -0
  44. package/src/app/components/command/home-command-menu.tsx +96 -0
  45. package/src/app/components/icon-tooltip.tsx +34 -0
  46. package/src/app/components/language-toggle.tsx +46 -0
  47. package/src/app/components/sidebar/folder-item.tsx +269 -0
  48. package/src/app/components/sidebar/icon-picker.tsx +61 -0
  49. package/src/app/components/sidebar/sidebar-footer.tsx +139 -0
  50. package/src/app/components/sidebar/sidebar.tsx +302 -0
  51. package/src/app/components/theme-toggle.tsx +66 -0
  52. package/src/app/components/themes/theme-detail.tsx +226 -0
  53. package/src/app/components/themes/themes-gallery.tsx +104 -0
  54. package/src/app/components/ui/badge.tsx +48 -0
  55. package/src/app/components/ui/button.tsx +93 -0
  56. package/src/app/components/ui/card.tsx +92 -0
  57. package/src/app/components/ui/context-menu.tsx +247 -0
  58. package/src/app/components/ui/dialog.tsx +155 -0
  59. package/src/app/components/ui/dropdown-menu.tsx +267 -0
  60. package/src/app/components/ui/input.tsx +25 -0
  61. package/src/app/components/ui/label.tsx +21 -0
  62. package/src/app/components/ui/popover.tsx +82 -0
  63. package/src/app/components/ui/progress.tsx +33 -0
  64. package/src/app/components/ui/scroll-area.tsx +53 -0
  65. package/src/app/components/ui/select.tsx +195 -0
  66. package/src/app/components/ui/separator.tsx +26 -0
  67. package/src/app/components/ui/slider.tsx +68 -0
  68. package/src/app/components/ui/sonner.tsx +48 -0
  69. package/src/app/components/ui/tabs.tsx +79 -0
  70. package/src/app/components/ui/textarea.tsx +22 -0
  71. package/src/app/components/ui/toggle-group.tsx +84 -0
  72. package/src/app/components/ui/toggle.tsx +45 -0
  73. package/src/app/components/ui/tooltip.tsx +75 -0
  74. package/src/app/favicon.ico +0 -0
  75. package/src/app/frame/inspect.ts +140 -0
  76. package/src/app/frame/main.tsx +91 -0
  77. package/src/app/frame.html +12 -0
  78. package/src/app/index.html +13 -0
  79. package/src/app/lib/asset-filter.test.ts +176 -0
  80. package/src/app/lib/asset-filter.ts +80 -0
  81. package/src/app/lib/assets.ts +257 -0
  82. package/src/app/lib/design.ts +58 -0
  83. package/src/app/lib/folders.ts +239 -0
  84. package/src/app/lib/frame.ts +38 -0
  85. package/src/app/lib/locale-store.ts +67 -0
  86. package/src/app/lib/page-thumb.tsx +46 -0
  87. package/src/app/lib/pages.ts +28 -0
  88. package/src/app/lib/sdk.ts +43 -0
  89. package/src/app/lib/themes.ts +22 -0
  90. package/src/app/lib/use-locale.ts +8 -0
  91. package/src/app/lib/use-page-module.ts +48 -0
  92. package/src/app/lib/use-page-titles.ts +29 -0
  93. package/src/app/lib/use-restart-server.ts +78 -0
  94. package/src/app/lib/utils.test.ts +25 -0
  95. package/src/app/lib/utils.ts +6 -0
  96. package/src/app/main.tsx +14 -0
  97. package/src/app/routes/assets.tsx +9 -0
  98. package/src/app/routes/home-shell.tsx +252 -0
  99. package/src/app/routes/home.tsx +846 -0
  100. package/src/app/routes/page.tsx +329 -0
  101. package/src/app/routes/themes.tsx +34 -0
  102. package/src/app/styles.css +405 -0
  103. package/src/app/virtual.d.ts +53 -0
  104. package/src/locale/en.ts +218 -0
  105. package/src/locale/format.ts +12 -0
  106. package/src/locale/index.ts +6 -0
  107. package/src/locale/ja.ts +221 -0
  108. package/src/locale/types.ts +215 -0
  109. package/src/locale/zh-cn.ts +218 -0
  110. package/src/locale/zh-tw.ts +218 -0
@@ -0,0 +1,846 @@
1
+ import {
2
+ ArrowDownAZ,
3
+ ChevronDown,
4
+ Clock,
5
+ Copy,
6
+ FolderInput,
7
+ FolderPlus,
8
+ MoreHorizontal,
9
+ Palette,
10
+ Pencil,
11
+ Search,
12
+ Trash2,
13
+ X,
14
+ } from 'lucide-react';
15
+ import { useEffect, useMemo, useRef, useState } from 'react';
16
+ import { Link, useOutletContext } from 'react-router-dom';
17
+ import { toast } from 'sonner';
18
+ import { Button } from '@/components/ui/button';
19
+ import {
20
+ Dialog,
21
+ DialogContent,
22
+ DialogDescription,
23
+ DialogFooter,
24
+ DialogHeader,
25
+ DialogTitle,
26
+ } from '@/components/ui/dialog';
27
+ import {
28
+ DropdownMenu,
29
+ DropdownMenuContent,
30
+ DropdownMenuItem,
31
+ DropdownMenuTrigger,
32
+ } from '@/components/ui/dropdown-menu';
33
+ import { format, useLocale } from '@/lib/use-locale';
34
+ import { cn } from '@/lib/utils';
35
+ import { DOC_DND_MIME, FolderIconChip } from '../components/sidebar/folder-item';
36
+ import { ALL_DOCS_ID, DRAFT_ID } from '../components/sidebar/sidebar';
37
+ import { PageThumb } from '../lib/page-thumb';
38
+ import { loadPage, pageCreatedAt, pageIds, pageKinds } from '../lib/pages';
39
+ import type { Folder, FolderIcon, PageModule } from '../lib/sdk';
40
+ import type { HomeOutletContext } from './home-shell';
41
+
42
+ type SortKey = 'created-desc' | 'created-asc' | 'title-asc' | 'title-desc';
43
+
44
+ const SORT_KEYS: readonly SortKey[] = ['created-desc', 'created-asc', 'title-asc', 'title-desc'];
45
+
46
+ const DEFAULT_SORT: SortKey = 'created-desc';
47
+ const SORT_STORAGE_KEY = 'open-pages:home-sort';
48
+
49
+ function readSortPref(): SortKey {
50
+ if (typeof window === 'undefined') return DEFAULT_SORT;
51
+ try {
52
+ const raw = window.localStorage.getItem(SORT_STORAGE_KEY);
53
+ if (raw && (SORT_KEYS as readonly string[]).includes(raw)) return raw as SortKey;
54
+ } catch {}
55
+ return DEFAULT_SORT;
56
+ }
57
+
58
+ function useSortPref(): [SortKey, (next: SortKey) => void] {
59
+ const [sortKey, setSortKey] = useState<SortKey>(readSortPref);
60
+ const update = (next: SortKey) => {
61
+ setSortKey(next);
62
+ try {
63
+ window.localStorage.setItem(SORT_STORAGE_KEY, next);
64
+ } catch {}
65
+ };
66
+ return [sortKey, update];
67
+ }
68
+
69
+ const TITLE_COLLATOR = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
70
+
71
+ export function Home() {
72
+ const {
73
+ manifest,
74
+ loading,
75
+ draftDocs,
76
+ docsByFolder,
77
+ selectedId,
78
+ selectFolder,
79
+ reportTitle,
80
+ titleMap,
81
+ assign,
82
+ renameDoc,
83
+ duplicatePage,
84
+ deletePage,
85
+ } = useOutletContext<HomeOutletContext>();
86
+ const t = useLocale();
87
+
88
+ const isAll = selectedId === ALL_DOCS_ID;
89
+ const isDraft = selectedId === DRAFT_ID;
90
+ const selectedFolder =
91
+ isAll || isDraft ? null : (manifest.folders.find((f) => f.id === selectedId) ?? null);
92
+ const visibleDocs = isAll ? pageIds : isDraft ? draftDocs : (docsByFolder[selectedId] ?? []);
93
+
94
+ const title = selectedFolder?.name ?? (isAll ? t.home.pages : t.home.draft);
95
+ const headerIcon = selectedFolder?.icon ?? {
96
+ type: 'emoji' as const,
97
+ value: isAll ? '🎞️' : '📝',
98
+ };
99
+
100
+ const [query, setQuery] = useState('');
101
+ const [sortKey, setSortKey] = useSortPref();
102
+
103
+ const trimmedQuery = query.trim().toLowerCase();
104
+ const filteredDocs = useMemo(() => {
105
+ if (!trimmedQuery) return visibleDocs;
106
+ return visibleDocs.filter((id) => {
107
+ if (id.toLowerCase().includes(trimmedQuery)) return true;
108
+ const tl = titleMap[id]?.toLowerCase();
109
+ return tl ? tl.includes(trimmedQuery) : false;
110
+ });
111
+ }, [visibleDocs, titleMap, trimmedQuery]);
112
+ const sortedDocs = useMemo(() => {
113
+ const list = filteredDocs.slice();
114
+ const titleOf = (id: string) => titleMap[id] ?? id;
115
+ switch (sortKey) {
116
+ case 'title-asc':
117
+ list.sort((a, b) => TITLE_COLLATOR.compare(titleOf(a), titleOf(b)));
118
+ break;
119
+ case 'title-desc':
120
+ list.sort((a, b) => TITLE_COLLATOR.compare(titleOf(b), titleOf(a)));
121
+ break;
122
+ case 'created-asc':
123
+ list.sort((a, b) => (pageCreatedAt[a] ?? 0) - (pageCreatedAt[b] ?? 0));
124
+ break;
125
+ default:
126
+ list.sort((a, b) => (pageCreatedAt[b] ?? 0) - (pageCreatedAt[a] ?? 0));
127
+ }
128
+ return list;
129
+ }, [filteredDocs, sortKey, titleMap]);
130
+ const isSearching = trimmedQuery.length > 0;
131
+
132
+ return (
133
+ <>
134
+ <header className="mb-8 md:mb-12">
135
+ <div className="flex flex-wrap items-center gap-3">
136
+ <FolderIconChip icon={headerIcon} className="size-7 text-2xl" />
137
+ <h1 className="font-heading text-[32px] font-semibold leading-[1.05] tracking-[-0.025em] md:text-[44px]">
138
+ {title}
139
+ </h1>
140
+ <DropdownMenu>
141
+ <DropdownMenuTrigger
142
+ render={
143
+ <button
144
+ type="button"
145
+ aria-label={t.home.folders}
146
+ className="flex size-7 items-center justify-center rounded-[6px] border border-border bg-card text-muted-foreground hover:text-foreground aria-expanded:border-foreground/40 aria-expanded:text-foreground md:hidden"
147
+ >
148
+ <ChevronDown className="size-4" />
149
+ </button>
150
+ }
151
+ />
152
+ <DropdownMenuContent align="start" className="min-w-[200px]">
153
+ <DropdownMenuItem
154
+ onClick={() => selectFolder(ALL_DOCS_ID)}
155
+ className={cn(isAll && 'bg-muted text-foreground')}
156
+ >
157
+ <FolderIconChip icon={{ type: 'emoji', value: '🎞️' }} />
158
+ <span className="flex-1 truncate">{t.home.pages}</span>
159
+ <span className="folio">{pageIds.length.toString().padStart(2, '0')}</span>
160
+ </DropdownMenuItem>
161
+ <DropdownMenuItem
162
+ onClick={() => selectFolder(DRAFT_ID)}
163
+ className={cn(isDraft && 'bg-muted text-foreground')}
164
+ >
165
+ <FolderIconChip icon={{ type: 'emoji', value: '📝' }} />
166
+ <span className="flex-1 truncate">{t.home.draft}</span>
167
+ <span className="folio">{draftDocs.length.toString().padStart(2, '0')}</span>
168
+ </DropdownMenuItem>
169
+ {manifest.folders.map((f) => (
170
+ <DropdownMenuItem
171
+ key={f.id}
172
+ onClick={() => selectFolder(f.id)}
173
+ className={cn(selectedId === f.id && 'bg-muted text-foreground')}
174
+ >
175
+ <FolderIconChip icon={f.icon} />
176
+ <span className="flex-1 truncate">{f.name}</span>
177
+ <span className="folio">
178
+ {(docsByFolder[f.id]?.length ?? 0).toString().padStart(2, '0')}
179
+ </span>
180
+ </DropdownMenuItem>
181
+ ))}
182
+ </DropdownMenuContent>
183
+ </DropdownMenu>
184
+ {!loading && (
185
+ <span className="folio ml-1 self-end pb-2">
186
+ {(isSearching ? filteredDocs.length : visibleDocs.length).toString().padStart(2, '0')}
187
+ {isSearching && (
188
+ <span className="opacity-40">
189
+ /{visibleDocs.length.toString().padStart(2, '0')}
190
+ </span>
191
+ )}
192
+ </span>
193
+ )}
194
+ <div className="ml-auto flex w-full items-center gap-2 md:w-auto">
195
+ <SortControl value={sortKey} onChange={setSortKey} />
196
+ <SearchInput value={query} onChange={setQuery} />
197
+ </div>
198
+ </div>
199
+ </header>
200
+
201
+ {loading ? (
202
+ <HomeLoading />
203
+ ) : visibleDocs.length === 0 ? (
204
+ <EmptyState isDraft={isAll || isDraft} folderName={selectedFolder?.name} />
205
+ ) : filteredDocs.length === 0 ? (
206
+ <NoResultsState query={query} onClear={() => setQuery('')} />
207
+ ) : (
208
+ <ul className="grid grid-cols-[repeat(auto-fill,minmax(240px,1fr))] gap-x-6 gap-y-9 md:grid-cols-[repeat(auto-fill,minmax(300px,1fr))]">
209
+ {sortedDocs.map((id) => (
210
+ <li key={id}>
211
+ <DocCard
212
+ id={id}
213
+ folders={manifest.folders}
214
+ currentFolderId={manifest.assignments[id] ?? null}
215
+ onRename={(name) => renameDoc(id, name)}
216
+ onDuplicate={async () => {
217
+ const docName = titleMap[id] ?? id;
218
+ try {
219
+ const newDocId = await duplicatePage(id);
220
+ toast.success(
221
+ format(t.home.toastDocDuplicated, {
222
+ page: docName,
223
+ newDoc: newDocId,
224
+ }),
225
+ );
226
+ } catch {
227
+ toast.error(t.home.toastDocDuplicateFailed);
228
+ }
229
+ }}
230
+ onMove={(folderId) => assign(id, folderId)}
231
+ onDelete={() => deletePage(id)}
232
+ onTitleResolved={reportTitle}
233
+ />
234
+ </li>
235
+ ))}
236
+ </ul>
237
+ )}
238
+ </>
239
+ );
240
+ }
241
+
242
+ function SearchInput({ value, onChange }: { value: string; onChange: (value: string) => void }) {
243
+ const t = useLocale();
244
+ return (
245
+ <div className="relative w-full md:w-[240px]">
246
+ <Search
247
+ className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
248
+ aria-hidden
249
+ />
250
+ <input
251
+ type="text"
252
+ value={value}
253
+ onChange={(e) => onChange(e.target.value)}
254
+ placeholder={t.home.searchPlaceholder}
255
+ className="h-8 w-full rounded-[6px] border border-border bg-background pl-8 pr-7 text-[12.5px] outline-none placeholder:text-muted-foreground/70 focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/30"
256
+ />
257
+ {value && (
258
+ <button
259
+ type="button"
260
+ onClick={() => onChange('')}
261
+ aria-label={t.home.clearSearch}
262
+ className="absolute right-1.5 top-1/2 flex size-5 -translate-y-1/2 items-center justify-center rounded-[4px] text-muted-foreground hover:bg-muted hover:text-foreground"
263
+ >
264
+ <X className="size-3" />
265
+ </button>
266
+ )}
267
+ </div>
268
+ );
269
+ }
270
+
271
+ function SortControl({ value, onChange }: { value: SortKey; onChange: (next: SortKey) => void }) {
272
+ const t = useLocale();
273
+ const labels: Record<SortKey, string> = {
274
+ 'created-desc': t.home.sortByCreatedDesc,
275
+ 'created-asc': t.home.sortByCreatedAsc,
276
+ 'title-asc': t.home.sortByTitleAsc,
277
+ 'title-desc': t.home.sortByTitleDesc,
278
+ };
279
+ const FieldIcon = ({ k, className }: { k: SortKey; className?: string }) =>
280
+ k === 'title-asc' || k === 'title-desc' ? (
281
+ <ArrowDownAZ className={className} aria-hidden />
282
+ ) : (
283
+ <Clock className={className} aria-hidden />
284
+ );
285
+ return (
286
+ <DropdownMenu>
287
+ <DropdownMenuTrigger
288
+ render={
289
+ <button
290
+ type="button"
291
+ aria-label={`${t.home.sortLabel}: ${labels[value]}`}
292
+ className="flex h-8 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-[6px] border border-border bg-background pl-2 pr-1.5 text-[12.5px] font-medium text-foreground outline-none hover:bg-muted focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/30"
293
+ >
294
+ <FieldIcon k={value} className="size-3.5 text-muted-foreground" />
295
+ <span>{labels[value]}</span>
296
+ <ChevronDown className="size-3 text-muted-foreground" aria-hidden />
297
+ </button>
298
+ }
299
+ />
300
+ <DropdownMenuContent align="end" className="min-w-[180px]">
301
+ {SORT_KEYS.map((key) => {
302
+ const active = value === key;
303
+ return (
304
+ <DropdownMenuItem
305
+ key={key}
306
+ onClick={() => onChange(key)}
307
+ className={cn(active && 'bg-muted text-foreground')}
308
+ >
309
+ <FieldIcon k={key} className="size-3.5 text-muted-foreground" />
310
+ <span>{labels[key]}</span>
311
+ </DropdownMenuItem>
312
+ );
313
+ })}
314
+ </DropdownMenuContent>
315
+ </DropdownMenu>
316
+ );
317
+ }
318
+
319
+ function HomeLoading() {
320
+ const t = useLocale();
321
+ return (
322
+ <div className="grid place-items-center px-8 py-24 text-muted-foreground">
323
+ <div className="flex flex-col items-center gap-4">
324
+ <div className="relative h-px w-56 overflow-hidden bg-hairline">
325
+ <span
326
+ aria-hidden
327
+ className="line-loader-bar absolute inset-y-[-0.5px] left-0 w-1/4 bg-foreground"
328
+ />
329
+ </div>
330
+ <span className="eyebrow text-[11.5px]">{t.page.loadingEyebrow}</span>
331
+ </div>
332
+ </div>
333
+ );
334
+ }
335
+
336
+ function NoResultsState({ query, onClear }: { query: string; onClear: () => void }) {
337
+ const t = useLocale();
338
+ return (
339
+ <div className="rounded-[10px] border border-dashed border-border bg-card/60 px-8 py-20">
340
+ <div className="mx-auto flex max-w-md flex-col items-center text-center">
341
+ <div className="flex size-12 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground">
342
+ <Search className="size-5" />
343
+ </div>
344
+ <p className="mt-4 font-heading text-[15px] font-semibold tracking-tight">
345
+ {t.home.noMatches}
346
+ </p>
347
+ <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
348
+ {t.home.nothingMatchesPrefix}
349
+ <span className="font-medium text-foreground">&ldquo;{query}&rdquo;</span>
350
+ {t.home.nothingMatchesSuffix}
351
+ </p>
352
+ <Button variant="ghost" size="sm" className="mt-4" onClick={onClear}>
353
+ {t.home.clearSearch}
354
+ </Button>
355
+ </div>
356
+ </div>
357
+ );
358
+ }
359
+
360
+ function EmptyState({ isDraft, folderName }: { isDraft: boolean; folderName?: string }) {
361
+ const t = useLocale();
362
+ const folderEmptyTitle = t.home.folderEmptyTitle.replace(
363
+ '{name}',
364
+ folderName ?? t.home.folderEmptyTitle,
365
+ );
366
+ return (
367
+ <div className="rounded-[10px] border border-dashed border-border bg-card/60 px-8 py-20">
368
+ <div className="mx-auto flex max-w-md flex-col items-center text-center">
369
+ <div className="flex size-12 items-center justify-center rounded-full border border-hairline bg-card text-muted-foreground">
370
+ <FolderPlus className="size-5" />
371
+ </div>
372
+ {isDraft ? (
373
+ <>
374
+ <p className="mt-4 font-heading text-[15px] font-semibold tracking-tight">
375
+ {t.home.noDocsYet}
376
+ </p>
377
+ <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
378
+ {t.home.createDocHintPrefix}
379
+ <code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
380
+ /create-page
381
+ </code>
382
+ {t.home.createDocHintSuffix}
383
+ </p>
384
+ </>
385
+ ) : (
386
+ <>
387
+ <p className="mt-4 font-heading text-[15px] font-semibold tracking-tight">
388
+ {folderEmptyTitle}
389
+ </p>
390
+ <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
391
+ {t.home.folderEmptyHint}
392
+ </p>
393
+ </>
394
+ )}
395
+ </div>
396
+ </div>
397
+ );
398
+ }
399
+
400
+ function createDragChip(title: string): HTMLElement | null {
401
+ if (typeof document === 'undefined') return null;
402
+ const chip = document.createElement('div');
403
+ chip.style.cssText = [
404
+ 'position: fixed',
405
+ 'top: -9999px',
406
+ 'left: -9999px',
407
+ 'display: inline-flex',
408
+ 'align-items: center',
409
+ 'gap: 8px',
410
+ 'padding: 6px 10px 6px 6px',
411
+ 'border-radius: 6px',
412
+ 'background: var(--card)',
413
+ 'color: var(--foreground)',
414
+ 'border: 1px solid var(--border)',
415
+ 'box-shadow: 0 12px 32px -8px rgba(0,0,0,0.25), 0 2px 6px rgba(0,0,0,0.08)',
416
+ 'font: 500 12.5px/1 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
417
+ 'white-space: nowrap',
418
+ 'pointer-events: none',
419
+ 'z-index: 9999',
420
+ ].join(';');
421
+
422
+ const thumb = document.createElement('span');
423
+ thumb.style.cssText = [
424
+ 'display: inline-block',
425
+ 'width: 30px',
426
+ 'height: 18px',
427
+ 'border-radius: 3px',
428
+ 'background: var(--muted)',
429
+ 'border: 1px solid var(--border)',
430
+ 'flex: 0 0 auto',
431
+ ].join(';');
432
+
433
+ const label = document.createElement('span');
434
+ label.textContent = title;
435
+ label.style.cssText = 'overflow: hidden; text-overflow: ellipsis; max-width: 220px;';
436
+
437
+ chip.appendChild(thumb);
438
+ chip.appendChild(label);
439
+ document.body.appendChild(chip);
440
+ return chip;
441
+ }
442
+
443
+ type DialogKind = null | 'rename' | 'move' | 'delete';
444
+
445
+ function DocCard({
446
+ id,
447
+ folders,
448
+ currentFolderId,
449
+ onRename,
450
+ onDuplicate,
451
+ onMove,
452
+ onDelete,
453
+ onTitleResolved,
454
+ }: {
455
+ id: string;
456
+ folders: Folder[];
457
+ currentFolderId: string | null;
458
+ onRename: (name: string) => Promise<void> | void;
459
+ onDuplicate: () => Promise<void> | void;
460
+ onMove: (folderId: string | null) => Promise<void> | void;
461
+ onDelete: () => Promise<void> | void;
462
+ onTitleResolved?: (id: string, title: string) => void;
463
+ }) {
464
+ const [page, setPage] = useState<PageModule | null>(null);
465
+ const [dragging, setDragging] = useState(false);
466
+ const [dialog, setDialog] = useState<DialogKind>(null);
467
+ const tCard = useLocale();
468
+
469
+ useEffect(() => {
470
+ let cancelled = false;
471
+ loadPage(id)
472
+ .then((mod) => {
473
+ if (!cancelled) setPage(mod);
474
+ })
475
+ .catch(() => {});
476
+ return () => {
477
+ cancelled = true;
478
+ };
479
+ }, [id]);
480
+
481
+ const displayTitle = page?.meta?.title ?? id;
482
+
483
+ useEffect(() => {
484
+ if (page && onTitleResolved) onTitleResolved(id, displayTitle);
485
+ }, [id, page, displayTitle, onTitleResolved]);
486
+
487
+ return (
488
+ <>
489
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: drag source wraps an interactive Link */}
490
+ <div
491
+ draggable
492
+ onDragStart={(e) => {
493
+ e.dataTransfer.setData(DOC_DND_MIME, id);
494
+ e.dataTransfer.effectAllowed = 'move';
495
+ const chip = createDragChip(displayTitle);
496
+ if (chip) {
497
+ e.dataTransfer.setDragImage(chip, 14, 14);
498
+ setTimeout(() => chip.remove(), 0);
499
+ }
500
+ setDragging(true);
501
+ }}
502
+ onDragEnd={() => setDragging(false)}
503
+ className={cn('group relative motion-safe:transition-opacity', dragging && 'opacity-40')}
504
+ >
505
+ <Link to={`/p/${id}`} className="block focus-visible:outline-none">
506
+ {/* Page thumb — tight border, grey baseboard, no shadcn rounded-xl */}
507
+ <div className="relative aspect-video overflow-hidden rounded-[6px] border border-hairline bg-card shadow-edge ring-1 ring-foreground/[0.04] group-hover:shadow-floating group-hover:ring-foreground/20 motion-safe:transition-[box-shadow,--tw-ring-color] motion-safe:duration-200">
508
+ <div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
509
+ <PageThumb source={{ pageId: id, kind: pageKinds[id] }} title={displayTitle} />
510
+ </div>
511
+ </div>
512
+ </Link>
513
+ <div className="mt-3 flex items-center gap-2">
514
+ <Link to={`/p/${id}`} className="min-w-0 flex-1 focus-visible:outline-none">
515
+ <h3 className="min-w-0 truncate font-heading text-[14px] font-medium tracking-tight">
516
+ {displayTitle}
517
+ </h3>
518
+ </Link>
519
+ {page?.meta?.theme && (
520
+ <Link
521
+ to={`/themes/${encodeURIComponent(page.meta.theme)}`}
522
+ className="inline-flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
523
+ >
524
+ <Palette className="size-3" aria-hidden />
525
+ <span className="max-w-[120px] truncate">{page.meta.theme}</span>
526
+ </Link>
527
+ )}
528
+ </div>
529
+
530
+ {import.meta.env.DEV && (
531
+ <div className="absolute right-2 top-2">
532
+ <DropdownMenu>
533
+ <DropdownMenuTrigger
534
+ render={
535
+ <button
536
+ type="button"
537
+ onClick={(e) => {
538
+ e.stopPropagation();
539
+ e.preventDefault();
540
+ }}
541
+ className="flex size-7 items-center justify-center rounded-[5px] bg-card/90 text-foreground shadow-edge ring-1 ring-border opacity-0 backdrop-blur hover:bg-card group-hover:opacity-100 aria-expanded:opacity-100 motion-safe:transition-opacity"
542
+ aria-label={tCard.home.docActions}
543
+ >
544
+ <MoreHorizontal className="size-3.5" />
545
+ </button>
546
+ }
547
+ />
548
+ <DropdownMenuContent align="end" className="min-w-[160px]">
549
+ <DropdownMenuItem onClick={() => setDialog('rename')}>
550
+ <Pencil />
551
+ {tCard.common.rename}
552
+ </DropdownMenuItem>
553
+ <DropdownMenuItem onClick={() => onDuplicate()}>
554
+ <Copy />
555
+ {tCard.home.duplicate}
556
+ </DropdownMenuItem>
557
+ <DropdownMenuItem onClick={() => setDialog('move')}>
558
+ <FolderInput />
559
+ {tCard.home.moveToFolder}
560
+ </DropdownMenuItem>
561
+ <DropdownMenuItem variant="destructive" onClick={() => setDialog('delete')}>
562
+ <Trash2 />
563
+ {tCard.common.delete}
564
+ </DropdownMenuItem>
565
+ </DropdownMenuContent>
566
+ </DropdownMenu>
567
+ </div>
568
+ )}
569
+ </div>
570
+
571
+ <RenameDialog
572
+ open={dialog === 'rename'}
573
+ initialName={displayTitle}
574
+ onOpenChange={(v) => setDialog(v ? 'rename' : null)}
575
+ onSubmit={async (name) => {
576
+ await onRename(name);
577
+ setDialog(null);
578
+ }}
579
+ />
580
+ <MoveDialog
581
+ open={dialog === 'move'}
582
+ docName={displayTitle}
583
+ folders={folders}
584
+ currentFolderId={currentFolderId}
585
+ onOpenChange={(v) => setDialog(v ? 'move' : null)}
586
+ onSubmit={async (folderId) => {
587
+ await onMove(folderId);
588
+ setDialog(null);
589
+ }}
590
+ />
591
+ <DeleteDialog
592
+ open={dialog === 'delete'}
593
+ docName={displayTitle}
594
+ onOpenChange={(v) => setDialog(v ? 'delete' : null)}
595
+ onConfirm={async () => {
596
+ await onDelete();
597
+ setDialog(null);
598
+ }}
599
+ />
600
+ </>
601
+ );
602
+ }
603
+
604
+ function RenameDialog({
605
+ open,
606
+ initialName,
607
+ onOpenChange,
608
+ onSubmit,
609
+ }: {
610
+ open: boolean;
611
+ initialName: string;
612
+ onOpenChange: (open: boolean) => void;
613
+ onSubmit: (name: string) => Promise<void> | void;
614
+ }) {
615
+ const [value, setValue] = useState(initialName);
616
+ const [submitting, setSubmitting] = useState(false);
617
+ const inputRef = useRef<HTMLInputElement | null>(null);
618
+ const t = useLocale();
619
+
620
+ useEffect(() => {
621
+ if (open) {
622
+ setValue(initialName);
623
+ setSubmitting(false);
624
+ queueMicrotask(() => {
625
+ inputRef.current?.focus();
626
+ inputRef.current?.select();
627
+ });
628
+ }
629
+ }, [open, initialName]);
630
+
631
+ const submit = async () => {
632
+ const trimmed = value.trim();
633
+ if (!trimmed || trimmed === initialName) {
634
+ onOpenChange(false);
635
+ return;
636
+ }
637
+ setSubmitting(true);
638
+ try {
639
+ await onSubmit(trimmed);
640
+ } finally {
641
+ setSubmitting(false);
642
+ }
643
+ };
644
+
645
+ return (
646
+ <Dialog open={open} onOpenChange={onOpenChange}>
647
+ <DialogContent>
648
+ <DialogHeader>
649
+ <span className="eyebrow">{t.home.renameDialogEyebrow}</span>
650
+ <DialogTitle>{t.home.renameDialogTitle}</DialogTitle>
651
+ <DialogDescription>{t.home.renameDialogDescription}</DialogDescription>
652
+ </DialogHeader>
653
+ <input
654
+ ref={inputRef}
655
+ value={value}
656
+ onChange={(e) => setValue(e.target.value)}
657
+ onKeyDown={(e) => {
658
+ if (e.nativeEvent.isComposing) return;
659
+ if (e.key === 'Enter') {
660
+ e.preventDefault();
661
+ submit();
662
+ }
663
+ }}
664
+ maxLength={80}
665
+ placeholder={t.home.docNamePlaceholder}
666
+ className="h-9 w-full rounded-[6px] border border-border bg-background px-3 text-[13px] outline-none focus-visible:border-foreground/40 focus-visible:ring-2 focus-visible:ring-ring/30"
667
+ />
668
+ <DialogFooter>
669
+ <Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
670
+ {t.common.cancel}
671
+ </Button>
672
+ <Button size="sm" disabled={submitting} onClick={submit}>
673
+ {t.common.save}
674
+ </Button>
675
+ </DialogFooter>
676
+ </DialogContent>
677
+ </Dialog>
678
+ );
679
+ }
680
+
681
+ function MoveDialog({
682
+ open,
683
+ docName,
684
+ folders,
685
+ currentFolderId,
686
+ onOpenChange,
687
+ onSubmit,
688
+ }: {
689
+ open: boolean;
690
+ docName: string;
691
+ folders: Folder[];
692
+ currentFolderId: string | null;
693
+ onOpenChange: (open: boolean) => void;
694
+ onSubmit: (folderId: string | null) => Promise<void> | void;
695
+ }) {
696
+ const [selected, setSelected] = useState<string | null>(currentFolderId);
697
+ const [submitting, setSubmitting] = useState(false);
698
+ const t = useLocale();
699
+
700
+ useEffect(() => {
701
+ if (open) {
702
+ setSelected(currentFolderId);
703
+ setSubmitting(false);
704
+ }
705
+ }, [open, currentFolderId]);
706
+
707
+ const submit = async () => {
708
+ if (selected === currentFolderId) {
709
+ onOpenChange(false);
710
+ return;
711
+ }
712
+ setSubmitting(true);
713
+ try {
714
+ await onSubmit(selected);
715
+ } finally {
716
+ setSubmitting(false);
717
+ }
718
+ };
719
+
720
+ return (
721
+ <Dialog open={open} onOpenChange={onOpenChange}>
722
+ <DialogContent>
723
+ <DialogHeader>
724
+ <span className="eyebrow">{t.home.moveDialogEyebrow}</span>
725
+ <DialogTitle>{t.home.moveDialogTitle}</DialogTitle>
726
+ <DialogDescription>
727
+ {t.home.moveDialogDescriptionPrefix}
728
+ <span className="font-medium text-foreground">{docName}</span>
729
+ {t.home.moveDialogDescriptionSuffix}
730
+ </DialogDescription>
731
+ </DialogHeader>
732
+ <div className="max-h-[320px] overflow-y-auto rounded-[6px] border border-border bg-background">
733
+ <FolderOption
734
+ icon={{ type: 'emoji', value: '📝' }}
735
+ label={t.home.draft}
736
+ active={selected === null}
737
+ onClick={() => setSelected(null)}
738
+ />
739
+ {folders.map((f) => (
740
+ <FolderOption
741
+ key={f.id}
742
+ icon={f.icon}
743
+ label={f.name}
744
+ active={selected === f.id}
745
+ onClick={() => setSelected(f.id)}
746
+ />
747
+ ))}
748
+ </div>
749
+ <DialogFooter>
750
+ <Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
751
+ {t.common.cancel}
752
+ </Button>
753
+ <Button size="sm" disabled={submitting || selected === currentFolderId} onClick={submit}>
754
+ {t.common.move}
755
+ </Button>
756
+ </DialogFooter>
757
+ </DialogContent>
758
+ </Dialog>
759
+ );
760
+ }
761
+
762
+ function FolderOption({
763
+ icon,
764
+ label,
765
+ active,
766
+ onClick,
767
+ }: {
768
+ icon: FolderIcon;
769
+ label: string;
770
+ active: boolean;
771
+ onClick: () => void;
772
+ }) {
773
+ const tOpt = useLocale();
774
+ return (
775
+ <button
776
+ type="button"
777
+ onClick={onClick}
778
+ className={cn(
779
+ 'flex w-full items-center gap-2 border-b border-hairline px-3 py-2 text-left text-[13px] transition-colors last:border-b-0',
780
+ active ? 'bg-muted text-foreground' : 'hover:bg-muted/60',
781
+ )}
782
+ >
783
+ <FolderIconChip icon={icon} />
784
+ <span className="truncate">{label}</span>
785
+ {active && (
786
+ <span className="ml-auto inline-flex items-center gap-1 text-[10.5px] text-brand">
787
+ <span className="inline-block size-1 rounded-full bg-brand" aria-hidden />
788
+ {tOpt.common.selected}
789
+ </span>
790
+ )}
791
+ </button>
792
+ );
793
+ }
794
+
795
+ function DeleteDialog({
796
+ open,
797
+ docName,
798
+ onOpenChange,
799
+ onConfirm,
800
+ }: {
801
+ open: boolean;
802
+ docName: string;
803
+ onOpenChange: (open: boolean) => void;
804
+ onConfirm: () => Promise<void> | void;
805
+ }) {
806
+ const [submitting, setSubmitting] = useState(false);
807
+ const t = useLocale();
808
+
809
+ useEffect(() => {
810
+ if (open) setSubmitting(false);
811
+ }, [open]);
812
+
813
+ const confirm = async () => {
814
+ setSubmitting(true);
815
+ try {
816
+ await onConfirm();
817
+ } finally {
818
+ setSubmitting(false);
819
+ }
820
+ };
821
+
822
+ return (
823
+ <Dialog open={open} onOpenChange={onOpenChange}>
824
+ <DialogContent>
825
+ <DialogHeader>
826
+ <span className="eyebrow text-destructive/80">{t.home.deleteDialogEyebrow}</span>
827
+ <DialogTitle>{t.home.deleteDialogTitle}</DialogTitle>
828
+ <DialogDescription>
829
+ {t.home.deleteDialogDescriptionPrefix}
830
+ <span className="font-medium text-foreground">{docName}</span>
831
+ {t.home.deleteDialogDescriptionMid}
832
+ {t.home.deleteDialogDescriptionSuffix}
833
+ </DialogDescription>
834
+ </DialogHeader>
835
+ <DialogFooter>
836
+ <Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
837
+ {t.common.cancel}
838
+ </Button>
839
+ <Button variant="destructive" size="sm" disabled={submitting} onClick={confirm}>
840
+ {t.common.delete}
841
+ </Button>
842
+ </DialogFooter>
843
+ </DialogContent>
844
+ </Dialog>
845
+ );
846
+ }