@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.
Files changed (142) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/bin.js +2 -0
  4. package/dist/build-DxTqmvsO.js +17 -0
  5. package/dist/cli/bin.d.ts +1 -0
  6. package/dist/cli/bin.js +86 -0
  7. package/dist/config-CjzqjrEA.js +4280 -0
  8. package/dist/config-DIC-yVPp.d.ts +23 -0
  9. package/dist/design-cpzS8aud.js +35 -0
  10. package/dist/dev-BYuTeJbA.js +20 -0
  11. package/dist/format-BCeKbTOM.js +1605 -0
  12. package/dist/index.d.ts +134 -0
  13. package/dist/index.js +467 -0
  14. package/dist/locale/index.d.ts +24 -0
  15. package/dist/locale/index.js +3 -0
  16. package/dist/preview-DlQvnJPq.js +18 -0
  17. package/dist/sync-BPZ0m27m.js +139 -0
  18. package/dist/sync-EsYusbbL.js +3 -0
  19. package/dist/types-CHmFPIG_.d.ts +430 -0
  20. package/dist/vite/index.d.ts +14 -0
  21. package/dist/vite/index.js +4 -0
  22. package/env.d.ts +59 -0
  23. package/package.json +103 -0
  24. package/skills/apply-comments/SKILL.md +83 -0
  25. package/skills/create-slide/SKILL.md +91 -0
  26. package/skills/create-theme/SKILL.md +250 -0
  27. package/skills/current-slide/SKILL.md +110 -0
  28. package/skills/slide-authoring/SKILL.md +625 -0
  29. package/src/app/app.tsx +47 -0
  30. package/src/app/components/asset-view.tsx +966 -0
  31. package/src/app/components/history-provider.tsx +120 -0
  32. package/src/app/components/image-placeholder.tsx +243 -0
  33. package/src/app/components/inspector/asset-picker-dialog.tsx +196 -0
  34. package/src/app/components/inspector/comment-widget.tsx +93 -0
  35. package/src/app/components/inspector/image-crop-dialog.tsx +212 -0
  36. package/src/app/components/inspector/inspect-overlay.tsx +387 -0
  37. package/src/app/components/inspector/inspector-panel.tsx +1115 -0
  38. package/src/app/components/inspector/inspector-provider.tsx +1218 -0
  39. package/src/app/components/inspector/save-bar.tsx +48 -0
  40. package/src/app/components/language-toggle.tsx +39 -0
  41. package/src/app/components/notes-drawer.tsx +120 -0
  42. package/src/app/components/overview-grid.tsx +363 -0
  43. package/src/app/components/panel/panel-fields.tsx +60 -0
  44. package/src/app/components/panel/panel-shell.tsx +80 -0
  45. package/src/app/components/panel/save-card.tsx +142 -0
  46. package/src/app/components/pdf-progress-toast.tsx +32 -0
  47. package/src/app/components/player.tsx +466 -0
  48. package/src/app/components/pptx-progress-toast.tsx +32 -0
  49. package/src/app/components/present/blackout-overlay.tsx +18 -0
  50. package/src/app/components/present/control-bar.tsx +315 -0
  51. package/src/app/components/present/help-overlay.tsx +57 -0
  52. package/src/app/components/present/jump-input.tsx +74 -0
  53. package/src/app/components/present/laser-pointer.tsx +39 -0
  54. package/src/app/components/present/progress-bar.tsx +26 -0
  55. package/src/app/components/present/use-idle.ts +46 -0
  56. package/src/app/components/present/use-pointer-near-bottom.ts +34 -0
  57. package/src/app/components/present/use-presenter-channel.ts +66 -0
  58. package/src/app/components/present/use-touch-swipe.ts +66 -0
  59. package/src/app/components/shared-element.tsx +48 -0
  60. package/src/app/components/sidebar/folder-item.tsx +258 -0
  61. package/src/app/components/sidebar/icon-picker.tsx +61 -0
  62. package/src/app/components/sidebar/mobile-pill.tsx +34 -0
  63. package/src/app/components/sidebar/sidebar-footer.tsx +105 -0
  64. package/src/app/components/sidebar/sidebar.tsx +284 -0
  65. package/src/app/components/slide-canvas.tsx +102 -0
  66. package/src/app/components/slide-transition-layer.tsx +844 -0
  67. package/src/app/components/style-panel/design-provider.tsx +148 -0
  68. package/src/app/components/style-panel/style-panel.tsx +349 -0
  69. package/src/app/components/style-panel/use-design.ts +112 -0
  70. package/src/app/components/theme-toggle.tsx +59 -0
  71. package/src/app/components/themes/theme-detail.tsx +305 -0
  72. package/src/app/components/themes/themes-gallery.tsx +149 -0
  73. package/src/app/components/thumbnail-rail.tsx +805 -0
  74. package/src/app/components/ui/badge.tsx +45 -0
  75. package/src/app/components/ui/button.tsx +99 -0
  76. package/src/app/components/ui/card.tsx +92 -0
  77. package/src/app/components/ui/context-menu.tsx +237 -0
  78. package/src/app/components/ui/dialog.tsx +157 -0
  79. package/src/app/components/ui/dropdown-menu.tsx +245 -0
  80. package/src/app/components/ui/input.tsx +25 -0
  81. package/src/app/components/ui/label.tsx +24 -0
  82. package/src/app/components/ui/popover.tsx +75 -0
  83. package/src/app/components/ui/progress.tsx +31 -0
  84. package/src/app/components/ui/scroll-area.tsx +53 -0
  85. package/src/app/components/ui/select.tsx +196 -0
  86. package/src/app/components/ui/separator.tsx +28 -0
  87. package/src/app/components/ui/slider.tsx +61 -0
  88. package/src/app/components/ui/sonner.tsx +48 -0
  89. package/src/app/components/ui/tabs.tsx +79 -0
  90. package/src/app/components/ui/textarea.tsx +22 -0
  91. package/src/app/components/ui/toggle-group.tsx +83 -0
  92. package/src/app/components/ui/toggle.tsx +45 -0
  93. package/src/app/components/ui/tooltip.tsx +58 -0
  94. package/src/app/favicon.ico +0 -0
  95. package/src/app/index.html +13 -0
  96. package/src/app/lib/assets.ts +242 -0
  97. package/src/app/lib/design-presets.ts +94 -0
  98. package/src/app/lib/design.ts +58 -0
  99. package/src/app/lib/export-html.ts +326 -0
  100. package/src/app/lib/export-pdf.ts +298 -0
  101. package/src/app/lib/export-pptx.ts +284 -0
  102. package/src/app/lib/folders.ts +239 -0
  103. package/src/app/lib/inspector/fiber.test.ts +154 -0
  104. package/src/app/lib/inspector/fiber.ts +85 -0
  105. package/src/app/lib/inspector/use-comments.ts +74 -0
  106. package/src/app/lib/inspector/use-editor.ts +73 -0
  107. package/src/app/lib/inspector/use-notes.ts +134 -0
  108. package/src/app/lib/locale-store.ts +67 -0
  109. package/src/app/lib/page-context.tsx +38 -0
  110. package/src/app/lib/print-ready.test.ts +32 -0
  111. package/src/app/lib/print-ready.ts +51 -0
  112. package/src/app/lib/sdk.test.ts +13 -0
  113. package/src/app/lib/sdk.ts +37 -0
  114. package/src/app/lib/slides.ts +26 -0
  115. package/src/app/lib/step-context.tsx +261 -0
  116. package/src/app/lib/themes.ts +22 -0
  117. package/src/app/lib/transition.ts +30 -0
  118. package/src/app/lib/use-agent-socket.ts +18 -0
  119. package/src/app/lib/use-click-page-navigation.ts +60 -0
  120. package/src/app/lib/use-is-mobile.ts +21 -0
  121. package/src/app/lib/use-locale.ts +8 -0
  122. package/src/app/lib/use-prefers-reduced-motion.ts +19 -0
  123. package/src/app/lib/use-slide-module.ts +48 -0
  124. package/src/app/lib/use-wheel-page-navigation.ts +99 -0
  125. package/src/app/lib/utils.test.ts +25 -0
  126. package/src/app/lib/utils.ts +6 -0
  127. package/src/app/main.tsx +14 -0
  128. package/src/app/routes/assets.tsx +9 -0
  129. package/src/app/routes/home-shell.tsx +213 -0
  130. package/src/app/routes/home.tsx +807 -0
  131. package/src/app/routes/presenter.tsx +418 -0
  132. package/src/app/routes/slide.tsx +1108 -0
  133. package/src/app/routes/themes.tsx +34 -0
  134. package/src/app/styles.css +429 -0
  135. package/src/app/virtual.d.ts +51 -0
  136. package/src/locale/en.ts +416 -0
  137. package/src/locale/format.ts +12 -0
  138. package/src/locale/index.ts +6 -0
  139. package/src/locale/ja.ts +422 -0
  140. package/src/locale/types.ts +443 -0
  141. package/src/locale/zh-cn.ts +414 -0
  142. package/src/locale/zh-tw.ts +414 -0
@@ -0,0 +1,66 @@
1
+ import { type RefObject, useEffect, useRef } from 'react';
2
+
3
+ const MIN_SWIPE_PX = 50;
4
+ const MAX_SWIPE_MS = 600;
5
+
6
+ type Options<T extends HTMLElement> = {
7
+ ref: RefObject<T | null>;
8
+ enabled?: boolean;
9
+ onPrev: () => void;
10
+ onNext: () => void;
11
+ };
12
+
13
+ /**
14
+ * Single-finger horizontal swipe → prev/next. Vertical-dominant gestures
15
+ * are left alone so scroll-y on tablets keeps working. The handler only
16
+ * binds when `enabled`, so overlay layers can suppress it.
17
+ */
18
+ export function useTouchSwipe<T extends HTMLElement>({
19
+ ref,
20
+ enabled = true,
21
+ onPrev,
22
+ onNext,
23
+ }: Options<T>) {
24
+ const start = useRef<{ x: number; y: number; t: number } | null>(null);
25
+
26
+ useEffect(() => {
27
+ const el = ref.current;
28
+ if (!el || !enabled) return;
29
+
30
+ const onStart = (e: TouchEvent) => {
31
+ if (e.touches.length !== 1) {
32
+ start.current = null;
33
+ return;
34
+ }
35
+ const t = e.touches[0];
36
+ start.current = { x: t.clientX, y: t.clientY, t: performance.now() };
37
+ };
38
+ const onEnd = (e: TouchEvent) => {
39
+ const s = start.current;
40
+ start.current = null;
41
+ if (!s) return;
42
+ const t = e.changedTouches[0];
43
+ if (!t) return;
44
+ const dx = t.clientX - s.x;
45
+ const dy = t.clientY - s.y;
46
+ if (performance.now() - s.t > MAX_SWIPE_MS) return;
47
+ if (Math.abs(dx) < MIN_SWIPE_PX) return;
48
+ if (Math.abs(dx) <= Math.abs(dy)) return;
49
+ if (dx < 0) onNext();
50
+ else onPrev();
51
+ };
52
+
53
+ const onCancel = () => {
54
+ start.current = null;
55
+ };
56
+
57
+ el.addEventListener('touchstart', onStart, { passive: true });
58
+ el.addEventListener('touchend', onEnd);
59
+ el.addEventListener('touchcancel', onCancel);
60
+ return () => {
61
+ el.removeEventListener('touchstart', onStart);
62
+ el.removeEventListener('touchend', onEnd);
63
+ el.removeEventListener('touchcancel', onCancel);
64
+ };
65
+ }, [ref, enabled, onPrev, onNext]);
66
+ }
@@ -0,0 +1,48 @@
1
+ import {
2
+ Children,
3
+ type CSSProperties,
4
+ cloneElement,
5
+ isValidElement,
6
+ type ReactElement,
7
+ type ReactNode,
8
+ } from 'react';
9
+
10
+ export type unstable_SharedElementProps = {
11
+ id: string;
12
+ children: ReactNode;
13
+ className?: string;
14
+ style?: CSSProperties;
15
+ };
16
+
17
+ type SharedElementChildProps = {
18
+ className?: string;
19
+ style?: CSSProperties;
20
+ 'data-osd-shared-element'?: string;
21
+ };
22
+
23
+ export function unstable_SharedElement({
24
+ id,
25
+ children,
26
+ className,
27
+ style,
28
+ }: unstable_SharedElementProps) {
29
+ const child = Children.toArray(children)[0] ?? null;
30
+
31
+ if (
32
+ Children.count(children) === 1 &&
33
+ isValidElement<SharedElementChildProps>(child) &&
34
+ typeof child.type === 'string'
35
+ ) {
36
+ return cloneElement(child as ReactElement<SharedElementChildProps>, {
37
+ 'data-osd-shared-element': id,
38
+ className: [child.props.className, className].filter(Boolean).join(' ') || undefined,
39
+ style: style ? { ...child.props.style, ...style } : child.props.style,
40
+ });
41
+ }
42
+
43
+ return (
44
+ <div className={className} style={style} data-osd-shared-element={id}>
45
+ {children}
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,258 @@
1
+ import { MoreHorizontal, Pencil, Trash2 } from 'lucide-react';
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import {
4
+ DropdownMenu,
5
+ DropdownMenuContent,
6
+ DropdownMenuItem,
7
+ DropdownMenuTrigger,
8
+ } from '@/components/ui/dropdown-menu';
9
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
10
+ import type { Folder, FolderIcon } from '@/lib/sdk';
11
+ import { useLocale } from '@/lib/use-locale';
12
+ import { cn } from '@/lib/utils';
13
+ import { IconPicker } from './icon-picker';
14
+
15
+ export const SLIDE_DND_MIME = 'application/x-slide-id';
16
+
17
+ function useSlideDragActive() {
18
+ const [active, setActive] = useState(false);
19
+ useEffect(() => {
20
+ const onStart = (e: DragEvent) => {
21
+ if (e.dataTransfer?.types?.includes(SLIDE_DND_MIME)) setActive(true);
22
+ };
23
+ const onEnd = () => setActive(false);
24
+ document.addEventListener('dragstart', onStart);
25
+ document.addEventListener('dragend', onEnd);
26
+ document.addEventListener('drop', onEnd);
27
+ return () => {
28
+ document.removeEventListener('dragstart', onStart);
29
+ document.removeEventListener('dragend', onEnd);
30
+ document.removeEventListener('drop', onEnd);
31
+ };
32
+ }, []);
33
+ return active;
34
+ }
35
+
36
+ export function FolderIconChip({ icon, className }: { icon: FolderIcon; className?: string }) {
37
+ if (icon.type === 'emoji') {
38
+ return (
39
+ <span
40
+ className={cn(
41
+ 'inline-flex size-5 items-center justify-center text-[15px] leading-none',
42
+ className,
43
+ )}
44
+ >
45
+ {icon.value}
46
+ </span>
47
+ );
48
+ }
49
+ return (
50
+ <span
51
+ className={cn(
52
+ 'inline-block size-3 rounded-[3px] ring-1 ring-foreground/15 shadow-[inset_0_1px_0_oklch(1_0_0/0.18)]',
53
+ className,
54
+ )}
55
+ style={{ background: icon.value }}
56
+ />
57
+ );
58
+ }
59
+
60
+ type Row =
61
+ | {
62
+ kind: 'folder';
63
+ folder: Folder;
64
+ onRename: (name: string) => void;
65
+ onChangeIcon: (icon: FolderIcon) => void;
66
+ onDelete: () => void;
67
+ }
68
+ | {
69
+ kind: 'draft';
70
+ }
71
+ | {
72
+ kind: 'themes';
73
+ }
74
+ | {
75
+ kind: 'assets';
76
+ };
77
+
78
+ export function FolderItem({
79
+ row,
80
+ count,
81
+ selected,
82
+ onSelect,
83
+ onDropSlide,
84
+ }: {
85
+ row: Row;
86
+ count: number;
87
+ selected: boolean;
88
+ onSelect: () => void;
89
+ onDropSlide: (slideId: string) => void;
90
+ }) {
91
+ const [renaming, setRenaming] = useState(false);
92
+ const [dragOver, setDragOver] = useState(false);
93
+ const dragDepth = useRef(0);
94
+ const [draftName, setDraftName] = useState(row.kind === 'folder' ? row.folder.name : '');
95
+ const slideDragActive = useSlideDragActive();
96
+ const t = useLocale();
97
+
98
+ const acceptsSlideDrop = row.kind !== 'themes' && row.kind !== 'assets';
99
+ const isSlideDrag = (e: React.DragEvent) =>
100
+ acceptsSlideDrop && e.dataTransfer.types.includes(SLIDE_DND_MIME);
101
+ const handleDragEnter = (e: React.DragEvent) => {
102
+ if (!isSlideDrag(e)) return;
103
+ dragDepth.current += 1;
104
+ if (dragDepth.current === 1) setDragOver(true);
105
+ };
106
+ const handleDragOver = (e: React.DragEvent) => {
107
+ if (!isSlideDrag(e)) return;
108
+ e.preventDefault();
109
+ e.dataTransfer.dropEffect = 'move';
110
+ };
111
+ const handleDragLeave = (e: React.DragEvent) => {
112
+ if (!isSlideDrag(e)) return;
113
+ dragDepth.current = Math.max(0, dragDepth.current - 1);
114
+ if (dragDepth.current === 0) setDragOver(false);
115
+ };
116
+ const handleDrop = (e: React.DragEvent) => {
117
+ if (!acceptsSlideDrop) return;
118
+ const slideId = e.dataTransfer.getData(SLIDE_DND_MIME);
119
+ dragDepth.current = 0;
120
+ setDragOver(false);
121
+ if (!slideId) return;
122
+ e.preventDefault();
123
+ onDropSlide(slideId);
124
+ };
125
+
126
+ const icon: FolderIcon =
127
+ row.kind === 'draft'
128
+ ? { type: 'emoji', value: '📝' }
129
+ : row.kind === 'themes'
130
+ ? { type: 'emoji', value: '🎨' }
131
+ : row.kind === 'assets'
132
+ ? { type: 'emoji', value: '🗂️' }
133
+ : row.folder.icon;
134
+ const label =
135
+ row.kind === 'draft'
136
+ ? t.home.draft
137
+ : row.kind === 'themes'
138
+ ? t.home.themes
139
+ : row.kind === 'assets'
140
+ ? t.home.assets
141
+ : row.folder.name;
142
+
143
+ const commitRename = () => {
144
+ if (row.kind !== 'folder') return;
145
+ const trimmed = draftName.trim();
146
+ if (trimmed && trimmed !== row.folder.name) row.onRename(trimmed);
147
+ setRenaming(false);
148
+ };
149
+
150
+ return (
151
+ // biome-ignore lint/a11y/noStaticElementInteractions: drag-and-drop target wraps interactive children
152
+ <div
153
+ className={cn(
154
+ 'group relative flex items-center gap-2.5 rounded-[5px] px-2 py-[5px] text-[12.5px] transition-colors',
155
+ selected
156
+ ? 'bg-muted text-foreground before:absolute before:inset-y-1.5 before:-left-0.5 before:w-[2px] before:rounded-full before:bg-brand'
157
+ : 'text-foreground/70 hover:bg-muted/60 hover:text-foreground',
158
+ slideDragActive && acceptsSlideDrop && !dragOver && 'ring-1 ring-foreground/10',
159
+ dragOver &&
160
+ 'bg-brand/10 text-foreground ring-1 ring-brand ring-offset-1 ring-offset-sidebar motion-safe:scale-[1.01] motion-safe:transition-transform',
161
+ )}
162
+ onDragEnter={handleDragEnter}
163
+ onDragOver={handleDragOver}
164
+ onDragLeave={handleDragLeave}
165
+ onDrop={handleDrop}
166
+ >
167
+ {row.kind === 'folder' && import.meta.env.DEV ? (
168
+ <Popover>
169
+ <PopoverTrigger asChild>
170
+ <button
171
+ type="button"
172
+ className="flex size-5 shrink-0 items-center justify-center rounded transition-transform hover:scale-110"
173
+ aria-label={t.home.changeIcon}
174
+ onClick={(e) => e.stopPropagation()}
175
+ >
176
+ <FolderIconChip icon={icon} />
177
+ </button>
178
+ </PopoverTrigger>
179
+ <PopoverContent side="right" align="start" className="w-auto p-2">
180
+ <IconPicker value={row.folder.icon} onChange={(next) => row.onChangeIcon(next)} />
181
+ </PopoverContent>
182
+ </Popover>
183
+ ) : (
184
+ <button
185
+ type="button"
186
+ onClick={onSelect}
187
+ aria-label={label}
188
+ className="flex size-5 shrink-0 items-center justify-center"
189
+ >
190
+ <FolderIconChip icon={icon} />
191
+ </button>
192
+ )}
193
+
194
+ {renaming && row.kind === 'folder' ? (
195
+ <input
196
+ value={draftName}
197
+ onChange={(e) => setDraftName(e.target.value)}
198
+ onBlur={commitRename}
199
+ onKeyDown={(e) => {
200
+ if (e.nativeEvent.isComposing) return;
201
+ if (e.key === 'Enter') commitRename();
202
+ if (e.key === 'Escape') {
203
+ setDraftName(row.folder.name);
204
+ setRenaming(false);
205
+ }
206
+ }}
207
+ maxLength={40}
208
+ className="min-w-0 flex-1 rounded-[3px] bg-card px-1 text-[12.5px] outline-none ring-1 ring-foreground/20"
209
+ />
210
+ ) : (
211
+ <button type="button" onClick={onSelect} className="min-w-0 flex-1 truncate text-left">
212
+ {label}
213
+ </button>
214
+ )}
215
+
216
+ <span
217
+ className={cn(
218
+ 'folio ml-auto shrink-0 transition-opacity',
219
+ row.kind === 'folder' &&
220
+ import.meta.env.DEV &&
221
+ 'group-hover:opacity-0 group-has-[[aria-expanded=true]]:opacity-0',
222
+ )}
223
+ >
224
+ {count.toString().padStart(2, '0')}
225
+ </span>
226
+
227
+ {row.kind === 'folder' && import.meta.env.DEV && (
228
+ <DropdownMenu>
229
+ <DropdownMenuTrigger asChild>
230
+ <button
231
+ type="button"
232
+ onClick={(e) => e.stopPropagation()}
233
+ className="absolute right-2 top-1/2 size-5 -translate-y-1/2 rounded opacity-0 transition-opacity hover:bg-foreground/10 group-hover:opacity-100 aria-expanded:opacity-100"
234
+ aria-label={t.home.folderActions}
235
+ >
236
+ <MoreHorizontal className="mx-auto size-3.5" />
237
+ </button>
238
+ </DropdownMenuTrigger>
239
+ <DropdownMenuContent align="end" className="min-w-[140px]">
240
+ <DropdownMenuItem
241
+ onSelect={() => {
242
+ setDraftName(row.folder.name);
243
+ setRenaming(true);
244
+ }}
245
+ >
246
+ <Pencil />
247
+ {t.common.rename}
248
+ </DropdownMenuItem>
249
+ <DropdownMenuItem variant="destructive" onSelect={() => row.onDelete()}>
250
+ <Trash2 />
251
+ {t.common.delete}
252
+ </DropdownMenuItem>
253
+ </DropdownMenuContent>
254
+ </DropdownMenu>
255
+ )}
256
+ </div>
257
+ );
258
+ }
@@ -0,0 +1,61 @@
1
+ import EmojiPicker, { EmojiStyle, Theme } from 'emoji-picker-react';
2
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
3
+ import type { FolderIcon } from '@/lib/sdk';
4
+ import { useLocale } from '@/lib/use-locale';
5
+
6
+ export const PRESET_COLORS = [
7
+ '#c0392b', // vermillion
8
+ '#b8743e', // ochre
9
+ '#6f7a3a', // olive
10
+ '#2f6a4f', // forest
11
+ '#3a5a7c', // ink blue
12
+ '#6b4675', // plum
13
+ '#a3543b', // terracotta
14
+ '#3a3a3a', // graphite
15
+ ];
16
+
17
+ export function IconPicker({
18
+ value,
19
+ onChange,
20
+ }: {
21
+ value: FolderIcon;
22
+ onChange: (icon: FolderIcon) => void;
23
+ }) {
24
+ const t = useLocale();
25
+ return (
26
+ <Tabs defaultValue={value.type} className="w-[320px]">
27
+ <TabsList className="w-full">
28
+ <TabsTrigger value="emoji">{t.home.iconEmojiTab}</TabsTrigger>
29
+ <TabsTrigger value="color">{t.home.iconColorTab}</TabsTrigger>
30
+ </TabsList>
31
+
32
+ <TabsContent value="emoji">
33
+ <EmojiPicker
34
+ lazyLoadEmojis
35
+ emojiStyle={EmojiStyle.NATIVE}
36
+ theme={Theme.AUTO}
37
+ width="100%"
38
+ height={360}
39
+ onEmojiClick={(data) => onChange({ type: 'emoji', value: data.emoji })}
40
+ previewConfig={{ showPreview: false }}
41
+ skinTonesDisabled
42
+ />
43
+ </TabsContent>
44
+
45
+ <TabsContent value="color">
46
+ <div className="grid grid-cols-8 gap-1.5 py-2">
47
+ {PRESET_COLORS.map((c) => (
48
+ <button
49
+ key={c}
50
+ type="button"
51
+ onClick={() => onChange({ type: 'color', value: c })}
52
+ className="size-6 rounded-[4px] ring-1 ring-foreground/10 shadow-[inset_0_1px_0_oklch(1_0_0/0.18)] transition-transform hover:scale-110"
53
+ style={{ background: c }}
54
+ aria-label={c}
55
+ />
56
+ ))}
57
+ </div>
58
+ </TabsContent>
59
+ </Tabs>
60
+ );
61
+ }
@@ -0,0 +1,34 @@
1
+ import { cn } from '@/lib/utils';
2
+ import type { FolderIcon } from '../../lib/sdk';
3
+ import { FolderIconChip } from './folder-item';
4
+
5
+ export function MobileFolderPill({
6
+ icon,
7
+ label,
8
+ count,
9
+ active,
10
+ onClick,
11
+ }: {
12
+ icon: FolderIcon;
13
+ label: string;
14
+ count: number;
15
+ active: boolean;
16
+ onClick: () => void;
17
+ }) {
18
+ return (
19
+ <button
20
+ type="button"
21
+ onClick={onClick}
22
+ className={cn(
23
+ 'flex shrink-0 items-center gap-1.5 rounded-[5px] border px-2.5 py-1 text-[11.5px] font-medium transition-colors',
24
+ active
25
+ ? 'border-foreground/40 bg-foreground text-background'
26
+ : 'border-border bg-card text-muted-foreground hover:text-foreground',
27
+ )}
28
+ >
29
+ <FolderIconChip icon={icon} className="size-3.5 text-sm" />
30
+ <span className="max-w-[8rem] truncate">{label}</span>
31
+ <span className="folio nums">{count.toString().padStart(2, '0')}</span>
32
+ </button>
33
+ );
34
+ }
@@ -0,0 +1,105 @@
1
+ import config from 'virtual:open-aippt/config';
2
+ import { Loader2, RefreshCw } from 'lucide-react';
3
+ import { useEffect, useState } from 'react';
4
+ import { toast } from 'sonner';
5
+ import { Button } from '@/components/ui/button';
6
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
7
+ import { format, useLocale } from '@/lib/use-locale';
8
+
9
+ type UpdateCheck = { current: string; latest: string | null; outdated: boolean };
10
+ type UpdateStatus = 'idle' | 'running' | 'done' | 'error';
11
+
12
+ export function SidebarFooter() {
13
+ const t = useLocale();
14
+ const [update, setUpdate] = useState<UpdateCheck | null>(null);
15
+ const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
16
+
17
+ useEffect(() => {
18
+ if (!import.meta.env.DEV) return;
19
+ let cancelled = false;
20
+ fetch('/__update-check')
21
+ .then((res) => (res.ok ? (res.json() as Promise<UpdateCheck>) : null))
22
+ .then((data) => {
23
+ if (!cancelled && data?.outdated) setUpdate(data);
24
+ })
25
+ .catch(() => {});
26
+ return () => {
27
+ cancelled = true;
28
+ };
29
+ }, []);
30
+
31
+ const label = `v${config.version}`;
32
+ const isUpdating = updateStatus === 'running';
33
+
34
+ async function updatePackage() {
35
+ if (isUpdating) return;
36
+ setUpdateStatus('running');
37
+ try {
38
+ const res = await fetch('/__update-package', { method: 'POST' });
39
+ if (!res.ok) throw new Error('update failed');
40
+ setUpdateStatus('done');
41
+ toast.success(t.home.updatePackageDone);
42
+ } catch {
43
+ setUpdateStatus('error');
44
+ toast.error(t.home.updatePackageFailed);
45
+ }
46
+ }
47
+
48
+ const versionRow = (
49
+ <span className="inline-flex cursor-default items-center gap-1.5">
50
+ {update?.latest && <span className="size-1.5 rounded-full bg-brand" aria-hidden />}
51
+ {label}
52
+ </span>
53
+ );
54
+
55
+ return (
56
+ <div className="px-4 py-3 text-[11px] text-muted-foreground/70 tabular-nums">
57
+ {update?.latest ? (
58
+ <TooltipProvider delayDuration={200}>
59
+ <Tooltip>
60
+ <TooltipTrigger asChild>{versionRow}</TooltipTrigger>
61
+ <TooltipContent
62
+ side="top"
63
+ align="start"
64
+ alignOffset={-8}
65
+ sideOffset={9}
66
+ collisionPadding={12}
67
+ className="flex w-[232px] max-w-[calc(100vw-24px)] flex-col gap-2.5 rounded-[8px] border border-background/10 bg-foreground/95 p-2.5 text-[11.5px] leading-4 shadow-[0_12px_32px_oklch(0_0_0/0.28)] backdrop-blur"
68
+ >
69
+ <span className="pr-1 text-background/92">
70
+ {format(t.home.updateAvailable, { version: update.latest })}
71
+ </span>
72
+ <Button
73
+ type="button"
74
+ size="xs"
75
+ variant="secondary"
76
+ className="h-6 w-fit rounded-[5px] border border-background/15 bg-background/8 px-2 text-[11px] text-background shadow-none hover:bg-background/14"
77
+ disabled={isUpdating || updateStatus === 'done'}
78
+ onClick={updatePackage}
79
+ >
80
+ {isUpdating ? (
81
+ <Loader2 className="animate-spin" aria-hidden />
82
+ ) : (
83
+ <RefreshCw aria-hidden />
84
+ )}
85
+ {isUpdating ? t.home.updatingPackage : t.home.updatePackage}
86
+ </Button>
87
+ {updateStatus === 'done' && (
88
+ <span className="text-[11px] leading-4 text-background/65">
89
+ {t.home.updatePackageDone}
90
+ </span>
91
+ )}
92
+ {updateStatus === 'error' && (
93
+ <span className="text-[11px] leading-4 text-background/65">
94
+ {t.home.updatePackageFailed}
95
+ </span>
96
+ )}
97
+ </TooltipContent>
98
+ </Tooltip>
99
+ </TooltipProvider>
100
+ ) : (
101
+ versionRow
102
+ )}
103
+ </div>
104
+ );
105
+ }