@open-slide/core 1.3.0 → 1.5.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/dist/{build-_276DMmJ.js → build-DZhbjQpQ.js} +1 -1
- package/dist/cli/bin.js +3 -3
- package/dist/{config-D9cZ1A0X.d.ts → config-BQdTMho4.d.ts} +2 -1
- package/dist/{config-BAwKWNtW.js → config-iKjqaX08.js} +2528 -1640
- package/dist/{dev-BoqeVXVq.js → dev-BjLGk5nN.js} +1 -1
- package/dist/{en-CDKzoZvf.js → en-DDGqyNaW.js} +27 -4
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -1
- package/dist/locale/index.d.ts +1 -1
- package/dist/locale/index.js +82 -13
- package/dist/{preview-BLPxspc9.js → preview-jwLWHWkQ.js} +1 -1
- package/dist/{types-JYG1cmwC.d.ts → types-Dpr8nbih.d.ts} +27 -1
- package/dist/vite/index.d.ts +2 -2
- package/dist/vite/index.js +1 -1
- package/package.json +1 -1
- package/skills/slide-authoring/SKILL.md +19 -4
- package/src/app/app.tsx +2 -0
- package/src/app/components/asset-view.tsx +111 -18
- package/src/app/components/inspector/inspect-overlay.tsx +49 -3
- package/src/app/components/inspector/inspector-panel.tsx +267 -25
- package/src/app/components/inspector/inspector-provider.tsx +390 -49
- package/src/app/components/panel/panel-shell.tsx +5 -3
- package/src/app/components/player.tsx +25 -5
- package/src/app/components/present/control-bar.tsx +12 -0
- package/src/app/components/present/laser-pointer.tsx +3 -4
- package/src/app/components/present/progress-bar.tsx +4 -4
- package/src/app/components/sidebar/folder-item.tsx +14 -3
- package/src/app/components/sidebar/sidebar.tsx +10 -0
- package/src/app/lib/assets.ts +21 -0
- package/src/app/lib/export-pdf.ts +6 -0
- package/src/app/lib/inspector/use-editor.ts +9 -1
- package/src/app/lib/sdk.ts +2 -0
- package/src/app/lib/slides.ts +9 -0
- package/src/app/lib/use-slide-module.ts +48 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +23 -2
- package/src/app/routes/home.tsx +101 -3
- package/src/app/routes/presenter.tsx +2 -20
- package/src/app/routes/slide.tsx +117 -39
- package/src/app/virtual.d.ts +1 -0
- package/src/locale/en.ts +28 -5
- package/src/locale/ja.ts +28 -5
- package/src/locale/types.ts +27 -1
- package/src/locale/zh-cn.ts +28 -6
- package/src/locale/zh-tw.ts +28 -6
|
@@ -32,6 +32,12 @@ type Props = {
|
|
|
32
32
|
allowExit?: boolean;
|
|
33
33
|
controls?: boolean;
|
|
34
34
|
slideId?: string;
|
|
35
|
+
/**
|
|
36
|
+
* When true, the Player enters the browser Fullscreen API on mount.
|
|
37
|
+
* When false, it renders as a window-sized overlay (viewport-filling)
|
|
38
|
+
* without entering fullscreen. Defaults to true for back-compat.
|
|
39
|
+
*/
|
|
40
|
+
fullscreen?: boolean;
|
|
35
41
|
};
|
|
36
42
|
|
|
37
43
|
export function Player({
|
|
@@ -43,8 +49,9 @@ export function Player({
|
|
|
43
49
|
allowExit = true,
|
|
44
50
|
controls = false,
|
|
45
51
|
slideId,
|
|
52
|
+
fullscreen = true,
|
|
46
53
|
}: Props) {
|
|
47
|
-
const rootRef = useRef<HTMLDivElement>(null);
|
|
54
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
48
55
|
// Mirrored as state so descendants portaling *into* the player subtree
|
|
49
56
|
// (tooltips, popovers — the body is outside the fullscreen tree) re-render
|
|
50
57
|
// once the node mounts.
|
|
@@ -60,6 +67,12 @@ export function Player({
|
|
|
60
67
|
const [laser, setLaser] = useState(false);
|
|
61
68
|
const [keyboardDriven, setKeyboardDriven] = useState(false);
|
|
62
69
|
const [startedAt] = useState(() => Date.now());
|
|
70
|
+
const [windowed, setWindowed] = useState(!fullscreen);
|
|
71
|
+
// Mirror windowed into a ref so the fullscreenchange listener can read the
|
|
72
|
+
// latest value without re-binding — exits from window mode must not call
|
|
73
|
+
// onExit, but exits initiated by the browser (Esc in fullscreen) must.
|
|
74
|
+
const windowedRef = useRef(windowed);
|
|
75
|
+
windowedRef.current = windowed;
|
|
63
76
|
|
|
64
77
|
const canPrev = index > 0;
|
|
65
78
|
const canNext = index < pages.length - 1;
|
|
@@ -90,6 +103,7 @@ export function Player({
|
|
|
90
103
|
});
|
|
91
104
|
|
|
92
105
|
useEffect(() => {
|
|
106
|
+
if (windowed) return;
|
|
93
107
|
const el = rootRef.current;
|
|
94
108
|
if (!el) return;
|
|
95
109
|
if (document.fullscreenElement !== el) {
|
|
@@ -98,17 +112,21 @@ export function Player({
|
|
|
98
112
|
return () => {
|
|
99
113
|
if (document.fullscreenElement) document.exitFullscreen?.().catch(() => {});
|
|
100
114
|
};
|
|
101
|
-
}, []);
|
|
115
|
+
}, [windowed]);
|
|
102
116
|
|
|
103
117
|
useEffect(() => {
|
|
104
118
|
if (!allowExit) return;
|
|
105
119
|
const onFsChange = () => {
|
|
106
|
-
if (!document.fullscreenElement) onExit();
|
|
120
|
+
if (!document.fullscreenElement && !windowedRef.current) onExit();
|
|
107
121
|
};
|
|
108
122
|
document.addEventListener('fullscreenchange', onFsChange);
|
|
109
123
|
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
|
110
124
|
}, [onExit, allowExit]);
|
|
111
125
|
|
|
126
|
+
const toggleFullscreen = useCallback(() => {
|
|
127
|
+
setWindowed((w) => !w);
|
|
128
|
+
}, []);
|
|
129
|
+
|
|
112
130
|
// Player is the source of truth: it re-publishes state on every change
|
|
113
131
|
// and answers `request-state` pings so newly opened presenter windows
|
|
114
132
|
// hydrate immediately.
|
|
@@ -271,7 +289,7 @@ export function Player({
|
|
|
271
289
|
<div
|
|
272
290
|
ref={setRoot}
|
|
273
291
|
className={cn(
|
|
274
|
-
'
|
|
292
|
+
'fixed inset-0 flex items-center justify-center overflow-hidden bg-black',
|
|
275
293
|
controls && 'select-none',
|
|
276
294
|
controls && (hideCursor ? 'cursor-none' : 'cursor-default'),
|
|
277
295
|
)}
|
|
@@ -310,12 +328,14 @@ export function Player({
|
|
|
310
328
|
blackout={blackout}
|
|
311
329
|
laser={laser}
|
|
312
330
|
allowExit={allowExit}
|
|
331
|
+
windowed={windowed}
|
|
313
332
|
onPrev={goPrev}
|
|
314
333
|
onNext={goNext}
|
|
315
334
|
onOverview={() => setOverviewOpen(true)}
|
|
316
335
|
onBlackout={(mode) => setBlackout((c) => (c === mode ? null : mode))}
|
|
317
336
|
onLaser={() => setLaser((v) => !v)}
|
|
318
337
|
onPresenter={() => slideId && openPresenterWindow(slideId)}
|
|
338
|
+
onToggleFullscreen={toggleFullscreen}
|
|
319
339
|
onHelp={() => setHelpOpen(true)}
|
|
320
340
|
onExit={onExit}
|
|
321
341
|
/>
|
|
@@ -334,7 +354,7 @@ export function Player({
|
|
|
334
354
|
);
|
|
335
355
|
}
|
|
336
356
|
|
|
337
|
-
function openPresenterWindow(slideId: string) {
|
|
357
|
+
export function openPresenterWindow(slideId: string) {
|
|
338
358
|
if (typeof window === 'undefined') return;
|
|
339
359
|
const url = `/s/${encodeURIComponent(slideId)}/presenter`;
|
|
340
360
|
window.open(url, `open-slide-presenter-${slideId}`, 'popup,width=1280,height=800');
|
|
@@ -5,6 +5,8 @@ import {
|
|
|
5
5
|
Grid2x2,
|
|
6
6
|
Keyboard,
|
|
7
7
|
LogOut,
|
|
8
|
+
Maximize,
|
|
9
|
+
Minimize,
|
|
8
10
|
MonitorSpeaker,
|
|
9
11
|
Square,
|
|
10
12
|
Sun,
|
|
@@ -24,12 +26,14 @@ type Props = {
|
|
|
24
26
|
blackout: 'black' | 'white' | null;
|
|
25
27
|
laser: boolean;
|
|
26
28
|
allowExit: boolean;
|
|
29
|
+
windowed: boolean;
|
|
27
30
|
onPrev: () => void;
|
|
28
31
|
onNext: () => void;
|
|
29
32
|
onOverview: () => void;
|
|
30
33
|
onBlackout: (mode: 'black' | 'white') => void;
|
|
31
34
|
onLaser: () => void;
|
|
32
35
|
onPresenter: () => void;
|
|
36
|
+
onToggleFullscreen: () => void;
|
|
33
37
|
onHelp: () => void;
|
|
34
38
|
onExit: () => void;
|
|
35
39
|
/**
|
|
@@ -48,12 +52,14 @@ export function PresentControlBar({
|
|
|
48
52
|
blackout,
|
|
49
53
|
laser,
|
|
50
54
|
allowExit,
|
|
55
|
+
windowed,
|
|
51
56
|
onPrev,
|
|
52
57
|
onNext,
|
|
53
58
|
onOverview,
|
|
54
59
|
onBlackout,
|
|
55
60
|
onLaser,
|
|
56
61
|
onPresenter,
|
|
62
|
+
onToggleFullscreen,
|
|
57
63
|
onHelp,
|
|
58
64
|
onExit,
|
|
59
65
|
tooltipContainer,
|
|
@@ -123,6 +129,12 @@ export function PresentControlBar({
|
|
|
123
129
|
<BarButton label={t.present.presenterAria} onClick={onPresenter}>
|
|
124
130
|
<MonitorSpeaker className="size-4" />
|
|
125
131
|
</BarButton>
|
|
132
|
+
<BarButton
|
|
133
|
+
label={windowed ? t.present.enterFullscreenAria : t.present.exitFullscreenAria}
|
|
134
|
+
onClick={onToggleFullscreen}
|
|
135
|
+
>
|
|
136
|
+
{windowed ? <Maximize className="size-4" /> : <Minimize className="size-4" />}
|
|
137
|
+
</BarButton>
|
|
126
138
|
<BarButton label={t.present.helpAria} onClick={onHelp}>
|
|
127
139
|
<Keyboard className="size-4" />
|
|
128
140
|
</BarButton>
|
|
@@ -24,13 +24,12 @@ export function PresentLaserPointer({ enabled }: { enabled: boolean }) {
|
|
|
24
24
|
return (
|
|
25
25
|
<div
|
|
26
26
|
aria-hidden
|
|
27
|
-
className="pointer-events-none fixed z-[60]"
|
|
27
|
+
className="pointer-events-none fixed top-0 left-0 z-[60]"
|
|
28
28
|
style={{
|
|
29
|
-
left: pos.x,
|
|
30
|
-
top: pos.y,
|
|
31
29
|
width: 18,
|
|
32
30
|
height: 18,
|
|
33
|
-
transform:
|
|
31
|
+
transform: `translate3d(${pos.x - 9}px, ${pos.y - 9}px, 0)`,
|
|
32
|
+
willChange: 'transform',
|
|
34
33
|
borderRadius: '50%',
|
|
35
34
|
background: 'radial-gradient(circle, oklch(0.66 0.24 28 / 0.95) 30%, transparent 70%)',
|
|
36
35
|
boxShadow: '0 0 18px 4px oklch(0.66 0.24 28 / 0.55)',
|
|
@@ -7,19 +7,19 @@ type Props = {
|
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
export function PresentProgressBar({ index, total, visible }: Props) {
|
|
10
|
-
const pct = total > 0 ? (
|
|
10
|
+
const pct = total > 0 ? (index + 1) / total : 0;
|
|
11
11
|
return (
|
|
12
12
|
<div
|
|
13
13
|
aria-hidden
|
|
14
14
|
className={cn(
|
|
15
|
-
'pointer-events-none absolute inset-x-0 top-0 z-30 h-[2px] bg-white/8',
|
|
15
|
+
'pointer-events-none absolute inset-x-0 top-0 z-30 h-[2px] overflow-hidden bg-white/8',
|
|
16
16
|
'motion-safe:transition-opacity motion-safe:duration-200',
|
|
17
17
|
visible ? 'opacity-100' : 'opacity-0',
|
|
18
18
|
)}
|
|
19
19
|
>
|
|
20
20
|
<div
|
|
21
|
-
className="h-full bg-[var(--brand,#ef4444)] transition-
|
|
22
|
-
style={{
|
|
21
|
+
className="h-full w-full origin-left bg-[var(--brand,#ef4444)] transition-transform duration-200 ease-out"
|
|
22
|
+
style={{ transform: `scaleX(${pct})` }}
|
|
23
23
|
/>
|
|
24
24
|
</div>
|
|
25
25
|
);
|
|
@@ -70,6 +70,9 @@ type Row =
|
|
|
70
70
|
}
|
|
71
71
|
| {
|
|
72
72
|
kind: 'themes';
|
|
73
|
+
}
|
|
74
|
+
| {
|
|
75
|
+
kind: 'assets';
|
|
73
76
|
};
|
|
74
77
|
|
|
75
78
|
export function FolderItem({
|
|
@@ -92,7 +95,7 @@ export function FolderItem({
|
|
|
92
95
|
const slideDragActive = useSlideDragActive();
|
|
93
96
|
const t = useLocale();
|
|
94
97
|
|
|
95
|
-
const acceptsSlideDrop = row.kind !== 'themes';
|
|
98
|
+
const acceptsSlideDrop = row.kind !== 'themes' && row.kind !== 'assets';
|
|
96
99
|
const isSlideDrag = (e: React.DragEvent) =>
|
|
97
100
|
acceptsSlideDrop && e.dataTransfer.types.includes(SLIDE_DND_MIME);
|
|
98
101
|
const handleDragEnter = (e: React.DragEvent) => {
|
|
@@ -125,9 +128,17 @@ export function FolderItem({
|
|
|
125
128
|
? { type: 'emoji', value: '📝' }
|
|
126
129
|
: row.kind === 'themes'
|
|
127
130
|
? { type: 'emoji', value: '🎨' }
|
|
128
|
-
: row.
|
|
131
|
+
: row.kind === 'assets'
|
|
132
|
+
? { type: 'emoji', value: '🗂️' }
|
|
133
|
+
: row.folder.icon;
|
|
129
134
|
const label =
|
|
130
|
-
row.kind === 'draft'
|
|
135
|
+
row.kind === 'draft'
|
|
136
|
+
? t.home.draft
|
|
137
|
+
: row.kind === 'themes'
|
|
138
|
+
? t.home.themes
|
|
139
|
+
: row.kind === 'assets'
|
|
140
|
+
? t.home.assets
|
|
141
|
+
: row.folder.name;
|
|
131
142
|
|
|
132
143
|
const commitRename = () => {
|
|
133
144
|
if (row.kind !== 'folder') return;
|
|
@@ -10,11 +10,13 @@ import { IconPicker, PRESET_COLORS } from './icon-picker';
|
|
|
10
10
|
|
|
11
11
|
export const DRAFT_ID = 'draft';
|
|
12
12
|
export const THEMES_ID = '__themes__';
|
|
13
|
+
export const ASSETS_ID = '__assets__';
|
|
13
14
|
|
|
14
15
|
export function Sidebar({
|
|
15
16
|
folders,
|
|
16
17
|
countFor,
|
|
17
18
|
themesCount,
|
|
19
|
+
assetsCount,
|
|
18
20
|
selectedId,
|
|
19
21
|
onSelect,
|
|
20
22
|
onCreate,
|
|
@@ -27,6 +29,7 @@ export function Sidebar({
|
|
|
27
29
|
folders: Folder[];
|
|
28
30
|
countFor: (folderId: string | null) => number;
|
|
29
31
|
themesCount: number;
|
|
32
|
+
assetsCount: number;
|
|
30
33
|
selectedId: string;
|
|
31
34
|
onSelect: (id: string) => void;
|
|
32
35
|
onCreate: (name: string, icon: FolderIcon) => Promise<Folder> | undefined;
|
|
@@ -121,6 +124,13 @@ export function Sidebar({
|
|
|
121
124
|
onSelect={() => onSelect(THEMES_ID)}
|
|
122
125
|
onDropSlide={() => {}}
|
|
123
126
|
/>
|
|
127
|
+
<FolderItem
|
|
128
|
+
row={{ kind: 'assets' }}
|
|
129
|
+
count={assetsCount}
|
|
130
|
+
selected={selectedId === ASSETS_ID}
|
|
131
|
+
onSelect={() => onSelect(ASSETS_ID)}
|
|
132
|
+
onDropSlide={() => {}}
|
|
133
|
+
/>
|
|
124
134
|
</div>
|
|
125
135
|
|
|
126
136
|
<div className="mt-5 flex items-center gap-2 px-4 pb-1.5">
|
package/src/app/lib/assets.ts
CHANGED
|
@@ -45,6 +45,27 @@ async function deleteAsset(slideId: string, name: string): Promise<Response> {
|
|
|
45
45
|
return fetch(`/__assets/${slideId}/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export type AssetUsage = { slideId: string; count: number };
|
|
49
|
+
|
|
50
|
+
export async function listAssetUsages(slideId: string, name: string): Promise<AssetUsage[]> {
|
|
51
|
+
const res = await fetch(`/__assets/${slideId}/${encodeURIComponent(name)}/usages`);
|
|
52
|
+
if (!res.ok) return [];
|
|
53
|
+
const data = (await res.json().catch(() => null)) as { usages?: AssetUsage[] } | null;
|
|
54
|
+
return data?.usages ?? [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function revertAssetUsage(
|
|
58
|
+
slideId: string,
|
|
59
|
+
assetPath: string,
|
|
60
|
+
): Promise<{ ok: boolean; status: number }> {
|
|
61
|
+
const res = await fetch('/__edit/revert-asset', {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'content-type': 'application/json' },
|
|
64
|
+
body: JSON.stringify({ slideId, assetPath }),
|
|
65
|
+
});
|
|
66
|
+
return { ok: res.ok, status: res.status };
|
|
67
|
+
}
|
|
68
|
+
|
|
48
69
|
export async function uploadWithAutoRename(
|
|
49
70
|
slideId: string,
|
|
50
71
|
file: File,
|
|
@@ -65,6 +65,12 @@ const PRINT_STYLES = `
|
|
|
65
65
|
}
|
|
66
66
|
`;
|
|
67
67
|
|
|
68
|
+
export function isSafari(): boolean {
|
|
69
|
+
if (typeof navigator === 'undefined') return false;
|
|
70
|
+
const ua = navigator.userAgent;
|
|
71
|
+
return /Safari/.test(ua) && !/Chrome|Chromium|Edg|OPR|Firefox/.test(ua);
|
|
72
|
+
}
|
|
73
|
+
|
|
68
74
|
export type PdfExportProgress = {
|
|
69
75
|
phase: 'processing' | 'printing' | 'done';
|
|
70
76
|
/** Number of pages whose intro animations have finished (0..total). */
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { useCallback } from 'react';
|
|
2
2
|
|
|
3
3
|
export type EditOp =
|
|
4
|
-
| { kind: 'set-style'; key: string; value: string | null }
|
|
4
|
+
| { kind: 'set-style'; key: string; value: string | null; prevText?: string }
|
|
5
5
|
| { kind: 'set-text'; value: string; prevText?: string }
|
|
6
|
+
| {
|
|
7
|
+
kind: 'set-text-range-style';
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
key: string;
|
|
11
|
+
value: string | null;
|
|
12
|
+
prevText?: string;
|
|
13
|
+
}
|
|
6
14
|
| { kind: 'set-attr-asset'; attr: string; assetPath: string; previewUrl: string }
|
|
7
15
|
| { kind: 'replace-placeholder-with-image'; assetPath: string };
|
|
8
16
|
|
package/src/app/lib/sdk.ts
CHANGED
package/src/app/lib/slides.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
slideCreatedAt as createdAt,
|
|
2
3
|
slideIds as ids,
|
|
3
4
|
loadSlide as load,
|
|
4
5
|
slideThemes as themes,
|
|
@@ -7,6 +8,7 @@ import type { SlideModule } from './sdk';
|
|
|
7
8
|
|
|
8
9
|
export const slideIds: string[] = ids;
|
|
9
10
|
export const slideThemes: Record<string, string> = themes;
|
|
11
|
+
export const slideCreatedAt: Record<string, number> = createdAt;
|
|
10
12
|
|
|
11
13
|
export function slidesByTheme(themeId: string): string[] {
|
|
12
14
|
return slideIds.filter((id) => slideThemes[id] === themeId);
|
|
@@ -15,3 +17,10 @@ export function slidesByTheme(themeId: string): string[] {
|
|
|
15
17
|
export async function loadSlide(id: string): Promise<SlideModule> {
|
|
16
18
|
return load(id);
|
|
17
19
|
}
|
|
20
|
+
|
|
21
|
+
export function slideChangeIncludes(data: unknown, slideId: string): boolean {
|
|
22
|
+
if (!data || typeof data !== 'object') return false;
|
|
23
|
+
const payload = data as { slideId?: unknown; slideIds?: unknown };
|
|
24
|
+
if (payload.slideId === slideId) return true;
|
|
25
|
+
return Array.isArray(payload.slideIds) && payload.slideIds.includes(slideId);
|
|
26
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import type { SlideModule } from './sdk';
|
|
3
|
+
import { loadSlide, slideChangeIncludes } from './slides';
|
|
4
|
+
|
|
5
|
+
export function useSlideModule(slideId: string) {
|
|
6
|
+
const [slide, setSlide] = useState<SlideModule | null>(null);
|
|
7
|
+
const [error, setError] = useState<string | null>(null);
|
|
8
|
+
const loadSeqRef = useRef(0);
|
|
9
|
+
|
|
10
|
+
const reload = useCallback(
|
|
11
|
+
(reset: boolean) => {
|
|
12
|
+
const seq = ++loadSeqRef.current;
|
|
13
|
+
if (reset) setSlide(null);
|
|
14
|
+
setError(null);
|
|
15
|
+
loadSlide(slideId)
|
|
16
|
+
.then((mod) => {
|
|
17
|
+
if (seq === loadSeqRef.current) setSlide(mod);
|
|
18
|
+
})
|
|
19
|
+
.catch((e) => {
|
|
20
|
+
if (seq === loadSeqRef.current) setError(String(e?.message ?? e));
|
|
21
|
+
});
|
|
22
|
+
},
|
|
23
|
+
[slideId],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
reload(true);
|
|
28
|
+
}, [reload]);
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!import.meta.hot) return;
|
|
32
|
+
let cancelled = false;
|
|
33
|
+
const handler = (data: unknown) => {
|
|
34
|
+
if (slideChangeIncludes(data, slideId)) {
|
|
35
|
+
queueMicrotask(() => {
|
|
36
|
+
if (!cancelled) reload(false);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
import.meta.hot.on('open-slide:slide-changed', handler);
|
|
41
|
+
return () => {
|
|
42
|
+
cancelled = true;
|
|
43
|
+
import.meta.hot?.off('open-slide:slide-changed', handler);
|
|
44
|
+
};
|
|
45
|
+
}, [slideId, reload]);
|
|
46
|
+
|
|
47
|
+
return { slide, error, reload };
|
|
48
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { useCallback, useMemo, useState } from 'react';
|
|
2
2
|
import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
|
3
3
|
import { toast } from 'sonner';
|
|
4
|
+
import { useAssets } from '@/lib/assets';
|
|
4
5
|
import { useFolders } from '@/lib/folders';
|
|
5
6
|
import { format, useLocale } from '@/lib/use-locale';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
6
8
|
import { MobileFolderPill } from '../components/sidebar/mobile-pill';
|
|
7
|
-
import { DRAFT_ID, Sidebar, THEMES_ID } from '../components/sidebar/sidebar';
|
|
9
|
+
import { ASSETS_ID, DRAFT_ID, Sidebar, THEMES_ID } from '../components/sidebar/sidebar';
|
|
8
10
|
import type { FoldersManifest } from '../lib/sdk';
|
|
9
11
|
import { slideIds } from '../lib/slides';
|
|
10
12
|
import { themes as themeRegistry } from '../lib/themes';
|
|
@@ -25,6 +27,7 @@ export type HomeOutletContext = {
|
|
|
25
27
|
|
|
26
28
|
function pathToSelectedId(pathname: string, search: URLSearchParams): string {
|
|
27
29
|
if (pathname === '/themes' || pathname.startsWith('/themes/')) return THEMES_ID;
|
|
30
|
+
if (pathname === '/assets') return ASSETS_ID;
|
|
28
31
|
return search.get('f') ?? DRAFT_ID;
|
|
29
32
|
}
|
|
30
33
|
|
|
@@ -48,12 +51,16 @@ export function HomeShell() {
|
|
|
48
51
|
const selectFolder = useCallback(
|
|
49
52
|
(id: string) => {
|
|
50
53
|
if (id === THEMES_ID) navigate('/themes', { replace: true });
|
|
54
|
+
else if (id === ASSETS_ID) navigate('/assets', { replace: true });
|
|
51
55
|
else if (id === DRAFT_ID) navigate('/', { replace: true });
|
|
52
56
|
else navigate(`/?f=${encodeURIComponent(id)}`, { replace: true });
|
|
53
57
|
},
|
|
54
58
|
[navigate],
|
|
55
59
|
);
|
|
56
60
|
|
|
61
|
+
const { assets: globalAssets } = useAssets('@global');
|
|
62
|
+
const isAssetsRoute = location.pathname === '/assets';
|
|
63
|
+
|
|
57
64
|
const { draftSlides, slidesByFolder } = useMemo(() => {
|
|
58
65
|
const byFolder: Record<string, string[]> = {};
|
|
59
66
|
const draft: string[] = [];
|
|
@@ -111,6 +118,7 @@ export function HomeShell() {
|
|
|
111
118
|
folders={manifest.folders}
|
|
112
119
|
countFor={countFor}
|
|
113
120
|
themesCount={themeRegistry.length}
|
|
121
|
+
assetsCount={globalAssets.length}
|
|
114
122
|
selectedId={selectedId}
|
|
115
123
|
onSelect={selectFolder}
|
|
116
124
|
onCreate={(name, icon) => create(name, icon)}
|
|
@@ -151,6 +159,13 @@ export function HomeShell() {
|
|
|
151
159
|
active={selectedId === THEMES_ID}
|
|
152
160
|
onClick={() => selectFolder(THEMES_ID)}
|
|
153
161
|
/>
|
|
162
|
+
<MobileFolderPill
|
|
163
|
+
icon={{ type: 'emoji', value: '🗂️' }}
|
|
164
|
+
label={t.home.assets}
|
|
165
|
+
count={globalAssets.length}
|
|
166
|
+
active={selectedId === ASSETS_ID}
|
|
167
|
+
onClick={() => selectFolder(ASSETS_ID)}
|
|
168
|
+
/>
|
|
154
169
|
{manifest.folders.map((f) => (
|
|
155
170
|
<MobileFolderPill
|
|
156
171
|
key={f.id}
|
|
@@ -164,7 +179,13 @@ export function HomeShell() {
|
|
|
164
179
|
</div>
|
|
165
180
|
</div>
|
|
166
181
|
|
|
167
|
-
<div
|
|
182
|
+
<div
|
|
183
|
+
className={cn(
|
|
184
|
+
isAssetsRoute
|
|
185
|
+
? 'flex min-h-0 flex-1 flex-col'
|
|
186
|
+
: 'mx-auto w-full max-w-[1180px] px-5 py-8 md:px-10 md:py-12',
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
168
189
|
<Outlet context={ctx} />
|
|
169
190
|
</div>
|
|
170
191
|
</div>
|
package/src/app/routes/home.tsx
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ArrowDownAZ,
|
|
3
|
+
ChevronDown,
|
|
4
|
+
Clock,
|
|
2
5
|
FolderInput,
|
|
3
6
|
FolderPlus,
|
|
4
7
|
MoreHorizontal,
|
|
@@ -31,9 +34,38 @@ import { FolderIconChip, SLIDE_DND_MIME } from '../components/sidebar/folder-ite
|
|
|
31
34
|
import { DRAFT_ID } from '../components/sidebar/sidebar';
|
|
32
35
|
import { SlideCanvas } from '../components/slide-canvas';
|
|
33
36
|
import type { Folder, FolderIcon, SlideModule } from '../lib/sdk';
|
|
34
|
-
import { loadSlide } from '../lib/slides';
|
|
37
|
+
import { loadSlide, slideCreatedAt } from '../lib/slides';
|
|
35
38
|
import type { HomeOutletContext } from './home-shell';
|
|
36
39
|
|
|
40
|
+
type SortKey = 'created-desc' | 'created-asc' | 'title-asc' | 'title-desc';
|
|
41
|
+
|
|
42
|
+
const SORT_KEYS: readonly SortKey[] = ['created-desc', 'created-asc', 'title-asc', 'title-desc'];
|
|
43
|
+
|
|
44
|
+
const DEFAULT_SORT: SortKey = 'created-desc';
|
|
45
|
+
const SORT_STORAGE_KEY = 'open-slide:home-sort';
|
|
46
|
+
|
|
47
|
+
function readSortPref(): SortKey {
|
|
48
|
+
if (typeof window === 'undefined') return DEFAULT_SORT;
|
|
49
|
+
try {
|
|
50
|
+
const raw = window.localStorage.getItem(SORT_STORAGE_KEY);
|
|
51
|
+
if (raw && (SORT_KEYS as readonly string[]).includes(raw)) return raw as SortKey;
|
|
52
|
+
} catch {}
|
|
53
|
+
return DEFAULT_SORT;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function useSortPref(): [SortKey, (next: SortKey) => void] {
|
|
57
|
+
const [sortKey, setSortKey] = useState<SortKey>(readSortPref);
|
|
58
|
+
const update = (next: SortKey) => {
|
|
59
|
+
setSortKey(next);
|
|
60
|
+
try {
|
|
61
|
+
window.localStorage.setItem(SORT_STORAGE_KEY, next);
|
|
62
|
+
} catch {}
|
|
63
|
+
};
|
|
64
|
+
return [sortKey, update];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TITLE_COLLATOR = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
|
|
68
|
+
|
|
37
69
|
export function Home() {
|
|
38
70
|
const {
|
|
39
71
|
manifest,
|
|
@@ -58,6 +90,7 @@ export function Home() {
|
|
|
58
90
|
const isDraft = selectedId === DRAFT_ID;
|
|
59
91
|
|
|
60
92
|
const [query, setQuery] = useState('');
|
|
93
|
+
const [sortKey, setSortKey] = useSortPref();
|
|
61
94
|
|
|
62
95
|
const trimmedQuery = query.trim().toLowerCase();
|
|
63
96
|
const filteredSlides = useMemo(() => {
|
|
@@ -68,6 +101,24 @@ export function Home() {
|
|
|
68
101
|
return tl ? tl.includes(trimmedQuery) : false;
|
|
69
102
|
});
|
|
70
103
|
}, [visibleSlides, titleMap, trimmedQuery]);
|
|
104
|
+
const sortedSlides = useMemo(() => {
|
|
105
|
+
const list = filteredSlides.slice();
|
|
106
|
+
const titleOf = (id: string) => titleMap[id] ?? id;
|
|
107
|
+
switch (sortKey) {
|
|
108
|
+
case 'title-asc':
|
|
109
|
+
list.sort((a, b) => TITLE_COLLATOR.compare(titleOf(a), titleOf(b)));
|
|
110
|
+
break;
|
|
111
|
+
case 'title-desc':
|
|
112
|
+
list.sort((a, b) => TITLE_COLLATOR.compare(titleOf(b), titleOf(a)));
|
|
113
|
+
break;
|
|
114
|
+
case 'created-asc':
|
|
115
|
+
list.sort((a, b) => (slideCreatedAt[a] ?? 0) - (slideCreatedAt[b] ?? 0));
|
|
116
|
+
break;
|
|
117
|
+
default:
|
|
118
|
+
list.sort((a, b) => (slideCreatedAt[b] ?? 0) - (slideCreatedAt[a] ?? 0));
|
|
119
|
+
}
|
|
120
|
+
return list;
|
|
121
|
+
}, [filteredSlides, sortKey, titleMap]);
|
|
71
122
|
const isSearching = trimmedQuery.length > 0;
|
|
72
123
|
|
|
73
124
|
return (
|
|
@@ -90,7 +141,8 @@ export function Home() {
|
|
|
90
141
|
)}
|
|
91
142
|
</span>
|
|
92
143
|
)}
|
|
93
|
-
<div className="ml-auto w-full md:w-auto">
|
|
144
|
+
<div className="ml-auto flex w-full items-center gap-2 md:w-auto">
|
|
145
|
+
<SortControl value={sortKey} onChange={setSortKey} />
|
|
94
146
|
<SearchInput value={query} onChange={setQuery} />
|
|
95
147
|
</div>
|
|
96
148
|
</div>
|
|
@@ -104,7 +156,7 @@ export function Home() {
|
|
|
104
156
|
<NoResultsState query={query} onClear={() => setQuery('')} />
|
|
105
157
|
) : (
|
|
106
158
|
<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))]">
|
|
107
|
-
{
|
|
159
|
+
{sortedSlides.map((id) => (
|
|
108
160
|
<li key={id}>
|
|
109
161
|
<SlideCard
|
|
110
162
|
id={id}
|
|
@@ -152,6 +204,52 @@ function SearchInput({ value, onChange }: { value: string; onChange: (value: str
|
|
|
152
204
|
);
|
|
153
205
|
}
|
|
154
206
|
|
|
207
|
+
function SortControl({ value, onChange }: { value: SortKey; onChange: (next: SortKey) => void }) {
|
|
208
|
+
const t = useLocale();
|
|
209
|
+
const labels: Record<SortKey, string> = {
|
|
210
|
+
'created-desc': t.home.sortByCreatedDesc,
|
|
211
|
+
'created-asc': t.home.sortByCreatedAsc,
|
|
212
|
+
'title-asc': t.home.sortByTitleAsc,
|
|
213
|
+
'title-desc': t.home.sortByTitleDesc,
|
|
214
|
+
};
|
|
215
|
+
const FieldIcon = ({ k, className }: { k: SortKey; className?: string }) =>
|
|
216
|
+
k === 'title-asc' || k === 'title-desc' ? (
|
|
217
|
+
<ArrowDownAZ className={className} aria-hidden />
|
|
218
|
+
) : (
|
|
219
|
+
<Clock className={className} aria-hidden />
|
|
220
|
+
);
|
|
221
|
+
return (
|
|
222
|
+
<DropdownMenu>
|
|
223
|
+
<DropdownMenuTrigger asChild>
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
aria-label={`${t.home.sortLabel}: ${labels[value]}`}
|
|
227
|
+
className="flex h-8 items-center gap-1.5 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"
|
|
228
|
+
>
|
|
229
|
+
<FieldIcon k={value} className="size-3.5 text-muted-foreground" />
|
|
230
|
+
<span>{labels[value]}</span>
|
|
231
|
+
<ChevronDown className="size-3 text-muted-foreground" aria-hidden />
|
|
232
|
+
</button>
|
|
233
|
+
</DropdownMenuTrigger>
|
|
234
|
+
<DropdownMenuContent align="end" className="min-w-[180px]">
|
|
235
|
+
{SORT_KEYS.map((key) => {
|
|
236
|
+
const active = value === key;
|
|
237
|
+
return (
|
|
238
|
+
<DropdownMenuItem
|
|
239
|
+
key={key}
|
|
240
|
+
onSelect={() => onChange(key)}
|
|
241
|
+
className={cn(active && 'bg-muted text-foreground')}
|
|
242
|
+
>
|
|
243
|
+
<FieldIcon k={key} className="size-3.5 text-muted-foreground" />
|
|
244
|
+
<span>{labels[key]}</span>
|
|
245
|
+
</DropdownMenuItem>
|
|
246
|
+
);
|
|
247
|
+
})}
|
|
248
|
+
</DropdownMenuContent>
|
|
249
|
+
</DropdownMenu>
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
|
|
155
253
|
function HomeLoading() {
|
|
156
254
|
const t = useLocale();
|
|
157
255
|
return (
|