@open-aippt/core 1.13.2

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