@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,302 @@
1
+ import { Plus } from 'lucide-react';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { toast } from 'sonner';
4
+ import { LanguageToggle } from '@/components/language-toggle';
5
+ import { ThemeToggle } from '@/components/theme-toggle';
6
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
7
+ import type { Folder, FolderIcon } from '@/lib/sdk';
8
+ import { format, useLocale } from '@/lib/use-locale';
9
+ import { cn } from '@/lib/utils';
10
+ import { CommandMenuTrigger } from '../command/command-menu';
11
+ import { FolderIconChip, FolderItem } from './folder-item';
12
+ import { IconPicker, PRESET_COLORS } from './icon-picker';
13
+ import { SidebarFooter } from './sidebar-footer';
14
+
15
+ export const ALL_DOCS_ID = '__all__';
16
+ export const DRAFT_ID = 'draft';
17
+ export const THEMES_ID = '__themes__';
18
+ export const ASSETS_ID = '__assets__';
19
+
20
+ export const FOLDER_DND_MIME = 'application/x-folder-id';
21
+
22
+ export function Sidebar({
23
+ folders,
24
+ countFor,
25
+ allCount,
26
+ themesCount,
27
+ assetsCount,
28
+ selectedId,
29
+ onSelect,
30
+ onCreate,
31
+ onRename,
32
+ onChangeIcon,
33
+ onDelete,
34
+ onDropToFolder,
35
+ onDropToDraft,
36
+ onReorder,
37
+ onOpenCommandMenu,
38
+ }: {
39
+ folders: Folder[];
40
+ countFor: (folderId: string | null) => number;
41
+ allCount: number;
42
+ themesCount: number;
43
+ assetsCount: number;
44
+ selectedId: string;
45
+ onSelect: (id: string) => void;
46
+ onCreate: (name: string, icon: FolderIcon) => Promise<Folder> | undefined;
47
+ onRename: (id: string, name: string) => void;
48
+ onChangeIcon: (id: string, icon: FolderIcon) => void;
49
+ onDelete: (id: string) => void;
50
+ onDropToFolder: (folderId: string, pageId: string) => void;
51
+ onDropToDraft: (pageId: string) => void;
52
+ onReorder: (ids: string[]) => void;
53
+ onOpenCommandMenu: () => void;
54
+ }) {
55
+ const [dragId, setDragId] = useState<string | null>(null);
56
+ const [dropTarget, setDropTarget] = useState<{ id: string; before: boolean } | null>(null);
57
+
58
+ const finishReorder = (toId: string, before: boolean) => {
59
+ const fromId = dragId;
60
+ setDragId(null);
61
+ setDropTarget(null);
62
+ if (!fromId || fromId === toId) return;
63
+ const ids = folders.map((f) => f.id);
64
+ if (!ids.includes(fromId) || !ids.includes(toId)) return;
65
+ const next = ids.filter((id) => id !== fromId);
66
+ next.splice(next.indexOf(toId) + (before ? 0 : 1), 0, fromId);
67
+ if (next.every((id, i) => id === ids[i])) return;
68
+ onReorder(next);
69
+ };
70
+ const [creating, setCreating] = useState(false);
71
+ const [newName, setNewName] = useState('');
72
+ const [newIcon, setNewIcon] = useState<FolderIcon>(() => ({
73
+ type: 'color',
74
+ value: PRESET_COLORS[0],
75
+ }));
76
+ const [iconOpen, setIconOpen] = useState(false);
77
+ const inputRef = useRef<HTMLInputElement>(null);
78
+ const t = useLocale();
79
+
80
+ const startCreating = () => {
81
+ const color = PRESET_COLORS[folders.length % PRESET_COLORS.length];
82
+ setNewIcon({ type: 'color', value: color });
83
+ setNewName('');
84
+ setCreating(true);
85
+ };
86
+
87
+ useEffect(() => {
88
+ if (creating) inputRef.current?.focus();
89
+ }, [creating]);
90
+
91
+ const stateRef = useRef({ name: newName, icon: newIcon, iconOpen });
92
+ stateRef.current = { name: newName, icon: newIcon, iconOpen };
93
+
94
+ const exitCreate = () => {
95
+ setCreating(false);
96
+ setNewName('');
97
+ setIconOpen(false);
98
+ };
99
+
100
+ const commitCreate = async () => {
101
+ const trimmed = stateRef.current.name.trim();
102
+ const icon = stateRef.current.icon;
103
+ if (!trimmed) {
104
+ exitCreate();
105
+ return;
106
+ }
107
+ exitCreate();
108
+ try {
109
+ const folder = await onCreate(trimmed, icon);
110
+ toast.success(format(t.home.toastFolderCreated, { name: folder?.name ?? trimmed }));
111
+ } catch {
112
+ toast.error(t.home.toastFolderCreateFailed);
113
+ }
114
+ };
115
+
116
+ // biome-ignore lint/correctness/useExhaustiveDependencies: commitCreate reads latest state via stateRef
117
+ useEffect(() => {
118
+ if (!creating) return;
119
+ const onDown = (e: MouseEvent) => {
120
+ if (stateRef.current.iconOpen) return;
121
+ const target = e.target as HTMLElement | null;
122
+ if (!target) return;
123
+ if (target.closest('[data-folder-create]')) return;
124
+ if (target.closest('[data-slot="popover-content"]')) return;
125
+ commitCreate();
126
+ };
127
+ document.addEventListener('mousedown', onDown);
128
+ return () => document.removeEventListener('mousedown', onDown);
129
+ }, [creating]);
130
+
131
+ return (
132
+ <aside className="relative flex h-full w-[16.5rem] shrink-0 flex-col border-r border-hairline bg-sidebar text-sidebar-foreground">
133
+ <div className="flex items-center justify-between px-4 pt-5 pb-4">
134
+ <h1 className="font-heading text-lg font-bold tracking-tight">{t.home.appTitle}</h1>
135
+ <div className="-mr-1.5 flex items-center">
136
+ <CommandMenuTrigger onClick={onOpenCommandMenu} />
137
+ <LanguageToggle />
138
+ <ThemeToggle />
139
+ </div>
140
+ </div>
141
+
142
+ <div className="px-2">
143
+ <FolderItem
144
+ row={{ kind: 'all' }}
145
+ count={allCount}
146
+ selected={selectedId === ALL_DOCS_ID}
147
+ onSelect={() => onSelect(ALL_DOCS_ID)}
148
+ onDropDoc={() => {}}
149
+ />
150
+ <FolderItem
151
+ row={{ kind: 'themes' }}
152
+ count={themesCount}
153
+ selected={selectedId === THEMES_ID}
154
+ onSelect={() => onSelect(THEMES_ID)}
155
+ onDropDoc={() => {}}
156
+ />
157
+ {import.meta.env.DEV && (
158
+ <FolderItem
159
+ row={{ kind: 'assets' }}
160
+ count={assetsCount}
161
+ selected={selectedId === ASSETS_ID}
162
+ onSelect={() => onSelect(ASSETS_ID)}
163
+ onDropDoc={() => {}}
164
+ />
165
+ )}
166
+ </div>
167
+
168
+ <div className="mt-5 flex items-center gap-2 px-4 pb-1.5">
169
+ <span className="eyebrow">{t.home.folders}</span>
170
+ <span className="h-px flex-1 bg-hairline" aria-hidden />
171
+ </div>
172
+
173
+ <div className="flex-1 overflow-y-auto px-2 pb-2">
174
+ <FolderItem
175
+ row={{ kind: 'draft' }}
176
+ count={countFor(null)}
177
+ selected={selectedId === DRAFT_ID}
178
+ onSelect={() => onSelect(DRAFT_ID)}
179
+ onDropDoc={onDropToDraft}
180
+ />
181
+ {folders.map((folder) => {
182
+ const isDropTarget = dropTarget?.id === folder.id;
183
+ const before = isDropTarget && dropTarget.before;
184
+ const after = isDropTarget && !dropTarget.before;
185
+ return (
186
+ // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop handle wraps the row
187
+ <div
188
+ key={folder.id}
189
+ className={cn(
190
+ 'relative',
191
+ before &&
192
+ 'before:absolute before:inset-x-2 before:-top-px before:h-[2px] before:rounded-full before:bg-brand',
193
+ after &&
194
+ 'after:absolute after:inset-x-2 after:-bottom-px after:h-[2px] after:rounded-full after:bg-brand',
195
+ dragId === folder.id && 'opacity-50',
196
+ )}
197
+ draggable={import.meta.env.DEV}
198
+ onDragStart={(e) => {
199
+ if (!import.meta.env.DEV) return;
200
+ e.dataTransfer.setData(FOLDER_DND_MIME, folder.id);
201
+ e.dataTransfer.effectAllowed = 'move';
202
+ setDragId(folder.id);
203
+ }}
204
+ onDragEnd={() => {
205
+ setDragId(null);
206
+ setDropTarget(null);
207
+ }}
208
+ onDragOver={(e) => {
209
+ if (!e.dataTransfer.types.includes(FOLDER_DND_MIME)) return;
210
+ e.preventDefault();
211
+ e.dataTransfer.dropEffect = 'move';
212
+ const rect = e.currentTarget.getBoundingClientRect();
213
+ const isBefore = e.clientY < rect.top + rect.height / 2;
214
+ if (!dropTarget || dropTarget.id !== folder.id || dropTarget.before !== isBefore) {
215
+ setDropTarget({ id: folder.id, before: isBefore });
216
+ }
217
+ }}
218
+ onDragLeave={(e) => {
219
+ if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
220
+ if (dropTarget?.id === folder.id) setDropTarget(null);
221
+ }}
222
+ onDrop={(e) => {
223
+ const fromId = e.dataTransfer.getData(FOLDER_DND_MIME);
224
+ if (!fromId) return;
225
+ e.preventDefault();
226
+ e.stopPropagation();
227
+ const rect = e.currentTarget.getBoundingClientRect();
228
+ const isBefore = e.clientY < rect.top + rect.height / 2;
229
+ finishReorder(folder.id, isBefore);
230
+ }}
231
+ >
232
+ <FolderItem
233
+ row={{
234
+ kind: 'folder',
235
+ folder,
236
+ onRename: (name) => onRename(folder.id, name),
237
+ onChangeIcon: (icon) => onChangeIcon(folder.id, icon),
238
+ onDelete: () => onDelete(folder.id),
239
+ }}
240
+ count={countFor(folder.id)}
241
+ selected={selectedId === folder.id}
242
+ onSelect={() => onSelect(folder.id)}
243
+ onDropDoc={(pageId) => onDropToFolder(folder.id, pageId)}
244
+ />
245
+ </div>
246
+ );
247
+ })}
248
+
249
+ {import.meta.env.DEV &&
250
+ (creating ? (
251
+ <div
252
+ data-folder-create
253
+ className="mt-1 flex items-center gap-2.5 rounded-[5px] border border-dashed border-foreground/30 bg-card px-2 py-[5px]"
254
+ >
255
+ <Popover open={iconOpen} onOpenChange={setIconOpen}>
256
+ <PopoverTrigger
257
+ render={
258
+ <button
259
+ type="button"
260
+ className="flex size-5 shrink-0 items-center justify-center rounded transition-transform hover:scale-110"
261
+ aria-label={t.home.pickIcon}
262
+ >
263
+ <FolderIconChip icon={newIcon} />
264
+ </button>
265
+ }
266
+ />
267
+ <PopoverContent side="right" align="start" className="w-auto p-2">
268
+ <IconPicker value={newIcon} onChange={setNewIcon} />
269
+ </PopoverContent>
270
+ </Popover>
271
+ <input
272
+ ref={inputRef}
273
+ value={newName}
274
+ onChange={(e) => setNewName(e.target.value)}
275
+ onKeyDown={(e) => {
276
+ if (e.nativeEvent.isComposing) return;
277
+ if (e.key === 'Enter') commitCreate();
278
+ if (e.key === 'Escape') exitCreate();
279
+ }}
280
+ placeholder={t.home.folderName}
281
+ maxLength={40}
282
+ className="min-w-0 flex-1 bg-transparent text-[12.5px] outline-none placeholder:text-muted-foreground/60"
283
+ />
284
+ </div>
285
+ ) : (
286
+ <button
287
+ type="button"
288
+ onClick={startCreating}
289
+ className="mt-1 flex w-full items-center gap-2 rounded-[5px] px-2 py-1.5 text-[12px] text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
290
+ >
291
+ <Plus className="size-3.5" />
292
+ <span>{t.home.newFolder}</span>
293
+ </button>
294
+ ))}
295
+ </div>
296
+
297
+ <div className="border-t border-hairline">
298
+ <SidebarFooter />
299
+ </div>
300
+ </aside>
301
+ );
302
+ }
@@ -0,0 +1,66 @@
1
+ import { Monitor, Moon, Sun } from 'lucide-react';
2
+ import { useTheme } from 'next-themes';
3
+ import { useEffect, useState } from 'react';
4
+ import { IconTooltip } from '@/components/icon-tooltip';
5
+ import { buttonVariants } from '@/components/ui/button';
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuItem,
10
+ DropdownMenuTrigger,
11
+ } from '@/components/ui/dropdown-menu';
12
+ import { TooltipTrigger } from '@/components/ui/tooltip';
13
+ import { useLocale } from '@/lib/use-locale';
14
+ import { cn } from '@/lib/utils';
15
+
16
+ export function ThemeToggle() {
17
+ const { theme, setTheme } = useTheme();
18
+ const [mounted, setMounted] = useState(false);
19
+ const t = useLocale();
20
+
21
+ useEffect(() => {
22
+ setMounted(true);
23
+ }, []);
24
+
25
+ return (
26
+ <DropdownMenu>
27
+ <IconTooltip label={t.themeToggle.title}>
28
+ <DropdownMenuTrigger
29
+ render={
30
+ <TooltipTrigger
31
+ type="button"
32
+ aria-label={t.themeToggle.toggleAria}
33
+ className={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }), 'relative')}
34
+ />
35
+ }
36
+ >
37
+ <Sun className="size-3.5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
38
+ <Moon className="absolute size-3.5 scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
39
+ </DropdownMenuTrigger>
40
+ </IconTooltip>
41
+ <DropdownMenuContent align="end" className="min-w-[140px]">
42
+ <DropdownMenuItem
43
+ onClick={() => setTheme('light')}
44
+ data-active={mounted && theme === 'light'}
45
+ >
46
+ <Sun />
47
+ {t.themeToggle.light}
48
+ </DropdownMenuItem>
49
+ <DropdownMenuItem
50
+ onClick={() => setTheme('dark')}
51
+ data-active={mounted && theme === 'dark'}
52
+ >
53
+ <Moon />
54
+ {t.themeToggle.dark}
55
+ </DropdownMenuItem>
56
+ <DropdownMenuItem
57
+ onClick={() => setTheme('system')}
58
+ data-active={mounted && theme === 'system'}
59
+ >
60
+ <Monitor />
61
+ {t.themeToggle.system}
62
+ </DropdownMenuItem>
63
+ </DropdownMenuContent>
64
+ </DropdownMenu>
65
+ );
66
+ }
@@ -0,0 +1,226 @@
1
+ import { ChevronDown, ChevronLeft } from 'lucide-react';
2
+ import { Fragment, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Link } from 'react-router-dom';
4
+ import { Button } from '@/components/ui/button';
5
+ import { useLocale } from '@/lib/use-locale';
6
+ import { cn } from '@/lib/utils';
7
+ import { frameUrl } from '../../lib/frame';
8
+ import { PageThumb } from '../../lib/page-thumb';
9
+ import { loadPage, pageKinds, pagesByTheme } from '../../lib/pages';
10
+ import type { PageModule } from '../../lib/sdk';
11
+ import { themes } from '../../lib/themes';
12
+
13
+ export function ThemeDetail({ themeId, onBack }: { themeId: string; onBack: () => void }) {
14
+ const t = useLocale();
15
+ const theme = useMemo(() => themes.find((th) => th.id === themeId), [themeId]);
16
+ const usedByDocIds = useMemo(() => (theme ? pagesByTheme(theme.id) : []), [theme]);
17
+
18
+ const promptRef = useRef<HTMLPreElement>(null);
19
+ const [promptExpanded, setPromptExpanded] = useState(false);
20
+ const [promptOverflows, setPromptOverflows] = useState(false);
21
+
22
+ const themeBody = theme?.body;
23
+ useEffect(() => {
24
+ setPromptExpanded(false);
25
+ const el = promptRef.current;
26
+ if (!el || !themeBody) return;
27
+ setPromptOverflows(el.scrollHeight > PROMPT_COLLAPSED_PX + 8);
28
+ }, [themeBody]);
29
+
30
+ if (!theme) {
31
+ return (
32
+ <div className="px-8 py-12">
33
+ <Button variant="ghost" size="sm" onClick={onBack}>
34
+ <ChevronLeft className="size-4" />
35
+ {t.themes.backToGallery}
36
+ </Button>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <div className="flex flex-col gap-6 md:gap-8">
43
+ <div className="flex items-center gap-3">
44
+ <Button variant="ghost" size="sm" onClick={onBack} className="-ml-2">
45
+ <ChevronLeft className="size-4" />
46
+ {t.themes.backToGallery}
47
+ </Button>
48
+ </div>
49
+
50
+ <header className="flex flex-wrap items-baseline gap-3">
51
+ <h2 className="font-heading text-[26px] font-semibold leading-[1.05] tracking-[-0.025em] md:text-[32px]">
52
+ {theme.name}
53
+ </h2>
54
+ {theme.description ? (
55
+ <p className="basis-full text-[13px] leading-relaxed text-muted-foreground">
56
+ {theme.description}
57
+ </p>
58
+ ) : null}
59
+ </header>
60
+
61
+ <div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)] lg:gap-8">
62
+ <div className="flex min-w-0 flex-col gap-6">
63
+ <div className="flex flex-col gap-3">
64
+ <div className="relative aspect-video overflow-hidden rounded-[8px] border border-hairline bg-card shadow-edge ring-1 ring-foreground/[0.04]">
65
+ {!theme.hasDemo ? (
66
+ <NoDemoLargeState />
67
+ ) : (
68
+ <iframe
69
+ title={theme.name}
70
+ src={frameUrl({ themeId: theme.id })}
71
+ className="h-full w-full border-0 bg-white"
72
+ />
73
+ )}
74
+ </div>
75
+ </div>
76
+
77
+ <div className="relative">
78
+ <pre
79
+ ref={promptRef}
80
+ style={
81
+ promptOverflows && !promptExpanded ? { maxHeight: PROMPT_COLLAPSED_PX } : undefined
82
+ }
83
+ className={cn(
84
+ 'w-full rounded-[8px] border border-hairline bg-card p-4 font-mono text-[11.5px] leading-relaxed text-foreground/90',
85
+ promptOverflows && !promptExpanded ? 'overflow-hidden' : 'overflow-auto',
86
+ )}
87
+ >
88
+ {renderBodyWithSwatches(theme.body)}
89
+ </pre>
90
+ {promptOverflows && !promptExpanded ? (
91
+ <button
92
+ type="button"
93
+ aria-label={t.themes.expandPromptAria}
94
+ onClick={() => setPromptExpanded(true)}
95
+ className="absolute inset-x-0 bottom-0 flex h-24 items-end justify-center rounded-b-[8px] bg-gradient-to-t from-card via-card/85 to-transparent pb-3 text-muted-foreground transition-colors hover:text-foreground"
96
+ >
97
+ <ChevronDown className="size-4" />
98
+ </button>
99
+ ) : null}
100
+ {promptOverflows && promptExpanded ? (
101
+ <div className="mt-2 flex justify-center">
102
+ <button
103
+ type="button"
104
+ aria-label={t.themes.collapsePromptAria}
105
+ onClick={() => setPromptExpanded(false)}
106
+ className="flex size-8 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
107
+ >
108
+ <ChevronDown className="size-4 rotate-180" />
109
+ </button>
110
+ </div>
111
+ ) : null}
112
+ </div>
113
+ </div>
114
+
115
+ <aside className="flex min-w-0 flex-col gap-4">
116
+ <div className="flex flex-wrap items-baseline gap-3">
117
+ <span className="eyebrow">{t.themes.usedBy}</span>
118
+ {usedByDocIds.length > 0 ? (
119
+ <span className="folio">{usedByDocIds.length.toString().padStart(2, '0')}</span>
120
+ ) : null}
121
+ </div>
122
+ {usedByDocIds.length === 0 ? (
123
+ <p className="text-[12.5px] leading-relaxed text-muted-foreground">
124
+ {t.themes.usedByEmpty}
125
+ </p>
126
+ ) : (
127
+ <ul className="flex flex-col gap-5">
128
+ {usedByDocIds.map((id) => (
129
+ <li key={id}>
130
+ <ThemeDocCard id={id} />
131
+ </li>
132
+ ))}
133
+ </ul>
134
+ )}
135
+ </aside>
136
+ </div>
137
+ </div>
138
+ );
139
+ }
140
+
141
+ function ThemeDocCard({ id }: { id: string }) {
142
+ const [page, setPage] = useState<PageModule | null>(null);
143
+
144
+ useEffect(() => {
145
+ let cancelled = false;
146
+ loadPage(id)
147
+ .then((mod) => {
148
+ if (!cancelled) setPage(mod);
149
+ })
150
+ .catch(() => {});
151
+ return () => {
152
+ cancelled = true;
153
+ };
154
+ }, [id]);
155
+
156
+ const displayTitle = page?.meta?.title ?? id;
157
+
158
+ return (
159
+ <Link to={`/p/${id}`} className="group block focus-visible:outline-none">
160
+ <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">
161
+ <div className="h-full w-full motion-safe:transition-transform motion-safe:duration-300 motion-safe:group-hover:scale-[1.03]">
162
+ <PageThumb source={{ pageId: id, kind: pageKinds[id] }} title={displayTitle} />
163
+ </div>
164
+ </div>
165
+ <div className="mt-2.5">
166
+ <h3 className="min-w-0 truncate font-heading text-[13px] font-medium tracking-tight">
167
+ {displayTitle}
168
+ </h3>
169
+ <p className="mt-0.5 truncate font-mono text-[10.5px] text-muted-foreground/80">{id}</p>
170
+ </div>
171
+ </Link>
172
+ );
173
+ }
174
+
175
+ const PROMPT_COLLAPSED_PX = 320;
176
+
177
+ const HEX_RE = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})\b/g;
178
+
179
+ function renderBodyWithSwatches(body: string): ReactNode[] {
180
+ const out: ReactNode[] = [];
181
+ let lastIndex = 0;
182
+ let match: RegExpExecArray | null = HEX_RE.exec(body);
183
+ let key = 0;
184
+ while (match !== null) {
185
+ if (match.index > lastIndex) {
186
+ out.push(<Fragment key={`t${key}`}>{body.slice(lastIndex, match.index)}</Fragment>);
187
+ }
188
+ const hex = match[0];
189
+ out.push(
190
+ <span
191
+ key={`s${key}`}
192
+ aria-hidden
193
+ className="mr-[0.25em] -translate-y-[0.1em] inline-block size-[0.85em] rounded-[2px] align-middle ring-1 ring-foreground/15"
194
+ style={{ background: hex }}
195
+ />,
196
+ );
197
+ out.push(<Fragment key={`h${key}`}>{hex}</Fragment>);
198
+ lastIndex = match.index + hex.length;
199
+ key += 1;
200
+ match = HEX_RE.exec(body);
201
+ }
202
+ if (lastIndex < body.length) {
203
+ out.push(<Fragment key={`t${key}`}>{body.slice(lastIndex)}</Fragment>);
204
+ }
205
+ return out;
206
+ }
207
+
208
+ function NoDemoLargeState() {
209
+ const t = useLocale();
210
+ return (
211
+ <div className="grid h-full w-full place-items-center bg-muted/40 px-8 text-center">
212
+ <div className="max-w-sm">
213
+ <p className="font-heading text-[15px] font-semibold tracking-tight">
214
+ {t.themes.noDemoYet}
215
+ </p>
216
+ <p className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
217
+ {t.themes.noDemoHintPrefix}
218
+ <code className="rounded-[4px] bg-card px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
219
+ /create-theme
220
+ </code>
221
+ {t.themes.noDemoHintSuffix}
222
+ </p>
223
+ </div>
224
+ </div>
225
+ );
226
+ }
@@ -0,0 +1,104 @@
1
+ import { format, useLocale } from '@/lib/use-locale';
2
+ import { PageThumb } from '../../lib/page-thumb';
3
+ import { type Theme, themes } from '../../lib/themes';
4
+
5
+ export function ThemesGallery({ onOpen }: { onOpen: (id: string) => void }) {
6
+ const t = useLocale();
7
+
8
+ if (themes.length === 0) {
9
+ return <ThemesEmptyState />;
10
+ }
11
+
12
+ return (
13
+ <ul className="grid grid-cols-[repeat(auto-fill,minmax(min(240px,100%),1fr))] gap-x-6 gap-y-9 md:grid-cols-[repeat(auto-fill,minmax(340px,1fr))]">
14
+ {themes.map((theme) => (
15
+ <li key={theme.id}>
16
+ <ThemeCard
17
+ theme={theme}
18
+ onOpen={() => onOpen(theme.id)}
19
+ ariaLabel={format(t.themes.openThemeAria, { name: theme.name })}
20
+ />
21
+ </li>
22
+ ))}
23
+ </ul>
24
+ );
25
+ }
26
+
27
+ function ThemeCard({
28
+ theme,
29
+ onOpen,
30
+ ariaLabel,
31
+ }: {
32
+ theme: Theme;
33
+ onOpen: () => void;
34
+ ariaLabel: string;
35
+ }) {
36
+ return (
37
+ <button
38
+ type="button"
39
+ onClick={onOpen}
40
+ aria-label={ariaLabel}
41
+ className="group block w-full text-left focus-visible:outline-none"
42
+ >
43
+ <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">
44
+ <ThemePreview theme={theme} />
45
+ </div>
46
+ <div className="mt-3">
47
+ <h3 className="min-w-0 truncate font-heading text-[14px] font-medium tracking-tight">
48
+ {theme.name}
49
+ </h3>
50
+ </div>
51
+ {theme.description ? (
52
+ <p className="mt-1 line-clamp-2 text-[12px] leading-snug text-muted-foreground">
53
+ {theme.description}
54
+ </p>
55
+ ) : null}
56
+ </button>
57
+ );
58
+ }
59
+
60
+ function ThemePreview({ theme }: { theme: Theme }) {
61
+ if (!theme.hasDemo) return <NoDemoState />;
62
+ return <PageThumb source={{ themeId: theme.id }} title={theme.name} />;
63
+ }
64
+
65
+ function NoDemoState() {
66
+ const t = useLocale();
67
+ return (
68
+ <div className="grid h-full w-full place-items-center bg-muted/40 px-6 text-center">
69
+ <div>
70
+ <p className="font-heading text-[12px] font-semibold tracking-tight text-foreground/80">
71
+ {t.themes.noDemoYet}
72
+ </p>
73
+ <p className="mt-1 text-[10.5px] leading-snug text-muted-foreground">
74
+ {t.themes.noDemoHintPrefix}
75
+ <code className="rounded-[3px] bg-card px-1 py-0.5 font-mono text-[10px] text-foreground">
76
+ /create-theme
77
+ </code>
78
+ {t.themes.noDemoHintSuffix}
79
+ </p>
80
+ </div>
81
+ </div>
82
+ );
83
+ }
84
+
85
+ function ThemesEmptyState() {
86
+ const t = useLocale();
87
+ return (
88
+ <div className="rounded-[10px] border border-dashed border-border bg-card/60 px-8 py-20">
89
+ <div className="mx-auto flex max-w-md flex-col items-center text-center">
90
+ <div className="text-2xl">🎨</div>
91
+ <p className="mt-3 font-heading text-[15px] font-semibold tracking-tight">
92
+ {t.themes.noThemesTitle}
93
+ </p>
94
+ <p className="mt-1.5 text-[13px] leading-relaxed text-muted-foreground">
95
+ {t.themes.noThemesHintPrefix}
96
+ <code className="rounded-[4px] bg-muted px-1.5 py-0.5 font-mono text-[11.5px] text-foreground">
97
+ /create-theme
98
+ </code>
99
+ {t.themes.noThemesHintSuffix}
100
+ </p>
101
+ </div>
102
+ </div>
103
+ );
104
+ }