@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,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 (
@@ -1,10 +1,12 @@
1
1
  import config from 'virtual:open-slide/config';
2
2
  import {
3
+ Check,
3
4
  ChevronDown,
4
5
  ChevronLeft,
5
6
  Download,
6
7
  FileCode2,
7
8
  FileText,
9
+ Link2,
8
10
  Loader2,
9
11
  Maximize,
10
12
  MonitorSpeaker,
@@ -61,7 +63,15 @@ export function Slide() {
61
63
  const { slide, error } = useSlideModule(slideId);
62
64
  const [playMode, setPlayMode] = useState<'window' | 'fullscreen' | null>(null);
63
65
  const [exporting, setExporting] = useState(false);
66
+ const [linkCopied, setLinkCopied] = useState(false);
67
+ const linkCopiedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
64
68
  const [designOpen, setDesignOpen] = useState(false);
69
+
70
+ useEffect(() => {
71
+ return () => {
72
+ if (linkCopiedTimerRef.current) clearTimeout(linkCopiedTimerRef.current);
73
+ };
74
+ }, []);
65
75
  const { renameSlide } = useFolders();
66
76
  const slideViewportRef = useRef<HTMLElement>(null);
67
77
  const t = useLocale();
@@ -375,6 +385,41 @@ export function Slide() {
375
385
  </div>
376
386
 
377
387
  <div className="flex items-center gap-1">
388
+ {view === 'slides' && (
389
+ <button
390
+ type="button"
391
+ aria-label={t.slide.copyLink}
392
+ title={t.slide.copyLink}
393
+ className={cn(buttonVariants({ variant: 'ghost', size: 'icon-sm' }))}
394
+ onClick={async () => {
395
+ try {
396
+ await navigator.clipboard.writeText(window.location.href);
397
+ toast.success(t.slide.toastCopyLinkSuccess);
398
+ setLinkCopied(true);
399
+ if (linkCopiedTimerRef.current) clearTimeout(linkCopiedTimerRef.current);
400
+ linkCopiedTimerRef.current = setTimeout(() => setLinkCopied(false), 1200);
401
+ } catch (err) {
402
+ console.error('[open-slide] copy link failed', err);
403
+ toast.error(t.slide.toastCopyLinkFailed);
404
+ }
405
+ }}
406
+ >
407
+ <span className="relative grid size-4 place-items-center">
408
+ <Link2
409
+ className={cn(
410
+ 'col-start-1 row-start-1 size-4 transition-opacity duration-200',
411
+ linkCopied ? 'opacity-0' : 'opacity-100',
412
+ )}
413
+ />
414
+ <Check
415
+ className={cn(
416
+ 'col-start-1 row-start-1 size-4 transition-opacity duration-200',
417
+ linkCopied ? 'opacity-100' : 'opacity-0',
418
+ )}
419
+ />
420
+ </span>
421
+ </button>
422
+ )}
378
423
  {view === 'slides' && allowHtmlDownload && (
379
424
  <DropdownMenu>
380
425
  <DropdownMenuTrigger
@@ -2,6 +2,7 @@ declare module 'virtual:open-slide/slides' {
2
2
  import type { SlideModule } from './lib/sdk';
3
3
  export const slideIds: string[];
4
4
  export const slideThemes: Record<string, string>;
5
+ export const slideCreatedAt: Record<string, number>;
5
6
  export function loadSlide(id: string): Promise<SlideModule>;
6
7
  }
7
8
 
package/src/locale/en.ts CHANGED
@@ -49,6 +49,11 @@ export const en: Locale = {
49
49
  folderActions: 'Folder actions',
50
50
  searchPlaceholder: 'Search slides',
51
51
  clearSearch: 'Clear search',
52
+ sortLabel: 'Sort',
53
+ sortByCreatedDesc: 'Newest',
54
+ sortByCreatedAsc: 'Oldest',
55
+ sortByTitleAsc: 'A–Z',
56
+ sortByTitleDesc: 'Z–A',
52
57
  noMatches: 'No matches',
53
58
  nothingMatchesPrefix: 'Nothing matches ',
54
59
  nothingMatchesSuffix: ' in this folder.',
@@ -91,6 +96,9 @@ export const en: Locale = {
91
96
  agentDisconnectedTooltip:
92
97
  '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.',
93
98
  download: 'Download',
99
+ copyLink: 'Copy link',
100
+ toastCopyLinkSuccess: 'Link copied to clipboard',
101
+ toastCopyLinkFailed: 'Failed to copy link',
94
102
  exportAsHtml: 'Export as HTML',
95
103
  exportAsPdf: 'Export as PDF',
96
104
  pdfExportFailed: 'PDF export failed',
@@ -214,7 +222,7 @@ export const en: Locale = {
214
222
  cropResetAria: 'Reset crop',
215
223
  leaveComment: 'Leave a comment',
216
224
  commentPlaceholder: 'Describe a change for the agent…',
217
- commentShortcutHint: '⌘↵ to add',
225
+ commentShortcutHint: '⌘/ to focus · ⌘↵ to add',
218
226
  addComment: 'Add comment',
219
227
  unsavedChanges: {
220
228
  one: '{count} unsaved change',
@@ -281,6 +289,11 @@ export const en: Locale = {
281
289
  conflictRenameCopy: 'Rename copy',
282
290
  deleteAssetTitle: 'Delete asset',
283
291
  deleteAssetDescription: 'Delete {name}? Imports referencing this file in the slide will break.',
292
+ deleteAssetInUseDescription: '{name} is used in {count} place(s) across {slides} slide(s).',
293
+ deleteAssetInUseHint: 'Deleting will revert these usages back to image placeholders.',
294
+ deleteAndRevert: 'Delete & revert',
295
+ toastRevertFailed: "Couldn't revert usage in {slideId}",
296
+ toastDeletedWithRevert: 'Deleted {name} and reverted {count} usage(s)',
284
297
  noPreview: 'No preview available',
285
298
  importHintComment: 'import asset from ',
286
299
  importHintSemi: ';',
package/src/locale/ja.ts CHANGED
@@ -49,6 +49,11 @@ export const ja: Locale = {
49
49
  folderActions: 'フォルダ操作',
50
50
  searchPlaceholder: 'スライドを検索',
51
51
  clearSearch: '検索をクリア',
52
+ sortLabel: '並べ替え',
53
+ sortByCreatedDesc: '新しい順',
54
+ sortByCreatedAsc: '古い順',
55
+ sortByTitleAsc: 'A–Z',
56
+ sortByTitleDesc: 'Z–A',
52
57
  noMatches: '一致なし',
53
58
  nothingMatchesPrefix: 'このフォルダ内に ',
54
59
  nothingMatchesSuffix: ' に一致する項目はありません。',
@@ -91,6 +96,9 @@ export const ja: Locale = {
91
96
  home: 'ホーム',
92
97
  backToHome: 'ホームへ戻る',
93
98
  download: 'ダウンロード',
99
+ copyLink: 'リンクをコピー',
100
+ toastCopyLinkSuccess: 'リンクをクリップボードにコピーしました',
101
+ toastCopyLinkFailed: 'リンクのコピーに失敗しました',
94
102
  exportAsHtml: 'HTML として書き出し',
95
103
  exportAsPdf: 'PDF として書き出し',
96
104
  pdfExportFailed: 'PDF の書き出しに失敗しました',
@@ -215,7 +223,7 @@ export const ja: Locale = {
215
223
  'dev server との接続が切れたため、選択中の要素がエージェントに見えなくなっています。dev server を再起動して接続を復旧してください。',
216
224
  leaveComment: 'コメントを残す',
217
225
  commentPlaceholder: 'エージェントに依頼する変更を記述…',
218
- commentShortcutHint: '⌘↵ で追加',
226
+ commentShortcutHint: '⌘/ でフォーカス · ⌘↵ で追加',
219
227
  addComment: 'コメントを追加',
220
228
  unsavedChanges: {
221
229
  one: '未保存の変更 {count} 件',
@@ -284,6 +292,11 @@ export const ja: Locale = {
284
292
  deleteAssetTitle: 'アセットを削除',
285
293
  deleteAssetDescription:
286
294
  '{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: ';',
@@ -49,6 +49,11 @@ export 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;
@@ -92,6 +97,9 @@ export type Locale = {
92
97
  agentDisconnected: string;
93
98
  agentDisconnectedTooltip: string;
94
99
  download: string;
100
+ copyLink: string;
101
+ toastCopyLinkSuccess: string;
102
+ toastCopyLinkFailed: string;
95
103
  exportAsHtml: string;
96
104
  exportAsPdf: string;
97
105
  pdfExportFailed: string;
@@ -286,6 +294,14 @@ export type Locale = {
286
294
  deleteAssetTitle: string;
287
295
  /** template: "Delete {name}? Imports referencing this file in the slide will break." */
288
296
  deleteAssetDescription: string;
297
+ /** template: "{name} is used in {count} place across {slides} slide." (singular/plural via {count}/{slides}) */
298
+ deleteAssetInUseDescription: string;
299
+ deleteAssetInUseHint: string;
300
+ deleteAndRevert: string;
301
+ /** template: "Couldn't revert usage in {slideId}." */
302
+ toastRevertFailed: string;
303
+ /** template: "Deleted {name} and reverted {count} usage." */
304
+ toastDeletedWithRevert: string;
289
305
  noPreview: string;
290
306
  importHintComment: string;
291
307
  importHintSemi: string;
@@ -49,6 +49,11 @@ export const zhCN: Locale = {
49
49
  folderActions: '文件夹操作',
50
50
  searchPlaceholder: '搜索幻灯片',
51
51
  clearSearch: '清除搜索',
52
+ sortLabel: '排序',
53
+ sortByCreatedDesc: '最新',
54
+ sortByCreatedAsc: '最旧',
55
+ sortByTitleAsc: 'A–Z',
56
+ sortByTitleDesc: 'Z–A',
52
57
  noMatches: '没有匹配项',
53
58
  nothingMatchesPrefix: '该文件夹中没有匹配 ',
54
59
  nothingMatchesSuffix: ' 的内容。',
@@ -90,6 +95,9 @@ export const zhCN: Locale = {
90
95
  home: '首页',
91
96
  backToHome: '返回首页',
92
97
  download: '下载',
98
+ copyLink: '复制链接',
99
+ toastCopyLinkSuccess: '已复制链接到剪贴板',
100
+ toastCopyLinkFailed: '复制链接失败',
93
101
  exportAsHtml: '导出为 HTML',
94
102
  exportAsPdf: '导出为 PDF',
95
103
  pdfExportFailed: 'PDF 导出失败',
@@ -213,7 +221,7 @@ export const zhCN: Locale = {
213
221
  '已和 dev server 断开连接,agent 看不到你选的元素了。请重新启动 dev server 来恢复连接。',
214
222
  leaveComment: '留个 comment',
215
223
  commentPlaceholder: '描述你希望代理执行的更改…',
216
- commentShortcutHint: '⌘↵ 添加',
224
+ commentShortcutHint: '⌘/ 聚焦 · ⌘↵ 添加',
217
225
  addComment: '添加 comment',
218
226
  unsavedChanges: {
219
227
  one: '{count} 项未保存的更改',
@@ -280,6 +288,11 @@ export const zhCN: Locale = {
280
288
  conflictRenameCopy: '重命名副本',
281
289
  deleteAssetTitle: '删除素材',
282
290
  deleteAssetDescription: '要删除 {name} 吗?幻灯片中引用此文件的导入将失效。',
291
+ deleteAssetInUseDescription: '{name} 在 {slides} 个幻灯片中被使用了 {count} 次。',
292
+ deleteAssetInUseHint: '删除后这些使用处会自动还原为图片占位符。',
293
+ deleteAndRevert: '删除并还原',
294
+ toastRevertFailed: '无法还原 {slideId} 中的使用',
295
+ toastDeletedWithRevert: '已删除 {name} 并还原 {count} 个使用处',
283
296
  noPreview: '无预览',
284
297
  importHintComment: 'import asset from ',
285
298
  importHintSemi: ';',
@@ -49,6 +49,11 @@ export const zhTW: Locale = {
49
49
  folderActions: '資料夾操作',
50
50
  searchPlaceholder: '搜尋投影片',
51
51
  clearSearch: '清除搜尋',
52
+ sortLabel: '排序',
53
+ sortByCreatedDesc: '最新',
54
+ sortByCreatedAsc: '最舊',
55
+ sortByTitleAsc: 'A–Z',
56
+ sortByTitleDesc: 'Z–A',
52
57
  noMatches: '沒有相符結果',
53
58
  nothingMatchesPrefix: '此資料夾中沒有相符 ',
54
59
  nothingMatchesSuffix: ' 的項目。',
@@ -90,6 +95,9 @@ export const zhTW: Locale = {
90
95
  home: '首頁',
91
96
  backToHome: '返回首頁',
92
97
  download: '下載',
98
+ copyLink: '複製連結',
99
+ toastCopyLinkSuccess: '已複製連結到剪貼簿',
100
+ toastCopyLinkFailed: '複製連結失敗',
93
101
  exportAsHtml: '匯出為 HTML',
94
102
  exportAsPdf: '匯出為 PDF',
95
103
  pdfExportFailed: 'PDF 匯出失敗',
@@ -213,7 +221,7 @@ export const zhTW: Locale = {
213
221
  '已和 dev server 斷線,agent 看不到你選的元素了。請重新啟動 dev server 來恢復連線。',
214
222
  leaveComment: '留個 comment',
215
223
  commentPlaceholder: '描述你希望代理進行的修改…',
216
- commentShortcutHint: '⌘↵ 新增',
224
+ commentShortcutHint: '⌘/ 聚焦 · ⌘↵ 新增',
217
225
  addComment: '新增 comment',
218
226
  unsavedChanges: {
219
227
  one: '{count} 項未儲存的變更',
@@ -280,6 +288,11 @@ export const zhTW: Locale = {
280
288
  conflictRenameCopy: '重新命名副本',
281
289
  deleteAssetTitle: '刪除素材',
282
290
  deleteAssetDescription: '要刪除 {name} 嗎?投影片中引用此檔案的匯入將失效。',
291
+ deleteAssetInUseDescription: '{name} 在 {slides} 個投影片中被使用了 {count} 次。',
292
+ deleteAssetInUseHint: '刪除後這些使用處會自動還原為圖片占位符。',
293
+ deleteAndRevert: '刪除並還原',
294
+ toastRevertFailed: '無法還原 {slideId} 中的使用',
295
+ toastDeletedWithRevert: '已刪除 {name} 並還原 {count} 個使用處',
283
296
  noPreview: '無預覽',
284
297
  importHintComment: 'import asset from ',
285
298
  importHintSemi: ';',