@open-slide/core 1.0.4 → 1.0.5
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/dist/{build-DqfKmw9h.js → build-CoON6kTb.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-CN7J0RDO.js → config-Bxtztw-H.js} +373 -221
- package/dist/{config-DweCbRkQ.d.ts → config-D2y1AXaN.d.ts} +3 -0
- package/dist/{dev-jWxtWHAG.js → dev-IezNC17X.js} +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/locale/index.d.ts +24 -0
- package/dist/locale/index.js +1189 -0
- package/dist/{preview-CSA05Gfm.js → preview-BwYjtENY.js} +1 -1
- package/dist/types-BVvl_xup.d.ts +314 -0
- package/dist/vite/index.d.ts +2 -1
- package/dist/vite/index.js +1 -1
- package/package.json +7 -1
- package/src/app/app.tsx +6 -2
- package/src/app/components/asset-view.tsx +87 -64
- package/src/app/components/click-nav-zones.tsx +4 -2
- package/src/app/components/inspector/comment-widget.tsx +9 -7
- package/src/app/components/inspector/inspector-panel.tsx +68 -39
- package/src/app/components/inspector/inspector-provider.tsx +185 -58
- package/src/app/components/inspector/save-bar.tsx +6 -2
- package/src/app/components/panel/save-card.tsx +12 -9
- package/src/app/components/pdf-progress-toast.tsx +11 -4
- package/src/app/components/present/control-bar.tsx +17 -10
- package/src/app/components/present/help-overlay.tsx +18 -17
- package/src/app/components/present/overview-grid.tsx +6 -4
- package/src/app/components/sidebar/folder-item.tsx +16 -7
- package/src/app/components/sidebar/icon-picker.tsx +4 -2
- package/src/app/components/sidebar/sidebar.tsx +87 -25
- package/src/app/components/style-panel/style-panel.tsx +26 -18
- package/src/app/components/theme-toggle.tsx +7 -5
- package/src/app/components/thumbnail-rail.tsx +4 -2
- package/src/app/favicon.ico +0 -0
- package/src/app/lib/inspector/use-editor.ts +9 -7
- package/src/app/lib/use-locale.ts +20 -0
- package/src/app/routes/home.tsx +90 -45
- package/src/app/routes/presenter.tsx +45 -25
- package/src/app/routes/slide.tsx +37 -24
- package/src/app/styles.css +28 -0
- package/src/app/virtual.d.ts +4 -0
- package/src/locale/en.ts +303 -0
- package/src/locale/format.ts +12 -0
- package/src/locale/index.ts +6 -0
- package/src/locale/ja.ts +307 -0
- package/src/locale/types.ts +323 -0
- package/src/locale/zh-cn.ts +303 -0
- package/src/locale/zh-tw.ts +303 -0
|
@@ -1,18 +1,5 @@
|
|
|
1
1
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
2
|
-
|
|
3
|
-
const SHORTCUTS: Array<{ keys: string[]; label: string }> = [
|
|
4
|
-
{ keys: ['→', '↓', 'Space', 'PgDn'], label: 'Next slide' },
|
|
5
|
-
{ keys: ['←', '↑', 'PgUp'], label: 'Previous slide' },
|
|
6
|
-
{ keys: ['Home', 'End'], label: 'First / last slide' },
|
|
7
|
-
{ keys: ['1–9', 'Enter'], label: 'Jump to slide' },
|
|
8
|
-
{ keys: ['O'], label: 'Slide overview' },
|
|
9
|
-
{ keys: ['B'], label: 'Black screen' },
|
|
10
|
-
{ keys: ['W'], label: 'White screen' },
|
|
11
|
-
{ keys: ['L'], label: 'Laser pointer' },
|
|
12
|
-
{ keys: ['P'], label: 'Open Presenter View' },
|
|
13
|
-
{ keys: ['?', 'H'], label: 'Toggle this help' },
|
|
14
|
-
{ keys: ['Esc'], label: 'Close overlay / exit' },
|
|
15
|
-
];
|
|
2
|
+
import { useLocale } from '@/lib/use-locale';
|
|
16
3
|
|
|
17
4
|
type Props = {
|
|
18
5
|
open: boolean;
|
|
@@ -23,15 +10,29 @@ type Props = {
|
|
|
23
10
|
};
|
|
24
11
|
|
|
25
12
|
export function PresentHelpOverlay({ open, onOpenChange, container }: Props) {
|
|
13
|
+
const t = useLocale();
|
|
14
|
+
const shortcuts: Array<{ keys: string[]; label: string }> = [
|
|
15
|
+
{ keys: ['→', '↓', 'Space', 'PgDn'], label: t.present.shortcutNext },
|
|
16
|
+
{ keys: ['←', '↑', 'PgUp'], label: t.present.shortcutPrev },
|
|
17
|
+
{ keys: ['Home', 'End'], label: t.present.shortcutFirstLast },
|
|
18
|
+
{ keys: ['1–9', 'Enter'], label: t.present.shortcutJump },
|
|
19
|
+
{ keys: ['O'], label: t.present.shortcutOverview },
|
|
20
|
+
{ keys: ['B'], label: t.present.shortcutBlack },
|
|
21
|
+
{ keys: ['W'], label: t.present.shortcutWhite },
|
|
22
|
+
{ keys: ['L'], label: t.present.shortcutLaser },
|
|
23
|
+
{ keys: ['P'], label: t.present.shortcutPresenter },
|
|
24
|
+
{ keys: ['?', 'H'], label: t.present.shortcutToggleHelp },
|
|
25
|
+
{ keys: ['Esc'], label: t.present.shortcutCloseExit },
|
|
26
|
+
];
|
|
26
27
|
return (
|
|
27
28
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
28
29
|
<DialogContent container={container ?? undefined} className="max-w-lg sm:max-w-lg">
|
|
29
30
|
<DialogHeader>
|
|
30
|
-
<span className="eyebrow">
|
|
31
|
-
<DialogTitle>
|
|
31
|
+
<span className="eyebrow">{t.present.helpEyebrow}</span>
|
|
32
|
+
<DialogTitle>{t.present.helpTitle}</DialogTitle>
|
|
32
33
|
</DialogHeader>
|
|
33
34
|
<div className="grid grid-cols-1 gap-x-8 gap-y-2 sm:grid-cols-2">
|
|
34
|
-
{
|
|
35
|
+
{shortcuts.map((row) => (
|
|
35
36
|
<div
|
|
36
37
|
key={row.label}
|
|
37
38
|
className="flex items-center justify-between gap-3 border-b border-hairline py-1.5 last:border-0"
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
2
3
|
import { cn } from '@/lib/utils';
|
|
3
4
|
import type { DesignSystem } from '../../lib/design';
|
|
4
5
|
import type { Page } from '../../lib/sdk';
|
|
@@ -26,6 +27,7 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
|
|
|
26
27
|
const [focused, setFocused] = useState(current);
|
|
27
28
|
const gridRef = useRef<HTMLDivElement>(null);
|
|
28
29
|
const focusedRef = useRef<HTMLButtonElement | null>(null);
|
|
30
|
+
const t = useLocale();
|
|
29
31
|
|
|
30
32
|
// biome-ignore lint/correctness/useExhaustiveDependencies: only re-sync on open transition
|
|
31
33
|
useEffect(() => {
|
|
@@ -88,11 +90,11 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
|
|
|
88
90
|
<div
|
|
89
91
|
role="dialog"
|
|
90
92
|
aria-modal="true"
|
|
91
|
-
aria-label=
|
|
93
|
+
aria-label={t.present.overviewDialogAria}
|
|
92
94
|
className="absolute inset-0 z-50 flex flex-col bg-black/95 backdrop-blur-sm"
|
|
93
95
|
>
|
|
94
96
|
<div className="flex shrink-0 items-baseline justify-between px-8 pt-6 pb-3">
|
|
95
|
-
<span className="eyebrow text-white/55">
|
|
97
|
+
<span className="eyebrow text-white/55">{t.present.overviewEyebrow}</span>
|
|
96
98
|
<span className="font-mono text-[11px] text-white/55 tabular-nums">
|
|
97
99
|
{(focused + 1).toString().padStart(2, '0')} · {pages.length.toString().padStart(2, '0')}
|
|
98
100
|
</span>
|
|
@@ -118,7 +120,7 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
|
|
|
118
120
|
onClose();
|
|
119
121
|
}}
|
|
120
122
|
onMouseEnter={() => setFocused(i)}
|
|
121
|
-
aria-label={
|
|
123
|
+
aria-label={format(t.present.overviewGoToAria, { n: i + 1 })}
|
|
122
124
|
aria-current={isCurrent ? 'true' : undefined}
|
|
123
125
|
className={cn(
|
|
124
126
|
'group/thumb flex flex-col items-start gap-2 rounded-[6px] p-1.5 outline-none transition-colors',
|
|
@@ -146,7 +148,7 @@ export function PresentOverviewGrid({ pages, design, open, current, onClose, onS
|
|
|
146
148
|
aria-hidden
|
|
147
149
|
className="pointer-events-none absolute top-1.5 right-1.5 rounded-[3px] bg-[var(--brand,#ef4444)] px-1.5 py-0.5 font-mono text-[9.5px] tracking-[0.06em] uppercase text-white"
|
|
148
150
|
>
|
|
149
|
-
|
|
151
|
+
{t.present.nowBadge}
|
|
150
152
|
</span>
|
|
151
153
|
)}
|
|
152
154
|
</div>
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from '@/components/ui/dropdown-menu';
|
|
9
9
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
10
10
|
import type { Folder, FolderIcon } from '@/lib/sdk';
|
|
11
|
+
import { useLocale } from '@/lib/use-locale';
|
|
11
12
|
import { cn } from '@/lib/utils';
|
|
12
13
|
import { IconPicker } from './icon-picker';
|
|
13
14
|
|
|
@@ -86,6 +87,7 @@ export function FolderItem({
|
|
|
86
87
|
const dragDepth = useRef(0);
|
|
87
88
|
const [draftName, setDraftName] = useState(row.kind === 'folder' ? row.folder.name : '');
|
|
88
89
|
const slideDragActive = useSlideDragActive();
|
|
90
|
+
const t = useLocale();
|
|
89
91
|
|
|
90
92
|
const isSlideDrag = (e: React.DragEvent) => e.dataTransfer.types.includes(SLIDE_DND_MIME);
|
|
91
93
|
const handleDragEnter = (e: React.DragEvent) => {
|
|
@@ -114,7 +116,7 @@ export function FolderItem({
|
|
|
114
116
|
|
|
115
117
|
const icon =
|
|
116
118
|
row.kind === 'draft' ? ({ type: 'emoji', value: '📝' } satisfies FolderIcon) : row.folder.icon;
|
|
117
|
-
const label = row.kind === 'draft' ?
|
|
119
|
+
const label = row.kind === 'draft' ? t.home.draft : row.folder.name;
|
|
118
120
|
|
|
119
121
|
const commitRename = () => {
|
|
120
122
|
if (row.kind !== 'folder') return;
|
|
@@ -148,7 +150,7 @@ export function FolderItem({
|
|
|
148
150
|
<button
|
|
149
151
|
type="button"
|
|
150
152
|
className="flex size-5 shrink-0 items-center justify-center rounded transition-transform hover:scale-110"
|
|
151
|
-
aria-label=
|
|
153
|
+
aria-label={t.home.changeIcon}
|
|
152
154
|
onClick={(e) => e.stopPropagation()}
|
|
153
155
|
>
|
|
154
156
|
<FolderIconChip icon={icon} />
|
|
@@ -185,7 +187,14 @@ export function FolderItem({
|
|
|
185
187
|
</button>
|
|
186
188
|
)}
|
|
187
189
|
|
|
188
|
-
<span
|
|
190
|
+
<span
|
|
191
|
+
className={cn(
|
|
192
|
+
'folio ml-auto shrink-0 transition-opacity',
|
|
193
|
+
row.kind === 'folder' &&
|
|
194
|
+
import.meta.env.DEV &&
|
|
195
|
+
'group-hover:opacity-0 group-has-[[aria-expanded=true]]:opacity-0',
|
|
196
|
+
)}
|
|
197
|
+
>
|
|
189
198
|
{count.toString().padStart(2, '0')}
|
|
190
199
|
</span>
|
|
191
200
|
|
|
@@ -195,8 +204,8 @@ export function FolderItem({
|
|
|
195
204
|
<button
|
|
196
205
|
type="button"
|
|
197
206
|
onClick={(e) => e.stopPropagation()}
|
|
198
|
-
className="size-5
|
|
199
|
-
aria-label=
|
|
207
|
+
className="absolute right-2 top-1/2 size-5 -translate-y-1/2 rounded opacity-0 transition-opacity hover:bg-foreground/10 group-hover:opacity-100 aria-expanded:opacity-100"
|
|
208
|
+
aria-label={t.home.folderActions}
|
|
200
209
|
>
|
|
201
210
|
<MoreHorizontal className="mx-auto size-3.5" />
|
|
202
211
|
</button>
|
|
@@ -209,11 +218,11 @@ export function FolderItem({
|
|
|
209
218
|
}}
|
|
210
219
|
>
|
|
211
220
|
<Pencil />
|
|
212
|
-
|
|
221
|
+
{t.common.rename}
|
|
213
222
|
</DropdownMenuItem>
|
|
214
223
|
<DropdownMenuItem variant="destructive" onSelect={() => row.onDelete()}>
|
|
215
224
|
<Trash2 />
|
|
216
|
-
|
|
225
|
+
{t.common.delete}
|
|
217
226
|
</DropdownMenuItem>
|
|
218
227
|
</DropdownMenuContent>
|
|
219
228
|
</DropdownMenu>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import EmojiPicker, { EmojiStyle, Theme } from 'emoji-picker-react';
|
|
2
2
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
3
3
|
import type { FolderIcon } from '@/lib/sdk';
|
|
4
|
+
import { useLocale } from '@/lib/use-locale';
|
|
4
5
|
|
|
5
6
|
// Editorial palette — restrained warm/earth tones, no shadcn defaults
|
|
6
7
|
// (no #8b5cf6 violet, no #3b82f6 blue, etc.). Picked to coexist with the
|
|
@@ -23,11 +24,12 @@ export function IconPicker({
|
|
|
23
24
|
value: FolderIcon;
|
|
24
25
|
onChange: (icon: FolderIcon) => void;
|
|
25
26
|
}) {
|
|
27
|
+
const t = useLocale();
|
|
26
28
|
return (
|
|
27
29
|
<Tabs defaultValue={value.type} className="w-[320px]">
|
|
28
30
|
<TabsList className="w-full">
|
|
29
|
-
<TabsTrigger value="emoji">
|
|
30
|
-
<TabsTrigger value="color">
|
|
31
|
+
<TabsTrigger value="emoji">{t.home.iconEmojiTab}</TabsTrigger>
|
|
32
|
+
<TabsTrigger value="color">{t.home.iconColorTab}</TabsTrigger>
|
|
31
33
|
</TabsList>
|
|
32
34
|
|
|
33
35
|
<TabsContent value="emoji">
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { Plus } from 'lucide-react';
|
|
2
|
-
import { useState } from 'react';
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { toast } from 'sonner';
|
|
3
4
|
import { ThemeToggle } from '@/components/theme-toggle';
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
4
6
|
import type { Folder, FolderIcon } from '@/lib/sdk';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
8
|
+
import { FolderIconChip, FolderItem } from './folder-item';
|
|
9
|
+
import { IconPicker, PRESET_COLORS } from './icon-picker';
|
|
7
10
|
|
|
8
11
|
export const DRAFT_ID = 'draft';
|
|
9
12
|
|
|
@@ -32,25 +35,72 @@ export function Sidebar({
|
|
|
32
35
|
}) {
|
|
33
36
|
const [creating, setCreating] = useState(false);
|
|
34
37
|
const [newName, setNewName] = useState('');
|
|
38
|
+
const [newIcon, setNewIcon] = useState<FolderIcon>(() => ({
|
|
39
|
+
type: 'color',
|
|
40
|
+
value: PRESET_COLORS[0],
|
|
41
|
+
}));
|
|
42
|
+
const [iconOpen, setIconOpen] = useState(false);
|
|
43
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
44
|
+
const t = useLocale();
|
|
35
45
|
|
|
36
|
-
const
|
|
37
|
-
const trimmed = newName.trim();
|
|
38
|
-
if (!trimmed) {
|
|
39
|
-
setCreating(false);
|
|
40
|
-
setNewName('');
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
46
|
+
const startCreating = () => {
|
|
43
47
|
const color = PRESET_COLORS[folders.length % PRESET_COLORS.length];
|
|
44
|
-
|
|
48
|
+
setNewIcon({ type: 'color', value: color });
|
|
45
49
|
setNewName('');
|
|
50
|
+
setCreating(true);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (creating) inputRef.current?.focus();
|
|
55
|
+
}, [creating]);
|
|
56
|
+
|
|
57
|
+
const stateRef = useRef({ name: newName, icon: newIcon, iconOpen });
|
|
58
|
+
stateRef.current = { name: newName, icon: newIcon, iconOpen };
|
|
59
|
+
|
|
60
|
+
const exitCreate = () => {
|
|
46
61
|
setCreating(false);
|
|
62
|
+
setNewName('');
|
|
63
|
+
setIconOpen(false);
|
|
47
64
|
};
|
|
48
65
|
|
|
66
|
+
const commitCreate = async () => {
|
|
67
|
+
const trimmed = stateRef.current.name.trim();
|
|
68
|
+
const icon = stateRef.current.icon;
|
|
69
|
+
if (!trimmed) {
|
|
70
|
+
exitCreate();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
exitCreate();
|
|
74
|
+
try {
|
|
75
|
+
const folder = await onCreate(trimmed, icon);
|
|
76
|
+
toast.success(format(t.home.toastFolderCreated, { name: folder?.name ?? trimmed }));
|
|
77
|
+
} catch {
|
|
78
|
+
toast.error(t.home.toastFolderCreateFailed);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: commitCreate reads latest state via stateRef
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (!creating) return;
|
|
85
|
+
const onDown = (e: MouseEvent) => {
|
|
86
|
+
if (stateRef.current.iconOpen) return;
|
|
87
|
+
const target = e.target as HTMLElement | null;
|
|
88
|
+
if (!target) return;
|
|
89
|
+
if (target.closest('[data-folder-create]')) return;
|
|
90
|
+
if (target.closest('[data-radix-popper-content-wrapper]')) return;
|
|
91
|
+
commitCreate();
|
|
92
|
+
};
|
|
93
|
+
document.addEventListener('mousedown', onDown);
|
|
94
|
+
return () => document.removeEventListener('mousedown', onDown);
|
|
95
|
+
}, [creating]);
|
|
96
|
+
|
|
49
97
|
return (
|
|
50
98
|
<aside className="paper relative flex h-full w-[16.5rem] shrink-0 flex-col border-r border-hairline bg-sidebar text-sidebar-foreground">
|
|
51
99
|
<div className="flex items-center justify-between px-4 pt-5 pb-4">
|
|
52
|
-
<h1 className="font-heading text-lg font-bold tracking-tight">
|
|
53
|
-
<
|
|
100
|
+
<h1 className="font-heading text-lg font-bold tracking-tight">{t.home.appTitle}</h1>
|
|
101
|
+
<div className="-mr-1.5">
|
|
102
|
+
<ThemeToggle />
|
|
103
|
+
</div>
|
|
54
104
|
</div>
|
|
55
105
|
|
|
56
106
|
<div className="px-2">
|
|
@@ -64,9 +114,8 @@ export function Sidebar({
|
|
|
64
114
|
</div>
|
|
65
115
|
|
|
66
116
|
<div className="mt-5 flex items-center gap-2 px-4 pb-1.5">
|
|
67
|
-
<span className="eyebrow">
|
|
117
|
+
<span className="eyebrow">{t.home.folders}</span>
|
|
68
118
|
<span className="h-px flex-1 bg-hairline" aria-hidden />
|
|
69
|
-
<span className="folio">{folders.length.toString().padStart(2, '0')}</span>
|
|
70
119
|
</div>
|
|
71
120
|
|
|
72
121
|
<div className="flex-1 overflow-y-auto px-2 pb-2">
|
|
@@ -89,20 +138,33 @@ export function Sidebar({
|
|
|
89
138
|
|
|
90
139
|
{import.meta.env.DEV &&
|
|
91
140
|
(creating ? (
|
|
92
|
-
<div
|
|
93
|
-
|
|
141
|
+
<div
|
|
142
|
+
data-folder-create
|
|
143
|
+
className="mt-1 flex items-center gap-2.5 rounded-[5px] border border-dashed border-foreground/30 bg-card px-2 py-[5px]"
|
|
144
|
+
>
|
|
145
|
+
<Popover open={iconOpen} onOpenChange={setIconOpen}>
|
|
146
|
+
<PopoverTrigger asChild>
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
className="flex size-5 shrink-0 items-center justify-center rounded transition-transform hover:scale-110"
|
|
150
|
+
aria-label={t.home.pickIcon}
|
|
151
|
+
>
|
|
152
|
+
<FolderIconChip icon={newIcon} />
|
|
153
|
+
</button>
|
|
154
|
+
</PopoverTrigger>
|
|
155
|
+
<PopoverContent side="right" align="start" className="w-auto p-2">
|
|
156
|
+
<IconPicker value={newIcon} onChange={setNewIcon} />
|
|
157
|
+
</PopoverContent>
|
|
158
|
+
</Popover>
|
|
94
159
|
<input
|
|
160
|
+
ref={inputRef}
|
|
95
161
|
value={newName}
|
|
96
162
|
onChange={(e) => setNewName(e.target.value)}
|
|
97
|
-
onBlur={commitCreate}
|
|
98
163
|
onKeyDown={(e) => {
|
|
99
164
|
if (e.key === 'Enter') commitCreate();
|
|
100
|
-
if (e.key === 'Escape')
|
|
101
|
-
setCreating(false);
|
|
102
|
-
setNewName('');
|
|
103
|
-
}
|
|
165
|
+
if (e.key === 'Escape') exitCreate();
|
|
104
166
|
}}
|
|
105
|
-
placeholder=
|
|
167
|
+
placeholder={t.home.folderName}
|
|
106
168
|
maxLength={40}
|
|
107
169
|
className="min-w-0 flex-1 bg-transparent text-[12.5px] outline-none placeholder:text-muted-foreground/60"
|
|
108
170
|
/>
|
|
@@ -110,11 +172,11 @@ export function Sidebar({
|
|
|
110
172
|
) : (
|
|
111
173
|
<button
|
|
112
174
|
type="button"
|
|
113
|
-
onClick={
|
|
175
|
+
onClick={startCreating}
|
|
114
176
|
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"
|
|
115
177
|
>
|
|
116
178
|
<Plus className="size-3.5" />
|
|
117
|
-
<span>
|
|
179
|
+
<span>{t.home.newFolder}</span>
|
|
118
180
|
</button>
|
|
119
181
|
))}
|
|
120
182
|
</div>
|
|
@@ -2,6 +2,7 @@ import { Palette, X } from 'lucide-react';
|
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import { Field, NumberField, Section } from '@/components/panel/panel-fields';
|
|
4
4
|
import { PanelShell, usePanelMount } from '@/components/panel/panel-shell';
|
|
5
|
+
import { useLocale } from '@/lib/use-locale';
|
|
5
6
|
import { Button } from '../ui/button';
|
|
6
7
|
import { Input } from '../ui/input';
|
|
7
8
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
|
@@ -29,6 +30,7 @@ type DesignPanelProps = {
|
|
|
29
30
|
export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
30
31
|
const { draft, exists, warning, loaded, dirty, update } = useDesignPanelState();
|
|
31
32
|
const { mounted, animVisible } = usePanelMount(open);
|
|
33
|
+
const t = useLocale();
|
|
32
34
|
|
|
33
35
|
if (!loaded) return null;
|
|
34
36
|
if (!mounted) return null;
|
|
@@ -43,15 +45,19 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
43
45
|
<div className="flex min-w-0 items-center gap-2">
|
|
44
46
|
<Palette className="size-3.5 text-muted-foreground" />
|
|
45
47
|
<span className="font-heading text-[12px] font-semibold tracking-tight">
|
|
46
|
-
|
|
48
|
+
{t.stylePanel.designTokens}
|
|
47
49
|
</span>
|
|
48
50
|
{!exists && (
|
|
49
51
|
<span className="rounded-[3px] border border-hairline bg-muted/60 px-1.5 py-px font-mono text-[9.5px] uppercase tracking-[0.08em] text-muted-foreground">
|
|
50
|
-
|
|
52
|
+
{t.stylePanel.draftBadge}
|
|
51
53
|
</span>
|
|
52
54
|
)}
|
|
53
55
|
{dirty && (
|
|
54
|
-
<span
|
|
56
|
+
<span
|
|
57
|
+
className="size-1.5 rounded-full bg-brand"
|
|
58
|
+
title={t.stylePanel.unsavedTitle}
|
|
59
|
+
aria-hidden
|
|
60
|
+
/>
|
|
55
61
|
)}
|
|
56
62
|
</div>
|
|
57
63
|
<Button
|
|
@@ -59,7 +65,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
59
65
|
size="icon-sm"
|
|
60
66
|
className="text-muted-foreground hover:text-foreground"
|
|
61
67
|
onClick={onClose}
|
|
62
|
-
aria-label=
|
|
68
|
+
aria-label={t.stylePanel.closePanelAria}
|
|
63
69
|
>
|
|
64
70
|
<X className="size-3.5" />
|
|
65
71
|
</Button>
|
|
@@ -74,9 +80,9 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
74
80
|
)
|
|
75
81
|
}
|
|
76
82
|
>
|
|
77
|
-
<Section title=
|
|
83
|
+
<Section title={t.stylePanel.colorsSection}>
|
|
78
84
|
<ColorField
|
|
79
|
-
label=
|
|
85
|
+
label={t.stylePanel.backgroundLabel}
|
|
80
86
|
value={draft.palette.bg}
|
|
81
87
|
onChange={(v) =>
|
|
82
88
|
update((d) => {
|
|
@@ -85,7 +91,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
85
91
|
}
|
|
86
92
|
/>
|
|
87
93
|
<ColorField
|
|
88
|
-
label=
|
|
94
|
+
label={t.stylePanel.textLabel}
|
|
89
95
|
value={draft.palette.text}
|
|
90
96
|
onChange={(v) =>
|
|
91
97
|
update((d) => {
|
|
@@ -94,7 +100,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
94
100
|
}
|
|
95
101
|
/>
|
|
96
102
|
<ColorField
|
|
97
|
-
label=
|
|
103
|
+
label={t.stylePanel.accentLabel}
|
|
98
104
|
value={draft.palette.accent}
|
|
99
105
|
onChange={(v) =>
|
|
100
106
|
update((d) => {
|
|
@@ -106,9 +112,9 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
106
112
|
|
|
107
113
|
<Separator />
|
|
108
114
|
|
|
109
|
-
<Section title=
|
|
115
|
+
<Section title={t.stylePanel.typographySection}>
|
|
110
116
|
<FontField
|
|
111
|
-
label=
|
|
117
|
+
label={t.stylePanel.displayFontLabel}
|
|
112
118
|
value={draft.fonts.display}
|
|
113
119
|
onChange={(v) =>
|
|
114
120
|
update((d) => {
|
|
@@ -117,7 +123,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
117
123
|
}
|
|
118
124
|
/>
|
|
119
125
|
<FontField
|
|
120
|
-
label=
|
|
126
|
+
label={t.stylePanel.bodyFontLabel}
|
|
121
127
|
value={draft.fonts.body}
|
|
122
128
|
onChange={(v) =>
|
|
123
129
|
update((d) => {
|
|
@@ -126,7 +132,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
126
132
|
}
|
|
127
133
|
/>
|
|
128
134
|
<SliderField
|
|
129
|
-
label=
|
|
135
|
+
label={t.stylePanel.heroLabel}
|
|
130
136
|
value={draft.typeScale.hero}
|
|
131
137
|
min={48}
|
|
132
138
|
max={240}
|
|
@@ -139,7 +145,7 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
139
145
|
}
|
|
140
146
|
/>
|
|
141
147
|
<SliderField
|
|
142
|
-
label=
|
|
148
|
+
label={t.stylePanel.bodyLabel}
|
|
143
149
|
value={draft.typeScale.body}
|
|
144
150
|
min={16}
|
|
145
151
|
max={72}
|
|
@@ -155,9 +161,9 @@ export function DesignPanel({ open, onClose }: DesignPanelProps) {
|
|
|
155
161
|
|
|
156
162
|
<Separator />
|
|
157
163
|
|
|
158
|
-
<Section title=
|
|
164
|
+
<Section title={t.stylePanel.shapeSection}>
|
|
159
165
|
<SliderField
|
|
160
|
-
label=
|
|
166
|
+
label={t.stylePanel.radiusLabel}
|
|
161
167
|
value={draft.radius}
|
|
162
168
|
min={0}
|
|
163
169
|
max={80}
|
|
@@ -181,6 +187,7 @@ export function DesignToggleButton({
|
|
|
181
187
|
active: boolean;
|
|
182
188
|
onToggle: () => void;
|
|
183
189
|
}) {
|
|
190
|
+
const t = useLocale();
|
|
184
191
|
if (import.meta.env.PROD) return null;
|
|
185
192
|
return (
|
|
186
193
|
<Button
|
|
@@ -188,10 +195,10 @@ export function DesignToggleButton({
|
|
|
188
195
|
variant={active ? 'default' : 'ghost'}
|
|
189
196
|
onClick={onToggle}
|
|
190
197
|
data-design-ui
|
|
191
|
-
title=
|
|
198
|
+
title={t.stylePanel.designToggleTitle}
|
|
192
199
|
>
|
|
193
200
|
<Palette className="size-3.5" />
|
|
194
|
-
<span className="hidden md:inline">
|
|
201
|
+
<span className="hidden md:inline">{t.stylePanel.designToggle}</span>
|
|
195
202
|
</Button>
|
|
196
203
|
);
|
|
197
204
|
}
|
|
@@ -247,6 +254,7 @@ function FontField({
|
|
|
247
254
|
onChange: (v: string) => void;
|
|
248
255
|
}) {
|
|
249
256
|
const matched = FONT_PRESETS.find((p) => p.value === value);
|
|
257
|
+
const tFont = useLocale();
|
|
250
258
|
return (
|
|
251
259
|
<Field label={label}>
|
|
252
260
|
<Select
|
|
@@ -266,7 +274,7 @@ function FontField({
|
|
|
266
274
|
))}
|
|
267
275
|
{!matched && (
|
|
268
276
|
<SelectItem value="__custom__" className="text-xs">
|
|
269
|
-
|
|
277
|
+
{tFont.stylePanel.fontPresetCustom}
|
|
270
278
|
</SelectItem>
|
|
271
279
|
)}
|
|
272
280
|
</SelectContent>
|
|
@@ -8,11 +8,13 @@ import {
|
|
|
8
8
|
DropdownMenuItem,
|
|
9
9
|
DropdownMenuTrigger,
|
|
10
10
|
} from '@/components/ui/dropdown-menu';
|
|
11
|
+
import { useLocale } from '@/lib/use-locale';
|
|
11
12
|
import { cn } from '@/lib/utils';
|
|
12
13
|
|
|
13
14
|
export function ThemeToggle() {
|
|
14
15
|
const { theme, setTheme } = useTheme();
|
|
15
16
|
const [mounted, setMounted] = useState(false);
|
|
17
|
+
const t = useLocale();
|
|
16
18
|
|
|
17
19
|
useEffect(() => {
|
|
18
20
|
setMounted(true);
|
|
@@ -22,8 +24,8 @@ export function ThemeToggle() {
|
|
|
22
24
|
<DropdownMenu>
|
|
23
25
|
<DropdownMenuTrigger
|
|
24
26
|
type="button"
|
|
25
|
-
aria-label=
|
|
26
|
-
title=
|
|
27
|
+
aria-label={t.themeToggle.toggleAria}
|
|
28
|
+
title={t.themeToggle.title}
|
|
27
29
|
className={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }), 'relative')}
|
|
28
30
|
>
|
|
29
31
|
<Sun className="size-3.5 scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
|
@@ -35,21 +37,21 @@ export function ThemeToggle() {
|
|
|
35
37
|
data-active={mounted && theme === 'light'}
|
|
36
38
|
>
|
|
37
39
|
<Sun />
|
|
38
|
-
|
|
40
|
+
{t.themeToggle.light}
|
|
39
41
|
</DropdownMenuItem>
|
|
40
42
|
<DropdownMenuItem
|
|
41
43
|
onSelect={() => setTheme('dark')}
|
|
42
44
|
data-active={mounted && theme === 'dark'}
|
|
43
45
|
>
|
|
44
46
|
<Moon />
|
|
45
|
-
|
|
47
|
+
{t.themeToggle.dark}
|
|
46
48
|
</DropdownMenuItem>
|
|
47
49
|
<DropdownMenuItem
|
|
48
50
|
onSelect={() => setTheme('system')}
|
|
49
51
|
data-active={mounted && theme === 'system'}
|
|
50
52
|
>
|
|
51
53
|
<Monitor />
|
|
52
|
-
|
|
54
|
+
{t.themeToggle.system}
|
|
53
55
|
</DropdownMenuItem>
|
|
54
56
|
</DropdownMenuContent>
|
|
55
57
|
</DropdownMenu>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'react';
|
|
2
2
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
3
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
3
4
|
import { cn } from '@/lib/utils';
|
|
4
5
|
import type { DesignSystem } from '../lib/design';
|
|
5
6
|
import type { Page } from '../lib/sdk';
|
|
@@ -27,6 +28,7 @@ export function ThumbnailRail({
|
|
|
27
28
|
orientation = 'vertical',
|
|
28
29
|
}: Props) {
|
|
29
30
|
const activeRef = useRef<HTMLButtonElement | null>(null);
|
|
31
|
+
const t = useLocale();
|
|
30
32
|
|
|
31
33
|
// biome-ignore lint/correctness/useExhaustiveDependencies: `current` triggers re-scroll on selection change
|
|
32
34
|
useEffect(() => {
|
|
@@ -55,7 +57,7 @@ export function ThumbnailRail({
|
|
|
55
57
|
type="button"
|
|
56
58
|
ref={active ? activeRef : undefined}
|
|
57
59
|
onClick={() => onSelect(i)}
|
|
58
|
-
aria-label={
|
|
60
|
+
aria-label={format(t.thumbnailRail.goToPageAria, { n: i + 1 })}
|
|
59
61
|
aria-current={active ? 'true' : undefined}
|
|
60
62
|
className={cn('group/thumb relative flex shrink-0 flex-col items-center gap-1.5')}
|
|
61
63
|
>
|
|
@@ -95,7 +97,7 @@ export function ThumbnailRail({
|
|
|
95
97
|
<ScrollArea className="h-full border-r border-hairline bg-sidebar">
|
|
96
98
|
<aside className="flex flex-col gap-2 px-3 py-3">
|
|
97
99
|
<div className="flex items-baseline justify-between px-1 pb-1">
|
|
98
|
-
<span className="eyebrow">
|
|
100
|
+
<span className="eyebrow">{t.thumbnailRail.pages}</span>
|
|
99
101
|
<span className="folio">{pages.length.toString().padStart(2, '0')}</span>
|
|
100
102
|
</div>
|
|
101
103
|
{pages.map((PageComp, i) => {
|
package/src/app/favicon.ico
CHANGED
|
Binary file
|
|
@@ -2,12 +2,14 @@ import { useCallback } from 'react';
|
|
|
2
2
|
|
|
3
3
|
export type EditOp =
|
|
4
4
|
| { kind: 'set-style'; key: string; value: string | null }
|
|
5
|
-
| { kind: 'set-text'; value: string }
|
|
5
|
+
| { kind: 'set-text'; value: string; prevText?: string }
|
|
6
6
|
| { kind: 'set-attr-asset'; attr: string; assetPath: string; previewUrl: string }
|
|
7
7
|
| { kind: 'replace-placeholder-with-image'; assetPath: string };
|
|
8
8
|
|
|
9
9
|
export type Edit = { line: number; column: number; ops: EditOp[] };
|
|
10
10
|
|
|
11
|
+
export type EditResult = { ok: boolean; error?: string };
|
|
12
|
+
|
|
11
13
|
export class NoOpEditError extends Error {
|
|
12
14
|
constructor() {
|
|
13
15
|
super(
|
|
@@ -37,9 +39,11 @@ export function useEditor(slideId: string) {
|
|
|
37
39
|
);
|
|
38
40
|
|
|
39
41
|
// Batch many element edits into one file write and one HMR tick.
|
|
42
|
+
// Returns one result per input edit so callers can keep failed
|
|
43
|
+
// edits buffered while clearing the ones that landed.
|
|
40
44
|
const applyEdits = useCallback(
|
|
41
|
-
async (edits: Edit[]) => {
|
|
42
|
-
if (edits.length === 0) return;
|
|
45
|
+
async (edits: Edit[]): Promise<EditResult[]> => {
|
|
46
|
+
if (edits.length === 0) return [];
|
|
43
47
|
const res = await fetch('/__edit/batch', {
|
|
44
48
|
method: 'POST',
|
|
45
49
|
headers: { 'content-type': 'application/json' },
|
|
@@ -47,14 +51,12 @@ export function useEditor(slideId: string) {
|
|
|
47
51
|
});
|
|
48
52
|
const body = (await res.json().catch(() => ({}))) as {
|
|
49
53
|
error?: string;
|
|
50
|
-
|
|
51
|
-
results?: Array<{ ok: boolean; error?: string }>;
|
|
54
|
+
results?: EditResult[];
|
|
52
55
|
};
|
|
53
56
|
if (!res.ok) {
|
|
54
57
|
throw new Error(body.error ?? `POST /__edit/batch → ${res.status}`);
|
|
55
58
|
}
|
|
56
|
-
|
|
57
|
-
if (failed?.error) throw new Error(failed.error);
|
|
59
|
+
return body.results ?? [];
|
|
58
60
|
},
|
|
59
61
|
[slideId],
|
|
60
62
|
);
|