@open-aippt/core 1.13.2
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/LICENSE +21 -0
- package/README.md +98 -0
- package/bin.js +2 -0
- package/dist/build-DxTqmvsO.js +17 -0
- package/dist/cli/bin.d.ts +1 -0
- package/dist/cli/bin.js +86 -0
- package/dist/config-CjzqjrEA.js +4280 -0
- package/dist/config-DIC-yVPp.d.ts +23 -0
- package/dist/design-cpzS8aud.js +35 -0
- package/dist/dev-BYuTeJbA.js +20 -0
- package/dist/format-BCeKbTOM.js +1605 -0
- package/dist/index.d.ts +134 -0
- package/dist/index.js +467 -0
- package/dist/locale/index.d.ts +24 -0
- package/dist/locale/index.js +3 -0
- package/dist/preview-DlQvnJPq.js +18 -0
- package/dist/sync-BPZ0m27m.js +139 -0
- package/dist/sync-EsYusbbL.js +3 -0
- package/dist/types-CHmFPIG_.d.ts +430 -0
- package/dist/vite/index.d.ts +14 -0
- package/dist/vite/index.js +4 -0
- package/env.d.ts +59 -0
- package/package.json +103 -0
- package/skills/apply-comments/SKILL.md +83 -0
- package/skills/create-slide/SKILL.md +91 -0
- package/skills/create-theme/SKILL.md +250 -0
- package/skills/current-slide/SKILL.md +110 -0
- package/skills/slide-authoring/SKILL.md +625 -0
- package/src/app/app.tsx +47 -0
- package/src/app/components/asset-view.tsx +966 -0
- package/src/app/components/history-provider.tsx +120 -0
- package/src/app/components/image-placeholder.tsx +243 -0
- package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
- package/src/app/components/inspector/comment-widget.tsx +93 -0
- package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
- package/src/app/components/inspector/inspect-overlay.tsx +387 -0
- package/src/app/components/inspector/inspector-panel.tsx +1115 -0
- package/src/app/components/inspector/inspector-provider.tsx +1218 -0
- package/src/app/components/inspector/save-bar.tsx +48 -0
- package/src/app/components/language-toggle.tsx +39 -0
- package/src/app/components/notes-drawer.tsx +120 -0
- package/src/app/components/overview-grid.tsx +363 -0
- package/src/app/components/panel/panel-fields.tsx +60 -0
- package/src/app/components/panel/panel-shell.tsx +80 -0
- package/src/app/components/panel/save-card.tsx +142 -0
- package/src/app/components/pdf-progress-toast.tsx +32 -0
- package/src/app/components/player.tsx +466 -0
- package/src/app/components/pptx-progress-toast.tsx +32 -0
- package/src/app/components/present/blackout-overlay.tsx +18 -0
- package/src/app/components/present/control-bar.tsx +315 -0
- package/src/app/components/present/help-overlay.tsx +57 -0
- package/src/app/components/present/jump-input.tsx +74 -0
- package/src/app/components/present/laser-pointer.tsx +39 -0
- package/src/app/components/present/progress-bar.tsx +26 -0
- package/src/app/components/present/use-idle.ts +46 -0
- package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
- package/src/app/components/present/use-presenter-channel.ts +66 -0
- package/src/app/components/present/use-touch-swipe.ts +66 -0
- package/src/app/components/shared-element.tsx +48 -0
- package/src/app/components/sidebar/folder-item.tsx +258 -0
- package/src/app/components/sidebar/icon-picker.tsx +61 -0
- package/src/app/components/sidebar/mobile-pill.tsx +34 -0
- package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
- package/src/app/components/sidebar/sidebar.tsx +284 -0
- package/src/app/components/slide-canvas.tsx +102 -0
- package/src/app/components/slide-transition-layer.tsx +844 -0
- package/src/app/components/style-panel/design-provider.tsx +148 -0
- package/src/app/components/style-panel/style-panel.tsx +349 -0
- package/src/app/components/style-panel/use-design.ts +112 -0
- package/src/app/components/theme-toggle.tsx +59 -0
- package/src/app/components/themes/theme-detail.tsx +305 -0
- package/src/app/components/themes/themes-gallery.tsx +149 -0
- package/src/app/components/thumbnail-rail.tsx +805 -0
- package/src/app/components/ui/badge.tsx +45 -0
- package/src/app/components/ui/button.tsx +99 -0
- package/src/app/components/ui/card.tsx +92 -0
- package/src/app/components/ui/context-menu.tsx +237 -0
- package/src/app/components/ui/dialog.tsx +157 -0
- package/src/app/components/ui/dropdown-menu.tsx +245 -0
- package/src/app/components/ui/input.tsx +25 -0
- package/src/app/components/ui/label.tsx +24 -0
- package/src/app/components/ui/popover.tsx +75 -0
- package/src/app/components/ui/progress.tsx +31 -0
- package/src/app/components/ui/scroll-area.tsx +53 -0
- package/src/app/components/ui/select.tsx +196 -0
- package/src/app/components/ui/separator.tsx +28 -0
- package/src/app/components/ui/slider.tsx +61 -0
- package/src/app/components/ui/sonner.tsx +48 -0
- package/src/app/components/ui/tabs.tsx +79 -0
- package/src/app/components/ui/textarea.tsx +22 -0
- package/src/app/components/ui/toggle-group.tsx +83 -0
- package/src/app/components/ui/toggle.tsx +45 -0
- package/src/app/components/ui/tooltip.tsx +58 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/index.html +13 -0
- package/src/app/lib/assets.ts +242 -0
- package/src/app/lib/design-presets.ts +94 -0
- package/src/app/lib/design.ts +58 -0
- package/src/app/lib/export-html.ts +326 -0
- package/src/app/lib/export-pdf.ts +298 -0
- package/src/app/lib/export-pptx.ts +284 -0
- package/src/app/lib/folders.ts +239 -0
- package/src/app/lib/inspector/fiber.test.ts +154 -0
- package/src/app/lib/inspector/fiber.ts +85 -0
- package/src/app/lib/inspector/use-comments.ts +74 -0
- package/src/app/lib/inspector/use-editor.ts +73 -0
- package/src/app/lib/inspector/use-notes.ts +134 -0
- package/src/app/lib/locale-store.ts +67 -0
- package/src/app/lib/page-context.tsx +38 -0
- package/src/app/lib/print-ready.test.ts +32 -0
- package/src/app/lib/print-ready.ts +51 -0
- package/src/app/lib/sdk.test.ts +13 -0
- package/src/app/lib/sdk.ts +37 -0
- package/src/app/lib/slides.ts +26 -0
- package/src/app/lib/step-context.tsx +261 -0
- package/src/app/lib/themes.ts +22 -0
- package/src/app/lib/transition.ts +30 -0
- package/src/app/lib/use-agent-socket.ts +18 -0
- package/src/app/lib/use-click-page-navigation.ts +60 -0
- package/src/app/lib/use-is-mobile.ts +21 -0
- package/src/app/lib/use-locale.ts +8 -0
- package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
- package/src/app/lib/use-slide-module.ts +48 -0
- package/src/app/lib/use-wheel-page-navigation.ts +99 -0
- package/src/app/lib/utils.test.ts +25 -0
- package/src/app/lib/utils.ts +6 -0
- package/src/app/main.tsx +14 -0
- package/src/app/routes/assets.tsx +9 -0
- package/src/app/routes/home-shell.tsx +213 -0
- package/src/app/routes/home.tsx +807 -0
- package/src/app/routes/presenter.tsx +418 -0
- package/src/app/routes/slide.tsx +1108 -0
- package/src/app/routes/themes.tsx +34 -0
- package/src/app/styles.css +429 -0
- package/src/app/virtual.d.ts +51 -0
- package/src/locale/en.ts +416 -0
- package/src/locale/format.ts +12 -0
- package/src/locale/index.ts +6 -0
- package/src/locale/ja.ts +422 -0
- package/src/locale/types.ts +443 -0
- package/src/locale/zh-cn.ts +414 -0
- package/src/locale/zh-tw.ts +414 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Page } from './sdk';
|
|
2
|
+
|
|
3
|
+
export type TransitionPhase = {
|
|
4
|
+
keyframes: Keyframe[] | PropertyIndexedKeyframes;
|
|
5
|
+
easing?: string;
|
|
6
|
+
duration?: number;
|
|
7
|
+
delay?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type SlideTransition = {
|
|
11
|
+
duration: number;
|
|
12
|
+
easing?: string;
|
|
13
|
+
enter?: TransitionPhase;
|
|
14
|
+
exit?: TransitionPhase;
|
|
15
|
+
sharedElements?: boolean | SharedElementTransition;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type SharedElementTransition = {
|
|
19
|
+
duration?: number;
|
|
20
|
+
easing?: string;
|
|
21
|
+
delay?: number;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function resolveTransition(
|
|
25
|
+
pages: Page[],
|
|
26
|
+
index: number,
|
|
27
|
+
moduleDefault?: SlideTransition,
|
|
28
|
+
): SlideTransition | undefined {
|
|
29
|
+
return pages[index]?.transition ?? moduleDefault;
|
|
30
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
export function useAgentSocketConnected() {
|
|
4
|
+
const [connected, setConnected] = useState(true);
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
const hot = import.meta.hot;
|
|
7
|
+
if (!hot) return;
|
|
8
|
+
const onConnect = () => setConnected(true);
|
|
9
|
+
const onDisconnect = () => setConnected(false);
|
|
10
|
+
hot.on('vite:ws:connect', onConnect);
|
|
11
|
+
hot.on('vite:ws:disconnect', onDisconnect);
|
|
12
|
+
return () => {
|
|
13
|
+
hot.off('vite:ws:connect', onConnect);
|
|
14
|
+
hot.off('vite:ws:disconnect', onDisconnect);
|
|
15
|
+
};
|
|
16
|
+
}, []);
|
|
17
|
+
return connected;
|
|
18
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type RefObject, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
// Clicks that land on (or inside) these never navigate — interactive slide
|
|
4
|
+
// content keeps its click, and present chrome is excluded via data-osd-chrome.
|
|
5
|
+
// Authors can opt any element out with a data-osd-interactive attribute.
|
|
6
|
+
const NAV_PASSTHROUGH =
|
|
7
|
+
'a, button, input, textarea, select, label, summary, iframe, video, audio, embed, object, [role="button"], [role="link"], [contenteditable="true"], [data-osd-interactive], [data-osd-chrome]';
|
|
8
|
+
|
|
9
|
+
type UseClickPageNavigationOptions<T extends HTMLElement> = {
|
|
10
|
+
ref: RefObject<T>;
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
/** Fraction of the width on each side that navigates; the center is inert. */
|
|
13
|
+
edgeRatio?: number;
|
|
14
|
+
canPrev: boolean;
|
|
15
|
+
canNext: boolean;
|
|
16
|
+
onPrev: () => void;
|
|
17
|
+
onNext: () => void;
|
|
18
|
+
onCenterClick?: () => void;
|
|
19
|
+
onViewportClick?: (point: { x: number; y: number }) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function useClickPageNavigation<T extends HTMLElement>({
|
|
23
|
+
ref,
|
|
24
|
+
enabled = true,
|
|
25
|
+
edgeRatio = 0.3,
|
|
26
|
+
canPrev,
|
|
27
|
+
canNext,
|
|
28
|
+
onPrev,
|
|
29
|
+
onNext,
|
|
30
|
+
onCenterClick,
|
|
31
|
+
onViewportClick,
|
|
32
|
+
}: UseClickPageNavigationOptions<T>) {
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
const el = ref.current;
|
|
35
|
+
if (!el || !enabled) return;
|
|
36
|
+
|
|
37
|
+
const onClick = (event: MouseEvent) => {
|
|
38
|
+
if (event.button !== 0 || event.defaultPrevented) return;
|
|
39
|
+
const target = event.target;
|
|
40
|
+
if (target instanceof Element && target.closest(NAV_PASSTHROUGH)) return;
|
|
41
|
+
if (window.getSelection()?.toString()) return;
|
|
42
|
+
|
|
43
|
+
const rect = el.getBoundingClientRect();
|
|
44
|
+
if (rect.width === 0) return;
|
|
45
|
+
const x = (event.clientX - rect.left) / rect.width;
|
|
46
|
+
const y = rect.height === 0 ? 0 : (event.clientY - rect.top) / rect.height;
|
|
47
|
+
onViewportClick?.({ x, y });
|
|
48
|
+
if (x < edgeRatio) {
|
|
49
|
+
if (canPrev) onPrev();
|
|
50
|
+
} else if (x > 1 - edgeRatio) {
|
|
51
|
+
if (canNext) onNext();
|
|
52
|
+
} else {
|
|
53
|
+
onCenterClick?.();
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
el.addEventListener('click', onClick);
|
|
58
|
+
return () => el.removeEventListener('click', onClick);
|
|
59
|
+
}, [ref, enabled, edgeRatio, canPrev, canNext, onPrev, onNext, onCenterClick, onViewportClick]);
|
|
60
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
// Matches Tailwind's `md` breakpoint — below it the slide viewer hides desktop
|
|
4
|
+
// navigation chrome and relies on tap-to-navigate instead.
|
|
5
|
+
const QUERY = '(max-width: 767.98px)';
|
|
6
|
+
|
|
7
|
+
export function useIsMobile(): boolean {
|
|
8
|
+
const [mobile, setMobile] = useState(() => {
|
|
9
|
+
if (typeof window === 'undefined') return false;
|
|
10
|
+
return window.matchMedia(QUERY).matches;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const mql = window.matchMedia(QUERY);
|
|
15
|
+
const onChange = (e: MediaQueryListEvent) => setMobile(e.matches);
|
|
16
|
+
mql.addEventListener('change', onChange);
|
|
17
|
+
return () => mql.removeEventListener('change', onChange);
|
|
18
|
+
}, []);
|
|
19
|
+
|
|
20
|
+
return mobile;
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
const QUERY = '(prefers-reduced-motion: reduce)';
|
|
4
|
+
|
|
5
|
+
export function usePrefersReducedMotion(): boolean {
|
|
6
|
+
const [reduce, setReduce] = useState(() => {
|
|
7
|
+
if (typeof window === 'undefined') return false;
|
|
8
|
+
return window.matchMedia(QUERY).matches;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const mql = window.matchMedia(QUERY);
|
|
13
|
+
const onChange = (e: MediaQueryListEvent) => setReduce(e.matches);
|
|
14
|
+
mql.addEventListener('change', onChange);
|
|
15
|
+
return () => mql.removeEventListener('change', onChange);
|
|
16
|
+
}, []);
|
|
17
|
+
|
|
18
|
+
return reduce;
|
|
19
|
+
}
|
|
@@ -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-aippt:slide-changed', handler);
|
|
41
|
+
return () => {
|
|
42
|
+
cancelled = true;
|
|
43
|
+
import.meta.hot?.off('open-aippt:slide-changed', handler);
|
|
44
|
+
};
|
|
45
|
+
}, [slideId, reload]);
|
|
46
|
+
|
|
47
|
+
return { slide, error, reload };
|
|
48
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { type RefObject, useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
const WHEEL_PAGE_THRESHOLD_PX = 12;
|
|
4
|
+
const WHEEL_NAV_COOLDOWN_MS = 100;
|
|
5
|
+
const WHEEL_GESTURE_IDLE_MS = 80;
|
|
6
|
+
|
|
7
|
+
type UseWheelPageNavigationOptions<T extends HTMLElement> = {
|
|
8
|
+
ref: RefObject<T>;
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
canPrev: boolean;
|
|
11
|
+
canNext: boolean;
|
|
12
|
+
onPrev: () => void;
|
|
13
|
+
onNext: () => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function useWheelPageNavigation<T extends HTMLElement>({
|
|
17
|
+
ref,
|
|
18
|
+
enabled = true,
|
|
19
|
+
canPrev,
|
|
20
|
+
canNext,
|
|
21
|
+
onPrev,
|
|
22
|
+
onNext,
|
|
23
|
+
}: UseWheelPageNavigationOptions<T>) {
|
|
24
|
+
const accumulatedDeltaRef = useRef(0);
|
|
25
|
+
const lastWheelAtRef = useRef(0);
|
|
26
|
+
const lastNavigateAtRef = useRef(0);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const el = ref.current;
|
|
30
|
+
if (!el || !enabled) return;
|
|
31
|
+
|
|
32
|
+
const onWheel = (event: WheelEvent) => {
|
|
33
|
+
if (event.defaultPrevented || event.ctrlKey || shouldIgnoreWheelTarget(event.target)) return;
|
|
34
|
+
if (isVisualViewportZoomed()) return;
|
|
35
|
+
|
|
36
|
+
const deltaY = normalizeDeltaY(event);
|
|
37
|
+
if (Math.abs(deltaY) <= Math.abs(normalizeDeltaX(event))) return;
|
|
38
|
+
|
|
39
|
+
const now = performance.now();
|
|
40
|
+
if (now - lastWheelAtRef.current > WHEEL_GESTURE_IDLE_MS) {
|
|
41
|
+
accumulatedDeltaRef.current = 0;
|
|
42
|
+
}
|
|
43
|
+
lastWheelAtRef.current = now;
|
|
44
|
+
|
|
45
|
+
if (now - lastNavigateAtRef.current < WHEEL_NAV_COOLDOWN_MS) {
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
accumulatedDeltaRef.current += deltaY;
|
|
51
|
+
if (Math.abs(accumulatedDeltaRef.current) < WHEEL_PAGE_THRESHOLD_PX) {
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const direction = Math.sign(accumulatedDeltaRef.current);
|
|
57
|
+
accumulatedDeltaRef.current = 0;
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
|
|
60
|
+
if (direction > 0 && canNext) {
|
|
61
|
+
lastNavigateAtRef.current = now;
|
|
62
|
+
onNext();
|
|
63
|
+
} else if (direction < 0 && canPrev) {
|
|
64
|
+
lastNavigateAtRef.current = now;
|
|
65
|
+
onPrev();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
el.addEventListener('wheel', onWheel, { passive: false });
|
|
70
|
+
return () => el.removeEventListener('wheel', onWheel);
|
|
71
|
+
}, [ref, enabled, canPrev, canNext, onPrev, onNext]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function normalizeDeltaY(event: WheelEvent) {
|
|
75
|
+
return normalizeWheelDelta(event.deltaY, event.deltaMode);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeDeltaX(event: WheelEvent) {
|
|
79
|
+
return normalizeWheelDelta(event.deltaX, event.deltaMode);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeWheelDelta(delta: number, deltaMode: number) {
|
|
83
|
+
if (deltaMode === WheelEvent.DOM_DELTA_LINE) return delta * 16;
|
|
84
|
+
if (deltaMode === WheelEvent.DOM_DELTA_PAGE) return delta * 800;
|
|
85
|
+
return delta;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isVisualViewportZoomed() {
|
|
89
|
+
if (typeof window === 'undefined') return false;
|
|
90
|
+
const vv = window.visualViewport;
|
|
91
|
+
return vv != null && vv.scale > 1.01;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function shouldIgnoreWheelTarget(target: EventTarget | null) {
|
|
95
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
96
|
+
return Boolean(
|
|
97
|
+
target.closest('input, textarea, select, [contenteditable="true"], [data-wheel-nav-ignore]'),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { cn } from './utils.ts';
|
|
3
|
+
|
|
4
|
+
describe('cn', () => {
|
|
5
|
+
it('joins multiple class names', () => {
|
|
6
|
+
expect(cn('a', 'b', 'c')).toBe('a b c');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('drops falsy values', () => {
|
|
10
|
+
expect(cn('a', false, undefined, null, '', 'b')).toBe('a b');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('flattens arrays and conditional objects from clsx', () => {
|
|
14
|
+
expect(cn(['a', 'b'], { c: true, d: false })).toBe('a b c');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('lets later tailwind classes override earlier ones', () => {
|
|
18
|
+
expect(cn('p-2', 'p-4')).toBe('p-4');
|
|
19
|
+
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('preserves classes that target different properties', () => {
|
|
23
|
+
expect(cn('p-2', 'm-4')).toBe('p-2 m-4');
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/app/main.tsx
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ThemeProvider } from 'next-themes';
|
|
2
|
+
import { StrictMode } from 'react';
|
|
3
|
+
import { createRoot } from 'react-dom/client';
|
|
4
|
+
import { App } from './app';
|
|
5
|
+
import './styles.css';
|
|
6
|
+
|
|
7
|
+
// biome-ignore lint/style/noNonNullAssertion: #root is guaranteed by index.html
|
|
8
|
+
createRoot(document.getElementById('root')!).render(
|
|
9
|
+
<StrictMode>
|
|
10
|
+
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
|
11
|
+
<App />
|
|
12
|
+
</ThemeProvider>
|
|
13
|
+
</StrictMode>,
|
|
14
|
+
);
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import { Outlet, useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
|
3
|
+
import { toast } from 'sonner';
|
|
4
|
+
import { useAssets } from '@/lib/assets';
|
|
5
|
+
import { useFolders } from '@/lib/folders';
|
|
6
|
+
import { format, useLocale } from '@/lib/use-locale';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
import { MobileFolderPill } from '../components/sidebar/mobile-pill';
|
|
9
|
+
import { ASSETS_ID, DRAFT_ID, Sidebar, THEMES_ID } from '../components/sidebar/sidebar';
|
|
10
|
+
import type { FoldersManifest } from '../lib/sdk';
|
|
11
|
+
import { slideIds } from '../lib/slides';
|
|
12
|
+
import { themes as themeRegistry } from '../lib/themes';
|
|
13
|
+
|
|
14
|
+
export type HomeOutletContext = {
|
|
15
|
+
manifest: FoldersManifest;
|
|
16
|
+
loading: boolean;
|
|
17
|
+
draftSlides: string[];
|
|
18
|
+
slidesByFolder: Record<string, string[]>;
|
|
19
|
+
/** Selected folder id when on `/`; equals DRAFT_ID, a folder id, or THEMES_ID. */
|
|
20
|
+
selectedId: string;
|
|
21
|
+
reportTitle: (slideId: string, title: string) => void;
|
|
22
|
+
titleMap: Record<string, string>;
|
|
23
|
+
assign: (slideId: string, folderId: string | null) => Promise<void>;
|
|
24
|
+
renameSlide: (slideId: string, name: string) => Promise<void>;
|
|
25
|
+
duplicateSlide: (slideId: string, newId?: string) => Promise<string>;
|
|
26
|
+
deleteSlide: (slideId: string) => Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function pathToSelectedId(pathname: string, search: URLSearchParams): string {
|
|
30
|
+
if (pathname === '/themes' || pathname.startsWith('/themes/')) return THEMES_ID;
|
|
31
|
+
if (pathname === '/assets') return ASSETS_ID;
|
|
32
|
+
return search.get('f') ?? DRAFT_ID;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function HomeShell() {
|
|
36
|
+
const {
|
|
37
|
+
manifest,
|
|
38
|
+
loading,
|
|
39
|
+
create,
|
|
40
|
+
update,
|
|
41
|
+
remove,
|
|
42
|
+
reorder,
|
|
43
|
+
assign,
|
|
44
|
+
renameSlide,
|
|
45
|
+
duplicateSlide,
|
|
46
|
+
deleteSlide,
|
|
47
|
+
} = useFolders();
|
|
48
|
+
const navigate = useNavigate();
|
|
49
|
+
const location = useLocation();
|
|
50
|
+
const [searchParams] = useSearchParams();
|
|
51
|
+
const t = useLocale();
|
|
52
|
+
|
|
53
|
+
const selectedId = pathToSelectedId(location.pathname, searchParams);
|
|
54
|
+
|
|
55
|
+
const [titleMap, setTitleMap] = useState<Record<string, string>>({});
|
|
56
|
+
const reportTitle = useCallback((slideId: string, slideTitle: string) => {
|
|
57
|
+
setTitleMap((prev) =>
|
|
58
|
+
prev[slideId] === slideTitle ? prev : { ...prev, [slideId]: slideTitle },
|
|
59
|
+
);
|
|
60
|
+
}, []);
|
|
61
|
+
|
|
62
|
+
const selectFolder = useCallback(
|
|
63
|
+
(id: string) => {
|
|
64
|
+
if (id === THEMES_ID) navigate('/themes', { replace: true });
|
|
65
|
+
else if (id === ASSETS_ID) navigate('/assets', { replace: true });
|
|
66
|
+
else if (id === DRAFT_ID) navigate('/', { replace: true });
|
|
67
|
+
else navigate(`/?f=${encodeURIComponent(id)}`, { replace: true });
|
|
68
|
+
},
|
|
69
|
+
[navigate],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const { assets: globalAssets } = useAssets('@global');
|
|
73
|
+
const isAssetsRoute = location.pathname === '/assets';
|
|
74
|
+
|
|
75
|
+
const { draftSlides, slidesByFolder } = useMemo(() => {
|
|
76
|
+
const byFolder: Record<string, string[]> = {};
|
|
77
|
+
const draft: string[] = [];
|
|
78
|
+
const known = new Set(manifest.folders.map((f) => f.id));
|
|
79
|
+
for (const id of slideIds) {
|
|
80
|
+
const folderId = manifest.assignments[id];
|
|
81
|
+
if (folderId && known.has(folderId)) {
|
|
82
|
+
byFolder[folderId] ??= [];
|
|
83
|
+
byFolder[folderId].push(id);
|
|
84
|
+
} else {
|
|
85
|
+
draft.push(id);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { draftSlides: draft, slidesByFolder: byFolder };
|
|
89
|
+
}, [manifest]);
|
|
90
|
+
|
|
91
|
+
const countFor = (folderId: string | null) =>
|
|
92
|
+
folderId === null ? draftSlides.length : (slidesByFolder[folderId]?.length ?? 0);
|
|
93
|
+
|
|
94
|
+
const moveSlideWithToast = useCallback(
|
|
95
|
+
async (slideId: string, folderId: string | null) => {
|
|
96
|
+
if (manifest.assignments[slideId] === (folderId ?? undefined)) return;
|
|
97
|
+
const slideName = titleMap[slideId] ?? slideId;
|
|
98
|
+
const folderName =
|
|
99
|
+
folderId === null
|
|
100
|
+
? t.home.draft
|
|
101
|
+
: (manifest.folders.find((f) => f.id === folderId)?.name ?? folderId);
|
|
102
|
+
try {
|
|
103
|
+
await assign(slideId, folderId);
|
|
104
|
+
toast.success(format(t.home.toastSlideMoved, { slide: slideName, folder: folderName }));
|
|
105
|
+
} catch {
|
|
106
|
+
toast.error(t.home.toastSlideMoveFailed);
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
[assign, manifest, titleMap, t],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const ctx: HomeOutletContext = {
|
|
113
|
+
manifest,
|
|
114
|
+
loading,
|
|
115
|
+
draftSlides,
|
|
116
|
+
slidesByFolder,
|
|
117
|
+
selectedId,
|
|
118
|
+
reportTitle,
|
|
119
|
+
titleMap,
|
|
120
|
+
assign,
|
|
121
|
+
renameSlide,
|
|
122
|
+
duplicateSlide,
|
|
123
|
+
deleteSlide,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<div className="flex h-dvh overflow-hidden bg-background text-foreground">
|
|
128
|
+
<div className="hidden md:block">
|
|
129
|
+
<Sidebar
|
|
130
|
+
folders={manifest.folders}
|
|
131
|
+
countFor={countFor}
|
|
132
|
+
themesCount={themeRegistry.length}
|
|
133
|
+
assetsCount={globalAssets.length}
|
|
134
|
+
selectedId={selectedId}
|
|
135
|
+
onSelect={selectFolder}
|
|
136
|
+
onCreate={(name, icon) => create(name, icon)}
|
|
137
|
+
onRename={(id, name) => update(id, { name })}
|
|
138
|
+
onChangeIcon={(id, icon) => update(id, { icon })}
|
|
139
|
+
onDelete={async (id) => {
|
|
140
|
+
const name = manifest.folders.find((f) => f.id === id)?.name ?? id;
|
|
141
|
+
if (selectedId === id) selectFolder(DRAFT_ID);
|
|
142
|
+
try {
|
|
143
|
+
await remove(id);
|
|
144
|
+
toast.success(format(t.home.toastFolderDeleted, { name }));
|
|
145
|
+
} catch {
|
|
146
|
+
toast.error(t.home.toastFolderDeleteFailed);
|
|
147
|
+
}
|
|
148
|
+
}}
|
|
149
|
+
onDropToFolder={(folderId, slideId) => moveSlideWithToast(slideId, folderId)}
|
|
150
|
+
onDropToDraft={(slideId) => moveSlideWithToast(slideId, null)}
|
|
151
|
+
onReorder={async (ids) => {
|
|
152
|
+
try {
|
|
153
|
+
await reorder(ids);
|
|
154
|
+
} catch {
|
|
155
|
+
toast.error(t.home.toastFolderReorderFailed);
|
|
156
|
+
}
|
|
157
|
+
}}
|
|
158
|
+
/>
|
|
159
|
+
</div>
|
|
160
|
+
|
|
161
|
+
<div className="paper relative flex min-w-0 flex-1 flex-col overflow-y-auto bg-canvas">
|
|
162
|
+
<div className="flex items-center justify-between border-b border-hairline bg-sidebar px-4 py-3 md:hidden">
|
|
163
|
+
<h1 className="font-heading text-lg font-bold tracking-tight">{t.home.appTitle}</h1>
|
|
164
|
+
</div>
|
|
165
|
+
<div className="border-b border-hairline bg-sidebar px-4 py-2 md:hidden">
|
|
166
|
+
<div className="flex gap-2 overflow-x-auto pb-1">
|
|
167
|
+
<MobileFolderPill
|
|
168
|
+
icon={{ type: 'emoji', value: '📝' }}
|
|
169
|
+
label={t.home.draft}
|
|
170
|
+
count={countFor(null)}
|
|
171
|
+
active={selectedId === DRAFT_ID}
|
|
172
|
+
onClick={() => selectFolder(DRAFT_ID)}
|
|
173
|
+
/>
|
|
174
|
+
<MobileFolderPill
|
|
175
|
+
icon={{ type: 'emoji', value: '🎨' }}
|
|
176
|
+
label={t.home.themes}
|
|
177
|
+
count={themeRegistry.length}
|
|
178
|
+
active={selectedId === THEMES_ID}
|
|
179
|
+
onClick={() => selectFolder(THEMES_ID)}
|
|
180
|
+
/>
|
|
181
|
+
<MobileFolderPill
|
|
182
|
+
icon={{ type: 'emoji', value: '🗂️' }}
|
|
183
|
+
label={t.home.assets}
|
|
184
|
+
count={globalAssets.length}
|
|
185
|
+
active={selectedId === ASSETS_ID}
|
|
186
|
+
onClick={() => selectFolder(ASSETS_ID)}
|
|
187
|
+
/>
|
|
188
|
+
{manifest.folders.map((f) => (
|
|
189
|
+
<MobileFolderPill
|
|
190
|
+
key={f.id}
|
|
191
|
+
icon={f.icon}
|
|
192
|
+
label={f.name}
|
|
193
|
+
count={countFor(f.id)}
|
|
194
|
+
active={selectedId === f.id}
|
|
195
|
+
onClick={() => selectFolder(f.id)}
|
|
196
|
+
/>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<div
|
|
202
|
+
className={cn(
|
|
203
|
+
isAssetsRoute
|
|
204
|
+
? 'flex min-h-0 flex-1 flex-col'
|
|
205
|
+
: 'mx-auto w-full max-w-[1180px] px-5 py-8 md:px-10 md:py-12',
|
|
206
|
+
)}
|
|
207
|
+
>
|
|
208
|
+
<Outlet context={ctx} />
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
}
|