@open-slide/core 0.0.2 → 0.0.4

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 (35) hide show
  1. package/bin.js +2 -0
  2. package/dist/{build-DJGuOT6x.js → build-CuoESF2g.js} +1 -1
  3. package/dist/cli/bin.js +5 -5
  4. package/dist/config-DF58h0l4.js +641 -0
  5. package/dist/{dev-0SG0ArzD.js → dev-rlOZacWo.js} +1 -1
  6. package/dist/index.d.ts +7 -9
  7. package/dist/{preview-61Aawrlg.js → preview-DCrD9X36.js} +1 -1
  8. package/dist/vite/index.js +1 -1
  9. package/package.json +7 -4
  10. package/src/app/App.tsx +2 -2
  11. package/src/app/components/ClickNavZones.tsx +34 -0
  12. package/src/app/components/Player.tsx +26 -7
  13. package/src/app/components/ThumbnailRail.tsx +5 -5
  14. package/src/app/components/inspector/CommentPopover.tsx +3 -11
  15. package/src/app/components/inspector/InspectOverlay.tsx +15 -4
  16. package/src/app/components/inspector/InspectorProvider.tsx +12 -5
  17. package/src/app/components/sidebar/FolderItem.tsx +188 -0
  18. package/src/app/components/sidebar/IconPicker.tsx +59 -0
  19. package/src/app/components/sidebar/Sidebar.tsx +118 -0
  20. package/src/app/components/ui/dialog.tsx +141 -0
  21. package/src/app/components/ui/dropdown-menu.tsx +228 -0
  22. package/src/app/components/ui/popover.tsx +72 -0
  23. package/src/app/components/ui/tabs.tsx +79 -0
  24. package/src/app/lib/export-html.ts +313 -0
  25. package/src/app/lib/folders.ts +166 -0
  26. package/src/app/lib/inspector/fiber.ts +2 -2
  27. package/src/app/lib/inspector/useComments.ts +8 -8
  28. package/src/app/lib/sdk.ts +18 -5
  29. package/src/app/lib/slides.ts +8 -0
  30. package/src/app/routes/Home.tsx +540 -63
  31. package/src/app/routes/Slide.tsx +298 -0
  32. package/src/app/virtual.d.ts +4 -4
  33. package/dist/config-Opp2R1Jf.js +0 -335
  34. package/src/app/lib/decks.ts +0 -8
  35. package/src/app/routes/Deck.tsx +0 -185
package/dist/index.d.ts CHANGED
@@ -1,17 +1,15 @@
1
1
  import { ComponentType } from "react";
2
2
 
3
3
  //#region src/app/lib/sdk.d.ts
4
- type SlidePage = ComponentType;
5
- type DeckMeta = {
4
+ type Page = ComponentType;
5
+ type SlideMeta = {
6
6
  title?: string;
7
7
  theme?: 'light' | 'dark';
8
8
  };
9
- type DeckModule = {
10
- default: SlidePage[];
11
- meta?: DeckMeta;
9
+ type SlideModule = {
10
+ default: Page[];
11
+ meta?: SlideMeta;
12
12
  };
13
13
  declare const CANVAS_WIDTH = 1920;
14
- declare const CANVAS_HEIGHT = 1080;
15
-
16
- //#endregion
17
- export { CANVAS_HEIGHT, CANVAS_WIDTH, DeckMeta, DeckModule, SlidePage };
14
+ declare const CANVAS_HEIGHT = 1080; //#endregion
15
+ export { CANVAS_HEIGHT, CANVAS_WIDTH, Page, SlideMeta, SlideModule };
@@ -1,4 +1,4 @@
1
- import { createViteConfig } from "./config-Opp2R1Jf.js";
1
+ import { createViteConfig } from "./config-DF58h0l4.js";
2
2
  import { preview as preview$1 } from "vite";
3
3
 
4
4
  //#region src/cli/preview.ts
@@ -1,3 +1,3 @@
1
- import { createViteConfig } from "../config-Opp2R1Jf.js";
1
+ import { createViteConfig } from "../config-DF58h0l4.js";
2
2
 
3
3
  export { createViteConfig };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@open-slide/core",
3
- "version": "0.0.2",
4
- "description": "Runtime and CLI for open-slide — write decks in slides/, we handle the rest.",
3
+ "version": "0.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": {
7
7
  ".": {
@@ -14,16 +14,17 @@
14
14
  }
15
15
  },
16
16
  "bin": {
17
- "open-slide": "./dist/cli/bin.js"
17
+ "open-slide": "./bin.js"
18
18
  },
19
19
  "files": [
20
+ "bin.js",
20
21
  "dist",
21
22
  "src/app",
22
23
  "README.md"
23
24
  ],
24
25
  "scripts": {
25
26
  "build": "tsdown",
26
- "check": "tsc --noEmit",
27
+ "typecheck": "tsc --noEmit",
27
28
  "prepack": "pnpm build"
28
29
  },
29
30
  "engines": {
@@ -45,7 +46,9 @@
45
46
  "@vitejs/plugin-react": "^4.3.3",
46
47
  "class-variance-authority": "^0.7.1",
47
48
  "clsx": "^2.1.1",
49
+ "emoji-picker-react": "^4.18.0",
48
50
  "fast-glob": "^3.3.2",
51
+ "fflate": "^0.8.2",
49
52
  "lucide-react": "^1.8.0",
50
53
  "radix-ui": "^1.4.3",
51
54
  "react": "^18.3.1",
package/src/app/App.tsx CHANGED
@@ -1,13 +1,13 @@
1
1
  import { BrowserRouter, Route, Routes } from 'react-router-dom';
2
2
  import { Home } from './routes/Home';
3
- import { Deck } from './routes/Deck';
3
+ import { Slide } from './routes/Slide';
4
4
 
5
5
  export function App() {
6
6
  return (
7
7
  <BrowserRouter>
8
8
  <Routes>
9
9
  <Route path="/" element={<Home />} />
10
- <Route path="/d/:deckId" element={<Deck />} />
10
+ <Route path="/s/:slideId" element={<Slide />} />
11
11
  </Routes>
12
12
  </BrowserRouter>
13
13
  );
@@ -0,0 +1,34 @@
1
+ import { useInspector } from './inspector/InspectorProvider';
2
+
3
+ type Props = {
4
+ onPrev: () => void;
5
+ onNext: () => void;
6
+ canPrev: boolean;
7
+ canNext: boolean;
8
+ };
9
+
10
+ export function ClickNavZones({ onPrev, onNext, canPrev, canNext }: Props) {
11
+ const { active } = useInspector();
12
+ if (active) return null;
13
+
14
+ return (
15
+ <>
16
+ <button
17
+ type="button"
18
+ aria-label="Previous page"
19
+ onClick={onPrev}
20
+ disabled={!canPrev}
21
+ data-inspector-ui
22
+ className="absolute inset-y-0 left-0 z-20 w-[18%] min-w-12"
23
+ />
24
+ <button
25
+ type="button"
26
+ aria-label="Next page"
27
+ onClick={onNext}
28
+ disabled={!canNext}
29
+ data-inspector-ui
30
+ className="absolute inset-y-0 right-0 z-20 w-[18%] min-w-12"
31
+ />
32
+ </>
33
+ );
34
+ }
@@ -1,9 +1,9 @@
1
1
  import { useEffect, useRef } from 'react';
2
- import type { SlidePage } from '../lib/sdk';
2
+ import type { Page } from '../lib/sdk';
3
3
  import { SlideCanvas } from './SlideCanvas';
4
4
 
5
5
  type Props = {
6
- pages: SlidePage[];
6
+ pages: Page[];
7
7
  index: number;
8
8
  onIndexChange: (index: number) => void;
9
9
  onExit: () => void;
@@ -33,10 +33,15 @@ export function Player({ pages, index, onIndexChange, onExit }: Props) {
33
33
 
34
34
  useEffect(() => {
35
35
  const onKey = (e: KeyboardEvent) => {
36
- if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'PageDown') {
36
+ if (
37
+ e.key === 'ArrowRight' ||
38
+ e.key === 'ArrowDown' ||
39
+ e.key === ' ' ||
40
+ e.key === 'PageDown'
41
+ ) {
37
42
  e.preventDefault();
38
43
  if (index < pages.length - 1) onIndexChange(index + 1);
39
- } else if (e.key === 'ArrowLeft' || e.key === 'PageUp') {
44
+ } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp' || e.key === 'PageUp') {
40
45
  e.preventDefault();
41
46
  if (index > 0) onIndexChange(index - 1);
42
47
  } else if (e.key === 'Escape') {
@@ -51,11 +56,25 @@ export function Player({ pages, index, onIndexChange, onExit }: Props) {
51
56
  return () => window.removeEventListener('keydown', onKey);
52
57
  }, [index, pages.length, onIndexChange, onExit]);
53
58
 
54
- const Page = pages[index];
59
+ const PageComp = pages[index];
55
60
 
56
61
  return (
57
- <div ref={rootRef} className="flex h-screen w-screen items-center justify-center bg-black">
58
- <SlideCanvas flat>{Page ? <Page /> : null}</SlideCanvas>
62
+ <div ref={rootRef} className="relative flex h-screen w-screen items-center justify-center bg-black">
63
+ <SlideCanvas flat>{PageComp ? <PageComp /> : null}</SlideCanvas>
64
+ <button
65
+ type="button"
66
+ aria-label="Previous page"
67
+ onClick={() => index > 0 && onIndexChange(index - 1)}
68
+ disabled={index === 0}
69
+ className="absolute inset-y-0 left-0 z-10 w-[30%]"
70
+ />
71
+ <button
72
+ type="button"
73
+ aria-label="Next page"
74
+ onClick={() => index < pages.length - 1 && onIndexChange(index + 1)}
75
+ disabled={index === pages.length - 1}
76
+ className="absolute inset-y-0 right-0 z-10 w-[30%]"
77
+ />
59
78
  </div>
60
79
  );
61
80
  }
@@ -1,11 +1,11 @@
1
1
  import { cn } from '@/lib/utils';
2
2
  import { ScrollArea } from '@/components/ui/scroll-area';
3
- import type { SlidePage } from '../lib/sdk';
3
+ import type { Page } from '../lib/sdk';
4
4
  import { SlideCanvas } from './SlideCanvas';
5
5
  import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../lib/sdk';
6
6
 
7
7
  type Props = {
8
- pages: SlidePage[];
8
+ pages: Page[];
9
9
  current: number;
10
10
  onSelect: (index: number) => void;
11
11
  };
@@ -18,13 +18,13 @@ export function ThumbnailRail({ pages, current, onSelect }: Props) {
18
18
  return (
19
19
  <ScrollArea className="h-full border-r bg-card">
20
20
  <aside className="flex flex-col gap-2.5 p-3">
21
- {pages.map((Page, i) => {
21
+ {pages.map((PageComp, i) => {
22
22
  const active = i === current;
23
23
  return (
24
24
  <button
25
25
  key={i}
26
26
  onClick={() => onSelect(i)}
27
- aria-label={`Go to slide ${i + 1}`}
27
+ aria-label={`Go to page ${i + 1}`}
28
28
  aria-current={active ? 'true' : undefined}
29
29
  className={cn(
30
30
  'flex items-center gap-2.5 rounded-lg border-2 border-transparent p-1.5 text-left transition-colors',
@@ -45,7 +45,7 @@ export function ThumbnailRail({ pages, current, onSelect }: Props) {
45
45
  style={{ width: THUMB_WIDTH, height: THUMB_HEIGHT }}
46
46
  >
47
47
  <SlideCanvas scale={THUMB_SCALE} center={false} flat>
48
- <Page />
48
+ <PageComp />
49
49
  </SlideCanvas>
50
50
  </div>
51
51
  </button>
@@ -6,7 +6,7 @@ const POPOVER_W = 320;
6
6
  const POPOVER_H = 180;
7
7
 
8
8
  export function CommentPopover() {
9
- const { pending, setPending, add } = useInspector();
9
+ const { pending, setPending, add, cancel } = useInspector();
10
10
  const [text, setText] = useState('');
11
11
  const [submitting, setSubmitting] = useState(false);
12
12
  const [error, setError] = useState<string | null>(null);
@@ -16,14 +16,6 @@ export function CommentPopover() {
16
16
  taRef.current?.focus();
17
17
  }, []);
18
18
 
19
- useEffect(() => {
20
- const onKey = (e: KeyboardEvent) => {
21
- if (e.key === 'Escape') setPending(null);
22
- };
23
- window.addEventListener('keydown', onKey);
24
- return () => window.removeEventListener('keydown', onKey);
25
- }, [setPending]);
26
-
27
19
  if (!pending) return null;
28
20
 
29
21
  const left = clamp(pending.clickX + 12, 8, window.innerWidth - POPOVER_W - 8);
@@ -55,7 +47,7 @@ export function CommentPopover() {
55
47
  <button
56
48
  type="button"
57
49
  className="text-xs text-muted-foreground hover:text-foreground"
58
- onClick={() => setPending(null)}
50
+ onClick={cancel}
59
51
  >
60
52
 
61
53
  </button>
@@ -77,7 +69,7 @@ export function CommentPopover() {
77
69
  <div className="mt-2 flex items-center justify-end gap-2">
78
70
  <button
79
71
  type="button"
80
- onClick={() => setPending(null)}
72
+ onClick={cancel}
81
73
  className="rounded border px-2 py-1 text-xs hover:bg-muted"
82
74
  >
83
75
  Cancel
@@ -6,7 +6,7 @@ import { useInspector } from './InspectorProvider';
6
6
  type Highlight = { rect: DOMRect; hit: SlideSourceHit };
7
7
 
8
8
  export function InspectOverlay() {
9
- const { active, deckId, pending, setPending } = useInspector();
9
+ const { active, slideId, pending, setPending, cancel } = useInspector();
10
10
  const overlayRef = useRef<HTMLDivElement>(null);
11
11
  const [hover, setHover] = useState<Highlight | null>(null);
12
12
 
@@ -16,11 +16,19 @@ export function InspectOverlay() {
16
16
  return;
17
17
  }
18
18
 
19
+ const onKey = (e: KeyboardEvent) => {
20
+ if (e.key === 'Escape') {
21
+ e.preventDefault();
22
+ e.stopPropagation();
23
+ cancel();
24
+ }
25
+ };
26
+
19
27
  const onMove = (e: PointerEvent) => {
20
28
  if (pending) return;
21
29
  const el = pickElement(e.clientX, e.clientY);
22
30
  if (!el) return setHover(null);
23
- const hit = findSlideSource(el, deckId);
31
+ const hit = findSlideSource(el, slideId);
24
32
  if (!hit) return setHover(null);
25
33
  setHover({ rect: hit.anchor.getBoundingClientRect(), hit });
26
34
  };
@@ -30,7 +38,7 @@ export function InspectOverlay() {
30
38
  if (e.target instanceof Element && e.target.closest('[data-inspector-ui]')) return;
31
39
  const el = pickElement(e.clientX, e.clientY);
32
40
  if (!el) return;
33
- const hit = findSlideSource(el, deckId);
41
+ const hit = findSlideSource(el, slideId);
34
42
  if (!hit) return;
35
43
  e.preventDefault();
36
44
  e.stopPropagation();
@@ -46,11 +54,13 @@ export function InspectOverlay() {
46
54
 
47
55
  window.addEventListener('pointermove', onMove, true);
48
56
  window.addEventListener('click', onClick, true);
57
+ window.addEventListener('keydown', onKey, true);
49
58
  return () => {
50
59
  window.removeEventListener('pointermove', onMove, true);
51
60
  window.removeEventListener('click', onClick, true);
61
+ window.removeEventListener('keydown', onKey, true);
52
62
  };
53
- }, [active, deckId, pending, setPending]);
63
+ }, [active, slideId, pending, setPending, cancel]);
54
64
 
55
65
  if (!active) return null;
56
66
 
@@ -88,6 +98,7 @@ function pickElement(x: number, y: number): HTMLElement | null {
88
98
  for (const el of stack) {
89
99
  if (!(el instanceof HTMLElement)) continue;
90
100
  if (el.closest('[data-inspector-ui]')) continue;
101
+ if (!el.closest('[data-inspector-root]')) continue;
91
102
  return el;
92
103
  }
93
104
  return null;
@@ -12,9 +12,10 @@ export type PendingTarget = {
12
12
  };
13
13
 
14
14
  type InspectorCtx = {
15
- deckId: string;
15
+ slideId: string;
16
16
  active: boolean;
17
17
  toggle: () => void;
18
+ cancel: () => void;
18
19
  comments: SlideComment[];
19
20
  error: string | null;
20
21
  refetch: () => Promise<void>;
@@ -32,10 +33,10 @@ export function useInspector(): InspectorCtx {
32
33
  return v;
33
34
  }
34
35
 
35
- export function InspectorProvider({ deckId, children }: { deckId: string; children: ReactNode }) {
36
+ export function InspectorProvider({ slideId, children }: { slideId: string; children: ReactNode }) {
36
37
  const [active, setActive] = useState(false);
37
38
  const [pending, setPending] = useState<PendingTarget | null>(null);
38
- const { comments, error, refetch, add, remove } = useComments(deckId);
39
+ const { comments, error, refetch, add, remove } = useComments(slideId);
39
40
 
40
41
  const toggle = useCallback(() => {
41
42
  setActive((a) => {
@@ -44,11 +45,17 @@ export function InspectorProvider({ deckId, children }: { deckId: string; childr
44
45
  });
45
46
  }, []);
46
47
 
48
+ const cancel = useCallback(() => {
49
+ setActive(false);
50
+ setPending(null);
51
+ }, []);
52
+
47
53
  const value = useMemo<InspectorCtx>(
48
54
  () => ({
49
- deckId,
55
+ slideId,
50
56
  active,
51
57
  toggle,
58
+ cancel,
52
59
  comments,
53
60
  error,
54
61
  refetch,
@@ -57,7 +64,7 @@ export function InspectorProvider({ deckId, children }: { deckId: string; childr
57
64
  pending,
58
65
  setPending,
59
66
  }),
60
- [deckId, active, toggle, comments, error, refetch, add, remove, pending],
67
+ [slideId, active, toggle, cancel, comments, error, refetch, add, remove, pending],
61
68
  );
62
69
 
63
70
  return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
@@ -0,0 +1,188 @@
1
+ import { MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
2
+ import { useState } from 'react';
3
+ import type { Folder, FolderIcon } from '@/lib/sdk';
4
+ import { cn } from '@/lib/utils';
5
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuItem,
10
+ DropdownMenuTrigger,
11
+ } from '@/components/ui/dropdown-menu';
12
+ import { IconPicker } from './IconPicker';
13
+
14
+ export const SLIDE_DND_MIME = 'application/x-slide-id';
15
+
16
+ export function FolderIconChip({ icon, className }: { icon: FolderIcon; className?: string }) {
17
+ if (icon.type === 'emoji') {
18
+ return (
19
+ <span
20
+ className={cn(
21
+ 'inline-flex size-5 items-center justify-center text-base leading-none',
22
+ className,
23
+ )}
24
+ >
25
+ {icon.value}
26
+ </span>
27
+ );
28
+ }
29
+ return (
30
+ <span
31
+ className={cn('inline-block size-4 rounded-[4px] ring-1 ring-black/10', className)}
32
+ style={{ background: icon.value }}
33
+ />
34
+ );
35
+ }
36
+
37
+ type Row =
38
+ | {
39
+ kind: 'folder';
40
+ folder: Folder;
41
+ onRename: (name: string) => void;
42
+ onChangeIcon: (icon: FolderIcon) => void;
43
+ onDelete: () => void;
44
+ }
45
+ | {
46
+ kind: 'draft';
47
+ };
48
+
49
+ export function FolderItem({
50
+ row,
51
+ count,
52
+ selected,
53
+ onSelect,
54
+ onDropSlide,
55
+ }: {
56
+ row: Row;
57
+ count: number;
58
+ selected: boolean;
59
+ onSelect: () => void;
60
+ onDropSlide: (slideId: string) => void;
61
+ }) {
62
+ const [renaming, setRenaming] = useState(false);
63
+ const [dragOver, setDragOver] = useState(false);
64
+ const [draftName, setDraftName] = useState(row.kind === 'folder' ? row.folder.name : '');
65
+
66
+ const handleDragOver = (e: React.DragEvent) => {
67
+ if (e.dataTransfer.types.includes(SLIDE_DND_MIME)) {
68
+ e.preventDefault();
69
+ e.dataTransfer.dropEffect = 'move';
70
+ setDragOver(true);
71
+ }
72
+ };
73
+ const handleDragLeave = () => setDragOver(false);
74
+ const handleDrop = (e: React.DragEvent) => {
75
+ const slideId = e.dataTransfer.getData(SLIDE_DND_MIME);
76
+ setDragOver(false);
77
+ if (!slideId) return;
78
+ e.preventDefault();
79
+ onDropSlide(slideId);
80
+ };
81
+
82
+ const icon =
83
+ row.kind === 'draft' ? ({ type: 'emoji', value: '📝' } satisfies FolderIcon) : row.folder.icon;
84
+ const label = row.kind === 'draft' ? 'Draft' : row.folder.name;
85
+
86
+ const commitRename = () => {
87
+ if (row.kind !== 'folder') return;
88
+ const trimmed = draftName.trim();
89
+ if (trimmed && trimmed !== row.folder.name) row.onRename(trimmed);
90
+ setRenaming(false);
91
+ };
92
+
93
+ return (
94
+ <div
95
+ className={cn(
96
+ 'group relative flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
97
+ selected ? 'bg-muted text-foreground' : 'text-foreground/80 hover:bg-muted/60',
98
+ dragOver && 'ring-2 ring-primary ring-offset-1 ring-offset-background',
99
+ )}
100
+ onDragOver={handleDragOver}
101
+ onDragLeave={handleDragLeave}
102
+ onDrop={handleDrop}
103
+ >
104
+ {row.kind === 'folder' ? (
105
+ <Popover>
106
+ <PopoverTrigger asChild>
107
+ <button
108
+ type="button"
109
+ className="flex size-5 shrink-0 items-center justify-center rounded transition-transform hover:scale-110"
110
+ aria-label="Change icon"
111
+ onClick={(e) => e.stopPropagation()}
112
+ >
113
+ <FolderIconChip icon={icon} />
114
+ </button>
115
+ </PopoverTrigger>
116
+ <PopoverContent side="right" align="start" className="w-auto p-2">
117
+ <IconPicker value={row.folder.icon} onChange={(next) => row.onChangeIcon(next)} />
118
+ </PopoverContent>
119
+ </Popover>
120
+ ) : (
121
+ <span className="flex size-5 shrink-0 items-center justify-center">
122
+ <FolderIconChip icon={icon} />
123
+ </span>
124
+ )}
125
+
126
+ {renaming && row.kind === 'folder' ? (
127
+ <input
128
+ autoFocus
129
+ value={draftName}
130
+ onChange={(e) => setDraftName(e.target.value)}
131
+ onBlur={commitRename}
132
+ onKeyDown={(e) => {
133
+ if (e.key === 'Enter') commitRename();
134
+ if (e.key === 'Escape') {
135
+ setDraftName(row.folder.name);
136
+ setRenaming(false);
137
+ }
138
+ }}
139
+ maxLength={40}
140
+ className="min-w-0 flex-1 rounded-sm bg-background px-1 text-sm outline-none ring-1 ring-ring/40"
141
+ />
142
+ ) : (
143
+ <button type="button" onClick={onSelect} className="min-w-0 flex-1 truncate text-left">
144
+ {label}
145
+ </button>
146
+ )}
147
+
148
+ <span
149
+ className={cn(
150
+ 'shrink-0 text-xs tabular-nums text-muted-foreground',
151
+ count === 0 && 'opacity-0 group-hover:opacity-100',
152
+ )}
153
+ >
154
+ {count}
155
+ </span>
156
+
157
+ {row.kind === 'folder' && (
158
+ <DropdownMenu>
159
+ <DropdownMenuTrigger asChild>
160
+ <button
161
+ type="button"
162
+ onClick={(e) => e.stopPropagation()}
163
+ className="size-5 shrink-0 rounded opacity-0 transition-opacity hover:bg-muted-foreground/10 group-hover:opacity-100 aria-expanded:opacity-100"
164
+ aria-label="Folder actions"
165
+ >
166
+ <MoreHorizontal className="mx-auto size-3.5" />
167
+ </button>
168
+ </DropdownMenuTrigger>
169
+ <DropdownMenuContent align="end" className="min-w-[140px]">
170
+ <DropdownMenuItem
171
+ onSelect={() => {
172
+ setDraftName(row.folder.name);
173
+ setRenaming(true);
174
+ }}
175
+ >
176
+ <Pencil />
177
+ Rename
178
+ </DropdownMenuItem>
179
+ <DropdownMenuItem variant="destructive" onSelect={() => row.onDelete()}>
180
+ <Trash2 />
181
+ Delete
182
+ </DropdownMenuItem>
183
+ </DropdownMenuContent>
184
+ </DropdownMenu>
185
+ )}
186
+ </div>
187
+ );
188
+ }
@@ -0,0 +1,59 @@
1
+ import EmojiPicker, { EmojiStyle, Theme } from 'emoji-picker-react';
2
+ import type { FolderIcon } from '@/lib/sdk';
3
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
4
+
5
+ export const PRESET_COLORS = [
6
+ '#8b5cf6',
7
+ '#6366f1',
8
+ '#3b82f6',
9
+ '#10b981',
10
+ '#f59e0b',
11
+ '#ef4444',
12
+ '#ec4899',
13
+ '#64748b',
14
+ ];
15
+
16
+ export function IconPicker({
17
+ value,
18
+ onChange,
19
+ }: {
20
+ value: FolderIcon;
21
+ onChange: (icon: FolderIcon) => void;
22
+ }) {
23
+ return (
24
+ <Tabs defaultValue={value.type} className="w-[320px]">
25
+ <TabsList className="w-full">
26
+ <TabsTrigger value="emoji">Emoji</TabsTrigger>
27
+ <TabsTrigger value="color">Color</TabsTrigger>
28
+ </TabsList>
29
+
30
+ <TabsContent value="emoji">
31
+ <EmojiPicker
32
+ lazyLoadEmojis
33
+ emojiStyle={EmojiStyle.NATIVE}
34
+ theme={Theme.AUTO}
35
+ width="100%"
36
+ height={360}
37
+ onEmojiClick={(data) => onChange({ type: 'emoji', value: data.emoji })}
38
+ previewConfig={{ showPreview: false }}
39
+ skinTonesDisabled
40
+ />
41
+ </TabsContent>
42
+
43
+ <TabsContent value="color">
44
+ <div className="grid grid-cols-8 gap-1.5 py-2">
45
+ {PRESET_COLORS.map((c) => (
46
+ <button
47
+ key={c}
48
+ type="button"
49
+ onClick={() => onChange({ type: 'color', value: c })}
50
+ className="size-6 rounded-md ring-1 ring-black/10 transition-transform hover:scale-110"
51
+ style={{ background: c }}
52
+ aria-label={c}
53
+ />
54
+ ))}
55
+ </div>
56
+ </TabsContent>
57
+ </Tabs>
58
+ );
59
+ }