@open-slide/core 1.4.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.
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-XZJnC_fu.js";
2
+ import { createViteConfig } from "./config-iKjqaX08.js";
3
3
  import { createServer, mergeConfig } from "vite";
4
4
 
5
5
  //#region src/cli/dev.ts
@@ -45,6 +45,11 @@ const en = {
45
45
  folderActions: "Folder actions",
46
46
  searchPlaceholder: "Search slides",
47
47
  clearSearch: "Clear search",
48
+ sortLabel: "Sort",
49
+ sortByCreatedDesc: "Newest",
50
+ sortByCreatedAsc: "Oldest",
51
+ sortByTitleAsc: "A–Z",
52
+ sortByTitleDesc: "Z–A",
48
53
  noMatches: "No matches",
49
54
  nothingMatchesPrefix: "Nothing matches ",
50
55
  nothingMatchesSuffix: " in this folder.",
@@ -84,6 +89,9 @@ const en = {
84
89
  agentDisconnected: "Agent disconnected",
85
90
  agentDisconnectedTooltip: "Lost connection to the dev server, so your agent can no longer see the current slide or inspector selection. Restart the dev server to restore the connection.",
86
91
  download: "Download",
92
+ copyLink: "Copy link",
93
+ toastCopyLinkSuccess: "Link copied to clipboard",
94
+ toastCopyLinkFailed: "Failed to copy link",
87
95
  exportAsHtml: "Export as HTML",
88
96
  exportAsPdf: "Export as PDF",
89
97
  pdfExportFailed: "PDF export failed",
@@ -202,7 +210,7 @@ const en = {
202
210
  cropResetAria: "Reset crop",
203
211
  leaveComment: "Leave a comment",
204
212
  commentPlaceholder: "Describe a change for the agent…",
205
- commentShortcutHint: "⌘↵ to add",
213
+ commentShortcutHint: "⌘/ to focus · ⌘↵ to add",
206
214
  addComment: "Add comment",
207
215
  unsavedChanges: {
208
216
  one: "{count} unsaved change",
@@ -270,6 +278,11 @@ const en = {
270
278
  conflictRenameCopy: "Rename copy",
271
279
  deleteAssetTitle: "Delete asset",
272
280
  deleteAssetDescription: "Delete {name}? Imports referencing this file in the slide will break.",
281
+ deleteAssetInUseDescription: "{name} is used in {count} place(s) across {slides} slide(s).",
282
+ deleteAssetInUseHint: "Deleting will revert these usages back to image placeholders.",
283
+ deleteAndRevert: "Delete & revert",
284
+ toastRevertFailed: "Couldn't revert usage in {slideId}",
285
+ toastDeletedWithRevert: "Deleted {name} and reverted {count} usage(s)",
273
286
  noPreview: "No preview available",
274
287
  importHintComment: "import asset from ",
275
288
  importHintSemi: ";",
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Locale, Plural } from "./types-QCpkHkiS.js";
2
- import { OpenSlideConfig } from "./config-s0YUbmUe.js";
1
+ import { Locale, Plural } from "./types-Dpr8nbih.js";
2
+ import { OpenSlideConfig } from "./config-BQdTMho4.js";
3
3
  import { CSSProperties, ComponentType, HTMLAttributes } from "react";
4
4
  import * as react_jsx_runtime0 from "react/jsx-runtime";
5
5
 
@@ -51,6 +51,8 @@ type Page = ComponentType;
51
51
  type SlideMeta = {
52
52
  title?: string;
53
53
  theme?: string;
54
+ /** ISO 8601 timestamp. Set once at scaffold time; used to sort the slide list. */
55
+ createdAt?: string;
54
56
  };
55
57
  type SlideModule = {
56
58
  default: Page[];
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { en } from "./en-7GU-DHbJ.js";
1
+ import { en } from "./en-DDGqyNaW.js";
2
2
  import { cssVarsToString, defaultDesign, designToCssVars } from "./design-cpzS8aud.js";
3
3
  import { useRef, useState } from "react";
4
4
  import { toast } from "sonner";
@@ -1,4 +1,4 @@
1
- import { Locale, Plural } from "../types-QCpkHkiS.js";
1
+ import { Locale, Plural } from "../types-Dpr8nbih.js";
2
2
 
3
3
  //#region src/locale/en.d.ts
4
4
  declare const en: Locale;
@@ -1,4 +1,4 @@
1
- import { en } from "../en-7GU-DHbJ.js";
1
+ import { en } from "../en-DDGqyNaW.js";
2
2
 
3
3
  //#region src/locale/format.ts
4
4
  function format(template, vars) {
@@ -59,6 +59,11 @@ const ja = {
59
59
  folderActions: "フォルダ操作",
60
60
  searchPlaceholder: "スライドを検索",
61
61
  clearSearch: "検索をクリア",
62
+ sortLabel: "並べ替え",
63
+ sortByCreatedDesc: "新しい順",
64
+ sortByCreatedAsc: "古い順",
65
+ sortByTitleAsc: "A–Z",
66
+ sortByTitleDesc: "Z–A",
62
67
  noMatches: "一致なし",
63
68
  nothingMatchesPrefix: "このフォルダ内に ",
64
69
  nothingMatchesSuffix: " に一致する項目はありません。",
@@ -98,6 +103,9 @@ const ja = {
98
103
  home: "ホーム",
99
104
  backToHome: "ホームへ戻る",
100
105
  download: "ダウンロード",
106
+ copyLink: "リンクをコピー",
107
+ toastCopyLinkSuccess: "リンクをクリップボードにコピーしました",
108
+ toastCopyLinkFailed: "リンクのコピーに失敗しました",
101
109
  exportAsHtml: "HTML として書き出し",
102
110
  exportAsPdf: "PDF として書き出し",
103
111
  pdfExportFailed: "PDF の書き出しに失敗しました",
@@ -216,7 +224,7 @@ const ja = {
216
224
  agentNotWatchingTooltip: "dev server との接続が切れたため、選択中の要素がエージェントに見えなくなっています。dev server を再起動して接続を復旧してください。",
217
225
  leaveComment: "コメントを残す",
218
226
  commentPlaceholder: "エージェントに依頼する変更を記述…",
219
- commentShortcutHint: "⌘↵ で追加",
227
+ commentShortcutHint: "⌘/ でフォーカス · ⌘↵ で追加",
220
228
  addComment: "コメントを追加",
221
229
  unsavedChanges: {
222
230
  one: "未保存の変更 {count} 件",
@@ -284,6 +292,11 @@ const ja = {
284
292
  conflictRenameCopy: "コピーをリネーム",
285
293
  deleteAssetTitle: "アセットを削除",
286
294
  deleteAssetDescription: "{name} を削除しますか?スライド内でこのファイルを参照しているインポートは壊れます。",
295
+ deleteAssetInUseDescription: "{name} は {slides} 枚のスライドで {count} 箇所使用されています。",
296
+ deleteAssetInUseHint: "削除すると、これらの使用箇所は画像プレースホルダーに戻ります。",
297
+ deleteAndRevert: "削除して戻す",
298
+ toastRevertFailed: "{slideId} の使用箇所を戻せませんでした",
299
+ toastDeletedWithRevert: "{name} を削除し、{count} 箇所をプレースホルダーに戻しました",
287
300
  noPreview: "プレビューはありません",
288
301
  importHintComment: "import asset from ",
289
302
  importHintSemi: ";",
@@ -419,6 +432,11 @@ const zhCN = {
419
432
  folderActions: "文件夹操作",
420
433
  searchPlaceholder: "搜索幻灯片",
421
434
  clearSearch: "清除搜索",
435
+ sortLabel: "排序",
436
+ sortByCreatedDesc: "最新",
437
+ sortByCreatedAsc: "最旧",
438
+ sortByTitleAsc: "A–Z",
439
+ sortByTitleDesc: "Z–A",
422
440
  noMatches: "没有匹配项",
423
441
  nothingMatchesPrefix: "该文件夹中没有匹配 ",
424
442
  nothingMatchesSuffix: " 的内容。",
@@ -458,6 +476,9 @@ const zhCN = {
458
476
  home: "首页",
459
477
  backToHome: "返回首页",
460
478
  download: "下载",
479
+ copyLink: "复制链接",
480
+ toastCopyLinkSuccess: "已复制链接到剪贴板",
481
+ toastCopyLinkFailed: "复制链接失败",
461
482
  exportAsHtml: "导出为 HTML",
462
483
  exportAsPdf: "导出为 PDF",
463
484
  pdfExportFailed: "PDF 导出失败",
@@ -576,7 +597,7 @@ const zhCN = {
576
597
  agentNotWatchingTooltip: "已和 dev server 断开连接,agent 看不到你选的元素了。请重新启动 dev server 来恢复连接。",
577
598
  leaveComment: "留个 comment",
578
599
  commentPlaceholder: "描述你希望代理执行的更改…",
579
- commentShortcutHint: "⌘↵ 添加",
600
+ commentShortcutHint: "⌘/ 聚焦 · ⌘↵ 添加",
580
601
  addComment: "添加 comment",
581
602
  unsavedChanges: {
582
603
  one: "{count} 项未保存的更改",
@@ -644,6 +665,11 @@ const zhCN = {
644
665
  conflictRenameCopy: "重命名副本",
645
666
  deleteAssetTitle: "删除素材",
646
667
  deleteAssetDescription: "要删除 {name} 吗?幻灯片中引用此文件的导入将失效。",
668
+ deleteAssetInUseDescription: "{name} 在 {slides} 个幻灯片中被使用了 {count} 次。",
669
+ deleteAssetInUseHint: "删除后这些使用处会自动还原为图片占位符。",
670
+ deleteAndRevert: "删除并还原",
671
+ toastRevertFailed: "无法还原 {slideId} 中的使用",
672
+ toastDeletedWithRevert: "已删除 {name} 并还原 {count} 个使用处",
647
673
  noPreview: "无预览",
648
674
  importHintComment: "import asset from ",
649
675
  importHintSemi: ";",
@@ -779,6 +805,11 @@ const zhTW = {
779
805
  folderActions: "資料夾操作",
780
806
  searchPlaceholder: "搜尋投影片",
781
807
  clearSearch: "清除搜尋",
808
+ sortLabel: "排序",
809
+ sortByCreatedDesc: "最新",
810
+ sortByCreatedAsc: "最舊",
811
+ sortByTitleAsc: "A–Z",
812
+ sortByTitleDesc: "Z–A",
782
813
  noMatches: "沒有相符結果",
783
814
  nothingMatchesPrefix: "此資料夾中沒有相符 ",
784
815
  nothingMatchesSuffix: " 的項目。",
@@ -818,6 +849,9 @@ const zhTW = {
818
849
  home: "首頁",
819
850
  backToHome: "返回首頁",
820
851
  download: "下載",
852
+ copyLink: "複製連結",
853
+ toastCopyLinkSuccess: "已複製連結到剪貼簿",
854
+ toastCopyLinkFailed: "複製連結失敗",
821
855
  exportAsHtml: "匯出為 HTML",
822
856
  exportAsPdf: "匯出為 PDF",
823
857
  pdfExportFailed: "PDF 匯出失敗",
@@ -936,7 +970,7 @@ const zhTW = {
936
970
  agentNotWatchingTooltip: "已和 dev server 斷線,agent 看不到你選的元素了。請重新啟動 dev server 來恢復連線。",
937
971
  leaveComment: "留個 comment",
938
972
  commentPlaceholder: "描述你希望代理進行的修改…",
939
- commentShortcutHint: "⌘↵ 新增",
973
+ commentShortcutHint: "⌘/ 聚焦 · ⌘↵ 新增",
940
974
  addComment: "新增 comment",
941
975
  unsavedChanges: {
942
976
  one: "{count} 項未儲存的變更",
@@ -1004,6 +1038,11 @@ const zhTW = {
1004
1038
  conflictRenameCopy: "重新命名副本",
1005
1039
  deleteAssetTitle: "刪除素材",
1006
1040
  deleteAssetDescription: "要刪除 {name} 嗎?投影片中引用此檔案的匯入將失效。",
1041
+ deleteAssetInUseDescription: "{name} 在 {slides} 個投影片中被使用了 {count} 次。",
1042
+ deleteAssetInUseHint: "刪除後這些使用處會自動還原為圖片占位符。",
1043
+ deleteAndRevert: "刪除並還原",
1044
+ toastRevertFailed: "無法還原 {slideId} 中的使用",
1045
+ toastDeletedWithRevert: "已刪除 {name} 並還原 {count} 個使用處",
1007
1046
  noPreview: "無預覽",
1008
1047
  importHintComment: "import asset from ",
1009
1048
  importHintSemi: ";",
@@ -1,5 +1,5 @@
1
1
  import "./design-cpzS8aud.js";
2
- import { createViteConfig } from "./config-XZJnC_fu.js";
2
+ import { createViteConfig } from "./config-iKjqaX08.js";
3
3
  import { mergeConfig, preview as preview$1 } from "vite";
4
4
 
5
5
  //#region src/cli/preview.ts
@@ -49,6 +49,11 @@ type Locale = {
49
49
  folderActions: string;
50
50
  searchPlaceholder: string;
51
51
  clearSearch: string;
52
+ sortLabel: string;
53
+ sortByCreatedDesc: string;
54
+ sortByCreatedAsc: string;
55
+ sortByTitleAsc: string;
56
+ sortByTitleDesc: string;
52
57
  noMatches: string;
53
58
  nothingMatchesPrefix: string;
54
59
  nothingMatchesSuffix: string;
@@ -91,6 +96,9 @@ type Locale = {
91
96
  agentDisconnected: string;
92
97
  agentDisconnectedTooltip: string;
93
98
  download: string;
99
+ copyLink: string;
100
+ toastCopyLinkSuccess: string;
101
+ toastCopyLinkFailed: string;
94
102
  exportAsHtml: string;
95
103
  exportAsPdf: string;
96
104
  pdfExportFailed: string;
@@ -280,6 +288,14 @@ type Locale = {
280
288
  deleteAssetTitle: string;
281
289
  /** template: "Delete {name}? Imports referencing this file in the slide will break." */
282
290
  deleteAssetDescription: string;
291
+ /** template: "{name} is used in {count} place across {slides} slide." (singular/plural via {count}/{slides}) */
292
+ deleteAssetInUseDescription: string;
293
+ deleteAssetInUseHint: string;
294
+ deleteAndRevert: string;
295
+ /** template: "Couldn't revert usage in {slideId}." */
296
+ toastRevertFailed: string;
297
+ /** template: "Deleted {name} and reverted {count} usage." */
298
+ toastDeletedWithRevert: string;
283
299
  noPreview: string;
284
300
  importHintComment: string;
285
301
  importHintSemi: string;
@@ -1,5 +1,5 @@
1
- import "../types-QCpkHkiS.js";
2
- import { OpenSlideConfig } from "../config-s0YUbmUe.js";
1
+ import "../types-Dpr8nbih.js";
2
+ import { OpenSlideConfig } from "../config-BQdTMho4.js";
3
3
  import { InlineConfig } from "vite";
4
4
 
5
5
  //#region src/vite/config.d.ts
@@ -1,4 +1,4 @@
1
1
  import "../design-cpzS8aud.js";
2
- import { createViteConfig } from "../config-XZJnC_fu.js";
2
+ import { createViteConfig } from "../config-iKjqaX08.js";
3
3
 
4
4
  export { createViteConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-slide/core",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Runtime and CLI for open-slide — write slides in slides/, we handle the rest.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -31,7 +31,10 @@ import type { Page, SlideMeta } from '@open-slide/core';
31
31
  const Cover: Page = () => <div>…</div>;
32
32
  const Body: Page = () => <div>…</div>;
33
33
 
34
- export const meta: SlideMeta = { title: 'My slide' };
34
+ export const meta: SlideMeta = {
35
+ title: 'My slide',
36
+ createdAt: '2026-05-16T12:00:00Z',
37
+ };
35
38
  export default [Cover, Body] satisfies Page[];
36
39
  ```
37
40
 
@@ -39,6 +42,7 @@ export default [Cover, Body] satisfies Page[];
39
42
  - `meta.title` (optional) shows in the slide header. Default is the folder name.
40
43
  - The slide id is the kebab-case folder name. Pick something short and descriptive (`q2-roadmap`, `team-offsite-2026`).
41
44
  - `meta.theme` (optional) marks the slide as built from a theme under `themes/`. The id must match a `<id>.md` basename. Surfaces a back-link chip on the slide card and lists the slide on `/themes/<id>`. Omit if the slide isn't derived from a registered theme.
45
+ - `meta.createdAt` is an **ISO 8601 string literal** (e.g. `'2026-05-16T12:00:00Z'`) set once when the slide is scaffolded. The home page uses it for the default "newest first" sort. Always include it on new slides — **immediately before writing the file, run `node -e "console.log(new Date().toISOString())"` via Bash and paste the exact output** as the value. Don't type a timestamp from memory — you will get the date or time wrong. Must be a plain string literal (no `new Date(...)` or imports in the slide itself) — the framework reads it via a regex at build time, not by evaluating the module.
42
46
 
43
47
  ## Editing an existing slide
44
48
 
@@ -236,7 +240,10 @@ const Content: Page = () => (
236
240
  </div>
237
241
  );
238
242
 
239
- export const meta: SlideMeta = { title: 'The Big Idea' };
243
+ export const meta: SlideMeta = {
244
+ title: 'The Big Idea',
245
+ createdAt: '2026-05-16T12:00:00Z',
246
+ };
240
247
  export default [Cover, Content] satisfies Page[];
241
248
  ```
242
249
 
@@ -33,8 +33,11 @@ import {
33
33
  import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
34
34
  import {
35
35
  type AssetEntry,
36
+ type AssetUsage,
36
37
  fetchSvgAsFile,
38
+ listAssetUsages,
37
39
  renamedCopy,
40
+ revertAssetUsage,
38
41
  type SvglItem,
39
42
  searchSvgl,
40
43
  useAssets,
@@ -62,6 +65,7 @@ export function AssetView({ slideId }: Props) {
62
65
  const [conflict, setConflict] = useState<ConflictState | null>(null);
63
66
  const [preview, setPreview] = useState<AssetEntry | null>(null);
64
67
  const [confirmDelete, setConfirmDelete] = useState<AssetEntry | null>(null);
68
+ const [confirmDeleteUsages, setConfirmDeleteUsages] = useState<AssetUsage[] | null>(null);
65
69
  const [renaming, setRenaming] = useState<string | null>(null);
66
70
  const [logoSearchOpen, setLogoSearchOpen] = useState(false);
67
71
  const dragDepth = useRef(0);
@@ -245,7 +249,13 @@ export function AssetView({ slideId }: Props) {
245
249
  asset={asset}
246
250
  onPreview={() => setPreview(asset)}
247
251
  onRename={() => setRenaming(asset.name)}
248
- onDelete={() => setConfirmDelete(asset)}
252
+ onDelete={() => {
253
+ setConfirmDelete(asset);
254
+ setConfirmDeleteUsages(null);
255
+ listAssetUsages(effectiveSlideId, asset.name)
256
+ .then((u) => setConfirmDeleteUsages(u))
257
+ .catch(() => setConfirmDeleteUsages([]));
258
+ }}
249
259
  />
250
260
  ),
251
261
  )}
@@ -282,13 +292,41 @@ export function AssetView({ slideId }: Props) {
282
292
  {confirmDelete && (
283
293
  <DeleteDialog
284
294
  asset={confirmDelete}
285
- onCancel={() => setConfirmDelete(null)}
295
+ usages={confirmDeleteUsages}
296
+ onCancel={() => {
297
+ setConfirmDelete(null);
298
+ setConfirmDeleteUsages(null);
299
+ }}
286
300
  onConfirm={async () => {
287
301
  const target = confirmDelete;
302
+ const usages = confirmDeleteUsages ?? [];
288
303
  setConfirmDelete(null);
304
+ setConfirmDeleteUsages(null);
305
+ const assetPath =
306
+ scope === 'global' ? `@assets/${target.name}` : `./assets/${target.name}`;
307
+ for (const u of usages) {
308
+ const rev = await revertAssetUsage(u.slideId, assetPath);
309
+ if (!rev.ok) {
310
+ toast.error(format(t.asset.toastRevertFailed, { slideId: u.slideId }));
311
+ return;
312
+ }
313
+ }
289
314
  const res = await remove(target.name);
290
- if (!res.ok) toast.error(format(t.asset.toastDeleteFailed, { status: res.status }));
291
- else toast.success(format(t.asset.toastDeleted, { name: target.name }));
315
+ if (!res.ok) {
316
+ toast.error(format(t.asset.toastDeleteFailed, { status: res.status }));
317
+ return;
318
+ }
319
+ const totalUsages = usages.reduce((acc, u) => acc + u.count, 0);
320
+ if (totalUsages > 0) {
321
+ toast.success(
322
+ format(t.asset.toastDeletedWithRevert, {
323
+ name: target.name,
324
+ count: totalUsages,
325
+ }),
326
+ );
327
+ } else {
328
+ toast.success(format(t.asset.toastDeleted, { name: target.name }));
329
+ }
292
330
  }}
293
331
  />
294
332
  )}
@@ -517,14 +555,19 @@ function ConflictDialog({
517
555
 
518
556
  function DeleteDialog({
519
557
  asset,
558
+ usages,
520
559
  onCancel,
521
560
  onConfirm,
522
561
  }: {
523
562
  asset: AssetEntry;
563
+ usages: AssetUsage[] | null;
524
564
  onCancel: () => void;
525
565
  onConfirm: () => void;
526
566
  }) {
527
567
  const t = useLocale();
568
+ const inUse = (usages?.length ?? 0) > 0;
569
+ const totalUses = usages?.reduce((acc, u) => acc + u.count, 0) ?? 0;
570
+ const slideCount = usages?.length ?? 0;
528
571
  const [descPrefix, descSuffix] = t.asset.deleteAssetDescription.split('{name}');
529
572
  return (
530
573
  <Dialog open onOpenChange={(open) => !open && onCancel()}>
@@ -532,17 +575,40 @@ function DeleteDialog({
532
575
  <DialogHeader>
533
576
  <DialogTitle>{t.asset.deleteAssetTitle}</DialogTitle>
534
577
  <DialogDescription>
535
- {descPrefix}
536
- <span className="font-mono">{asset.name}</span>
537
- {descSuffix}
578
+ {inUse ? (
579
+ <>
580
+ {format(t.asset.deleteAssetInUseDescription, {
581
+ name: asset.name,
582
+ count: totalUses,
583
+ slides: slideCount,
584
+ })}{' '}
585
+ {t.asset.deleteAssetInUseHint}
586
+ </>
587
+ ) : (
588
+ <>
589
+ {descPrefix}
590
+ <span className="font-mono">{asset.name}</span>
591
+ {descSuffix}
592
+ </>
593
+ )}
538
594
  </DialogDescription>
539
595
  </DialogHeader>
596
+ {inUse && usages && (
597
+ <ul className="max-h-40 overflow-y-auto rounded-[5px] border border-hairline bg-muted/40 px-3 py-2 font-mono text-[11.5px] leading-relaxed">
598
+ {usages.map((u) => (
599
+ <li key={u.slideId} className="flex items-center justify-between gap-3">
600
+ <span className="truncate">{u.slideId}</span>
601
+ <span className="text-muted-foreground">×{u.count}</span>
602
+ </li>
603
+ ))}
604
+ </ul>
605
+ )}
540
606
  <DialogFooter>
541
607
  <Button variant="outline" onClick={onCancel}>
542
608
  {t.common.cancel}
543
609
  </Button>
544
- <Button variant="destructive" onClick={onConfirm}>
545
- {t.common.delete}
610
+ <Button variant="destructive" onClick={onConfirm} disabled={usages === null}>
611
+ {inUse ? t.asset.deleteAndRevert : t.common.delete}
546
612
  </Button>
547
613
  </DialogFooter>
548
614
  </DialogContent>
@@ -1116,8 +1116,23 @@ function CommentsSection({
1116
1116
  }) {
1117
1117
  const [draft, setDraft] = useState('');
1118
1118
  const [submitting, setSubmitting] = useState(false);
1119
+ const wrapRef = useRef<HTMLDivElement>(null);
1119
1120
  const t = useLocale();
1120
1121
 
1122
+ useEffect(() => {
1123
+ const onKey = (e: KeyboardEvent) => {
1124
+ if (e.key !== '/') return;
1125
+ if (!(e.metaKey || e.ctrlKey)) return;
1126
+ if (e.altKey || e.shiftKey) return;
1127
+ const ta = wrapRef.current?.querySelector('textarea');
1128
+ if (!ta) return;
1129
+ e.preventDefault();
1130
+ ta.focus({ preventScroll: true });
1131
+ };
1132
+ window.addEventListener('keydown', onKey);
1133
+ return () => window.removeEventListener('keydown', onKey);
1134
+ }, []);
1135
+
1121
1136
  const submit = async () => {
1122
1137
  const trimmed = draft.trim();
1123
1138
  if (!trimmed) return;
@@ -1133,7 +1148,7 @@ function CommentsSection({
1133
1148
  return (
1134
1149
  <Section title={t.inspector.leaveComment}>
1135
1150
  <div className="flex flex-col gap-2">
1136
- <div className="comment-cue rounded-[6px]">
1151
+ <div ref={wrapRef} className="comment-cue rounded-[6px]">
1137
1152
  <Textarea
1138
1153
  value={draft}
1139
1154
  onChange={(e) => setDraft(e.target.value)}
@@ -68,10 +68,12 @@ export function PanelShell({
68
68
  {header}
69
69
  </header>
70
70
  {banner}
71
- <ScrollArea className="flex flex-1 flex-col">
72
- <div className="flex min-h-full flex-col">{children}</div>
71
+ <ScrollArea className="min-h-0 flex-1">
72
+ <div className="flex min-h-full flex-col">
73
+ {children}
74
+ {footer && <div className="mt-auto border-t border-hairline">{footer}</div>}
75
+ </div>
73
76
  </ScrollArea>
74
- {footer && <div className="shrink-0 border-t border-hairline">{footer}</div>}
75
77
  </div>
76
78
  </aside>
77
79
  );
@@ -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
  );
@@ -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,
@@ -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);