@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.
- package/LICENSE +23 -0
- package/README.md +108 -0
- package/bin.js +2 -0
- package/dist/build-TP72kiw7.js +35 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +100 -0
- package/dist/config-C9or4m4J.d.ts +23 -0
- package/dist/config-L0IbktJ0.js +3135 -0
- package/dist/design-C13iz9_4.js +35 -0
- package/dist/dev-I2IyEh5W.js +97 -0
- package/dist/export-B-kBRWB1.js +4 -0
- package/dist/export-ic28osQP.js +131 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +3 -0
- package/dist/locale/index.d.ts +24 -0
- package/dist/locale/index.js +881 -0
- package/dist/open-pages-plugin-D_DN_zjh.js +632 -0
- package/dist/preview-BrDuvgAN.js +21 -0
- package/dist/sync-Be_hxSOF.js +139 -0
- package/dist/sync-lKEXyPOh.js +3 -0
- package/dist/types-C5NCdDT-.d.ts +219 -0
- package/dist/update-CV62NhBT.js +37 -0
- package/dist/update-package-Cxt57pz7.js +152 -0
- package/dist/version-B6AiiGvT.js +26 -0
- package/dist/version-CeffgdfZ.js +3 -0
- package/dist/vite/index.d.ts +14 -0
- package/dist/vite/index.js +7 -0
- package/env.d.ts +59 -0
- package/package.json +106 -0
- package/skills/apply-comments/SKILL.md +86 -0
- package/skills/create-page/SKILL.md +92 -0
- package/skills/create-theme/SKILL.md +223 -0
- package/skills/current-page/SKILL.md +106 -0
- package/skills/page-authoring/SKILL.md +148 -0
- package/skills/page-authoring/references/assets-and-fonts.md +85 -0
- package/skills/page-authoring/references/html-pages.md +65 -0
- package/skills/page-authoring/references/interactivity.md +77 -0
- package/skills/page-authoring/references/layout-and-responsive.md +71 -0
- package/skills/page-authoring/references/typography-and-color.md +60 -0
- package/src/app/app.tsx +45 -0
- package/src/app/components/asset-view.tsx +1665 -0
- package/src/app/components/command/command-menu.tsx +227 -0
- package/src/app/components/command/command.tsx +142 -0
- package/src/app/components/command/home-command-menu.tsx +96 -0
- package/src/app/components/icon-tooltip.tsx +34 -0
- package/src/app/components/language-toggle.tsx +46 -0
- package/src/app/components/sidebar/folder-item.tsx +269 -0
- package/src/app/components/sidebar/icon-picker.tsx +61 -0
- package/src/app/components/sidebar/sidebar-footer.tsx +139 -0
- package/src/app/components/sidebar/sidebar.tsx +302 -0
- package/src/app/components/theme-toggle.tsx +66 -0
- package/src/app/components/themes/theme-detail.tsx +226 -0
- package/src/app/components/themes/themes-gallery.tsx +104 -0
- package/src/app/components/ui/badge.tsx +48 -0
- package/src/app/components/ui/button.tsx +93 -0
- package/src/app/components/ui/card.tsx +92 -0
- package/src/app/components/ui/context-menu.tsx +247 -0
- package/src/app/components/ui/dialog.tsx +155 -0
- package/src/app/components/ui/dropdown-menu.tsx +267 -0
- package/src/app/components/ui/input.tsx +25 -0
- package/src/app/components/ui/label.tsx +21 -0
- package/src/app/components/ui/popover.tsx +82 -0
- package/src/app/components/ui/progress.tsx +33 -0
- package/src/app/components/ui/scroll-area.tsx +53 -0
- package/src/app/components/ui/select.tsx +195 -0
- package/src/app/components/ui/separator.tsx +26 -0
- package/src/app/components/ui/slider.tsx +68 -0
- package/src/app/components/ui/sonner.tsx +48 -0
- package/src/app/components/ui/tabs.tsx +79 -0
- package/src/app/components/ui/textarea.tsx +22 -0
- package/src/app/components/ui/toggle-group.tsx +84 -0
- package/src/app/components/ui/toggle.tsx +45 -0
- package/src/app/components/ui/tooltip.tsx +75 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/frame/inspect.ts +140 -0
- package/src/app/frame/main.tsx +91 -0
- package/src/app/frame.html +12 -0
- package/src/app/index.html +13 -0
- package/src/app/lib/asset-filter.test.ts +176 -0
- package/src/app/lib/asset-filter.ts +80 -0
- package/src/app/lib/assets.ts +257 -0
- package/src/app/lib/design.ts +58 -0
- package/src/app/lib/folders.ts +239 -0
- package/src/app/lib/frame.ts +38 -0
- package/src/app/lib/locale-store.ts +67 -0
- package/src/app/lib/page-thumb.tsx +46 -0
- package/src/app/lib/pages.ts +28 -0
- package/src/app/lib/sdk.ts +43 -0
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/use-locale.ts +8 -0
- package/src/app/lib/use-page-module.ts +48 -0
- package/src/app/lib/use-page-titles.ts +29 -0
- package/src/app/lib/use-restart-server.ts +78 -0
- package/src/app/lib/utils.test.ts +25 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +252 -0
- package/src/app/routes/home.tsx +846 -0
- package/src/app/routes/page.tsx +329 -0
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/styles.css +405 -0
- package/src/app/virtual.d.ts +53 -0
- package/src/locale/en.ts +218 -0
- package/src/locale/format.ts +12 -0
- package/src/locale/index.ts +6 -0
- package/src/locale/ja.ts +221 -0
- package/src/locale/types.ts +215 -0
- package/src/locale/zh-cn.ts +218 -0
- 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
|
+
}
|