@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.
- package/bin.js +2 -0
- package/dist/{build-DJGuOT6x.js → build-CuoESF2g.js} +1 -1
- package/dist/cli/bin.js +5 -5
- package/dist/config-DF58h0l4.js +641 -0
- package/dist/{dev-0SG0ArzD.js → dev-rlOZacWo.js} +1 -1
- package/dist/index.d.ts +7 -9
- package/dist/{preview-61Aawrlg.js → preview-DCrD9X36.js} +1 -1
- package/dist/vite/index.js +1 -1
- package/package.json +7 -4
- package/src/app/App.tsx +2 -2
- package/src/app/components/ClickNavZones.tsx +34 -0
- package/src/app/components/Player.tsx +26 -7
- package/src/app/components/ThumbnailRail.tsx +5 -5
- package/src/app/components/inspector/CommentPopover.tsx +3 -11
- package/src/app/components/inspector/InspectOverlay.tsx +15 -4
- package/src/app/components/inspector/InspectorProvider.tsx +12 -5
- package/src/app/components/sidebar/FolderItem.tsx +188 -0
- package/src/app/components/sidebar/IconPicker.tsx +59 -0
- package/src/app/components/sidebar/Sidebar.tsx +118 -0
- package/src/app/components/ui/dialog.tsx +141 -0
- package/src/app/components/ui/dropdown-menu.tsx +228 -0
- package/src/app/components/ui/popover.tsx +72 -0
- package/src/app/components/ui/tabs.tsx +79 -0
- package/src/app/lib/export-html.ts +313 -0
- package/src/app/lib/folders.ts +166 -0
- package/src/app/lib/inspector/fiber.ts +2 -2
- package/src/app/lib/inspector/useComments.ts +8 -8
- package/src/app/lib/sdk.ts +18 -5
- package/src/app/lib/slides.ts +8 -0
- package/src/app/routes/Home.tsx +540 -63
- package/src/app/routes/Slide.tsx +298 -0
- package/src/app/virtual.d.ts +4 -4
- package/dist/config-Opp2R1Jf.js +0 -335
- package/src/app/lib/decks.ts +0 -8
- 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
|
|
5
|
-
type
|
|
4
|
+
type Page = ComponentType;
|
|
5
|
+
type SlideMeta = {
|
|
6
6
|
title?: string;
|
|
7
7
|
theme?: 'light' | 'dark';
|
|
8
8
|
};
|
|
9
|
-
type
|
|
10
|
-
default:
|
|
11
|
-
meta?:
|
|
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 };
|
package/dist/vite/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-slide/core",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Runtime and CLI for open-slide — write
|
|
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": "./
|
|
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
|
-
"
|
|
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 {
|
|
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="/
|
|
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 {
|
|
2
|
+
import type { Page } from '../lib/sdk';
|
|
3
3
|
import { SlideCanvas } from './SlideCanvas';
|
|
4
4
|
|
|
5
5
|
type Props = {
|
|
6
|
-
pages:
|
|
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 (
|
|
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
|
|
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>{
|
|
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 {
|
|
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:
|
|
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((
|
|
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
|
|
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
|
-
<
|
|
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={
|
|
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={
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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({
|
|
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(
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
+
}
|