@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.
Files changed (45) hide show
  1. package/dist/{build-_276DMmJ.js → build-DZhbjQpQ.js} +1 -1
  2. package/dist/cli/bin.js +3 -3
  3. package/dist/{config-D9cZ1A0X.d.ts → config-BQdTMho4.d.ts} +2 -1
  4. package/dist/{config-BAwKWNtW.js → config-iKjqaX08.js} +2528 -1640
  5. package/dist/{dev-BoqeVXVq.js → dev-BjLGk5nN.js} +1 -1
  6. package/dist/{en-CDKzoZvf.js → en-DDGqyNaW.js} +27 -4
  7. package/dist/index.d.ts +4 -2
  8. package/dist/index.js +1 -1
  9. package/dist/locale/index.d.ts +1 -1
  10. package/dist/locale/index.js +82 -13
  11. package/dist/{preview-BLPxspc9.js → preview-jwLWHWkQ.js} +1 -1
  12. package/dist/{types-JYG1cmwC.d.ts → types-Dpr8nbih.d.ts} +27 -1
  13. package/dist/vite/index.d.ts +2 -2
  14. package/dist/vite/index.js +1 -1
  15. package/package.json +1 -1
  16. package/skills/slide-authoring/SKILL.md +19 -4
  17. package/src/app/app.tsx +2 -0
  18. package/src/app/components/asset-view.tsx +111 -18
  19. package/src/app/components/inspector/inspect-overlay.tsx +49 -3
  20. package/src/app/components/inspector/inspector-panel.tsx +267 -25
  21. package/src/app/components/inspector/inspector-provider.tsx +390 -49
  22. package/src/app/components/panel/panel-shell.tsx +5 -3
  23. package/src/app/components/player.tsx +25 -5
  24. package/src/app/components/present/control-bar.tsx +12 -0
  25. package/src/app/components/present/laser-pointer.tsx +3 -4
  26. package/src/app/components/present/progress-bar.tsx +4 -4
  27. package/src/app/components/sidebar/folder-item.tsx +14 -3
  28. package/src/app/components/sidebar/sidebar.tsx +10 -0
  29. package/src/app/lib/assets.ts +21 -0
  30. package/src/app/lib/export-pdf.ts +6 -0
  31. package/src/app/lib/inspector/use-editor.ts +9 -1
  32. package/src/app/lib/sdk.ts +2 -0
  33. package/src/app/lib/slides.ts +9 -0
  34. package/src/app/lib/use-slide-module.ts +48 -0
  35. package/src/app/routes/assets.tsx +9 -0
  36. package/src/app/routes/home-shell.tsx +23 -2
  37. package/src/app/routes/home.tsx +101 -3
  38. package/src/app/routes/presenter.tsx +2 -20
  39. package/src/app/routes/slide.tsx +117 -39
  40. package/src/app/virtual.d.ts +1 -0
  41. package/src/locale/en.ts +28 -5
  42. package/src/locale/ja.ts +28 -5
  43. package/src/locale/types.ts +27 -1
  44. package/src/locale/zh-cn.ts +28 -6
  45. 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
- 'relative flex h-dvh w-screen items-center justify-center bg-black',
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: 'translate(-50%, -50%)',
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 ? ((index + 1) / total) * 100 : 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-[width] duration-200 ease-out"
22
- style={{ width: `${pct}%` }}
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.folder.icon;
131
+ : row.kind === 'assets'
132
+ ? { type: 'emoji', value: '🗂️' }
133
+ : row.folder.icon;
129
134
  const label =
130
- row.kind === 'draft' ? t.home.draft : row.kind === 'themes' ? t.home.themes : row.folder.name;
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">
@@ -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
 
@@ -6,6 +6,8 @@ export type Page = ComponentType;
6
6
  export type SlideMeta = {
7
7
  title?: string;
8
8
  theme?: string;
9
+ /** ISO 8601 timestamp. Set once at scaffold time; used to sort the slide list. */
10
+ createdAt?: string;
9
11
  };
10
12
 
11
13
  export type SlideModule = {
@@ -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
+ }
@@ -0,0 +1,9 @@
1
+ import { AssetView } from '../components/asset-view';
2
+
3
+ export function AssetsPage() {
4
+ return (
5
+ <div className="flex min-h-0 flex-1 flex-col">
6
+ <AssetView slideId={null} />
7
+ </div>
8
+ );
9
+ }
@@ -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 className="mx-auto w-full max-w-[1180px] px-5 py-8 md:px-10 md:py-12">
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>
@@ -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
- {filteredSlides.map((id) => (
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 (