From 93d1e3383ce47e45157d70f9ba6251239dae6bc2 Mon Sep 17 00:00:00 2001 From: Maze <167211895+mazeincoding@users.noreply.github.com> Date: Mon, 23 Feb 2026 03:24:02 +0100 Subject: feat: major editor overhaul (assets, properties, timeline, fonts) (#709) * feat: major editor overhaul (assets, properties, timeline, fonts) Refactor editor core systems to standardize UI architecture and improve performance. Assets & Properties: - Replace monolithic property items with composable `Section` architecture. - Add specialized sections for Transform, Blending, and Text. - Implement `NumberField` with scrubbing and math evaluation. - Add new ColorPicker with EyeDropper and multiple format support. - Standardize asset panels using new `PanelView` layout. Fonts & Stickers: - Implement custom font atlas/sprite system for high-performance previews. - Add virtualized FontPicker with search and favorites. - Refactor stickers to use a provider-based architecture (icons, emoji, flags, shapes). - Standardize sticker IDs to `provider:value` format. Timeline & Interaction: - Convert bookmarks to rich objects with notes, colors, and duration. - Refactor drag-and-drop to use Command pattern (enabling proper undo/redo). - Add Shift modifier to disable snapping during moves/resizes. - Add new overlays for layout guides and text editing. Renderer: - Add support for multi-line text, custom line-height, and letter-spacing. - Implement global composite operation (blend modes). - Update sticker node to resolve dynamic provider IDs. Infrastructure: - Add storage migrations (v3->v6) for text weights, sticker IDs, and bookmarks. - Update global styles and core UI components (Button, Input, Popover). * add ts-nocheck directive to settings-legacy.tsx to suppress TypeScript errors * fix: correct global composite operation assignment in TextNode to ensure proper blend mode handling * deleted shadcn components with errors * formatting * fix linter issues * migrate from next middleware to proxy * add missing component back * add breadcrumb back * chore: add @radix-ui/react-primitive deps * chore: more deps * chore: add missing env vars to bun-ci * next env --- apps/web/src/stores/editor-store.ts | 68 +-- apps/web/src/stores/keybindings-store.ts | 5 +- .../web/src/stores/keybindings/migrations/index.ts | 6 +- .../src/stores/keybindings/migrations/v3-to-v4.ts | 25 + apps/web/src/stores/preview-store.ts | 94 ++++ apps/web/src/stores/sounds-store.ts | 522 ++++++++++----------- apps/web/src/stores/stickers-store.ts | 349 ++++++++------ 7 files changed, 592 insertions(+), 477 deletions(-) create mode 100644 apps/web/src/stores/keybindings/migrations/v3-to-v4.ts create mode 100644 apps/web/src/stores/preview-store.ts (limited to 'apps/web/src/stores') diff --git a/apps/web/src/stores/editor-store.ts b/apps/web/src/stores/editor-store.ts index 5c4856d3..ad351bf9 100644 --- a/apps/web/src/stores/editor-store.ts +++ b/apps/web/src/stores/editor-store.ts @@ -1,70 +1,24 @@ import { create } from "zustand"; -import { persist } from "zustand/middleware"; -import type { TPlatformLayout } from "@/types/editor"; import { DEFAULT_CANVAS_PRESETS } from "@/constants/project-constants"; import type { TCanvasSize } from "@/types/project"; -interface LayoutGuideSettings { - platform: TPlatformLayout | null; -} - interface EditorState { isInitializing: boolean; isPanelsReady: boolean; canvasPresets: TCanvasSize[]; - layoutGuide: LayoutGuideSettings; setInitializing: (loading: boolean) => void; setPanelsReady: (ready: boolean) => void; initializeApp: () => Promise; - setLayoutGuide: (settings: Partial) => void; - toggleLayoutGuide: (platform: TPlatformLayout) => void; } -export const useEditorStore = create()( - persist( - (set) => ({ - isInitializing: true, - isPanelsReady: false, - canvasPresets: DEFAULT_CANVAS_PRESETS, - layoutGuide: { - platform: null, - }, - setInitializing: (loading) => { - set({ isInitializing: loading }); - }, - - setPanelsReady: (ready) => { - set({ isPanelsReady: ready }); - }, - - initializeApp: async () => { - set({ isInitializing: true, isPanelsReady: false }); - - set({ isPanelsReady: true, isInitializing: false }); - }, - - setLayoutGuide: (settings) => { - set((state) => ({ - layoutGuide: { - ...state.layoutGuide, - ...settings, - }, - })); - }, - - toggleLayoutGuide: (platform) => { - set((state) => ({ - layoutGuide: { - platform: state.layoutGuide.platform === platform ? null : platform, - }, - })); - }, - }), - { - name: "editor-settings", - partialize: (state) => ({ - layoutGuide: state.layoutGuide, - }), - }, - ), -); +export const useEditorStore = create()((set) => ({ + isInitializing: true, + isPanelsReady: false, + canvasPresets: DEFAULT_CANVAS_PRESETS, + setInitializing: (loading) => set({ isInitializing: loading }), + setPanelsReady: (ready) => set({ isPanelsReady: ready }), + initializeApp: async () => { + set({ isInitializing: true, isPanelsReady: false }); + set({ isPanelsReady: true, isInitializing: false }); + }, +})); diff --git a/apps/web/src/stores/keybindings-store.ts b/apps/web/src/stores/keybindings-store.ts index 5ff48ffc..fbbf169e 100644 --- a/apps/web/src/stores/keybindings-store.ts +++ b/apps/web/src/stores/keybindings-store.ts @@ -7,10 +7,7 @@ import { getDefaultShortcuts } from "@/lib/actions"; import { isTypableDOMElement } from "@/utils/browser"; import { isAppleDevice } from "@/utils/platform"; import type { KeybindingConfig, ShortcutKey } from "@/types/keybinding"; -import { - runMigrations, - CURRENT_VERSION, -} from "./keybindings/migrations"; +import { runMigrations, CURRENT_VERSION } from "./keybindings/migrations"; export const defaultKeybindings: KeybindingConfig = getDefaultShortcuts(); diff --git a/apps/web/src/stores/keybindings/migrations/index.ts b/apps/web/src/stores/keybindings/migrations/index.ts index 1689802e..64d86ffa 100644 --- a/apps/web/src/stores/keybindings/migrations/index.ts +++ b/apps/web/src/stores/keybindings/migrations/index.ts @@ -1,4 +1,5 @@ -import { v2ToV3 } from './v2-to-v3'; +import { v2ToV3 } from "./v2-to-v3"; +import { v3ToV4 } from "./v3-to-v4"; type MigrationFn = ({ state }: { state: unknown }) => unknown; @@ -8,9 +9,10 @@ type MigrationFn = ({ state }: { state: unknown }) => unknown; */ const migrations: Record = { 2: v2ToV3, + 3: v3ToV4, }; -export const CURRENT_VERSION = 3; +export const CURRENT_VERSION = 4; export function runMigrations({ state, diff --git a/apps/web/src/stores/keybindings/migrations/v3-to-v4.ts b/apps/web/src/stores/keybindings/migrations/v3-to-v4.ts new file mode 100644 index 00000000..b53e082f --- /dev/null +++ b/apps/web/src/stores/keybindings/migrations/v3-to-v4.ts @@ -0,0 +1,25 @@ +import type { TActionWithOptionalArgs } from "@/lib/actions"; +import type { ShortcutKey } from "@/types/keybinding"; +import type { KeybindingConfig } from "@/types/keybinding"; + +interface V3State { + keybindings: KeybindingConfig; + isCustomized: boolean; +} + +export function v3ToV4({ state }: { state: unknown }): unknown { + const v3 = state as V3State; + + const renames: Record = { + "paste-selected": "paste-copied", + }; + + const migrated = { ...v3.keybindings }; + for (const [key, action] of Object.entries(migrated)) { + if (action && renames[action]) { + migrated[key as ShortcutKey] = renames[action] as TActionWithOptionalArgs; + } + } + + return { ...v3, keybindings: migrated }; +} diff --git a/apps/web/src/stores/preview-store.ts b/apps/web/src/stores/preview-store.ts new file mode 100644 index 00000000..03b28fe2 --- /dev/null +++ b/apps/web/src/stores/preview-store.ts @@ -0,0 +1,94 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import type { TPlatformLayout } from "@/types/editor"; + +interface LayoutGuideSettings { + platform: TPlatformLayout | null; +} + +interface PreviewOverlaysState { + bookmarks: boolean; +} + +interface PreviewState { + layoutGuide: LayoutGuideSettings; + overlays: PreviewOverlaysState; + setLayoutGuide: (settings: Partial) => void; + toggleLayoutGuide: (platform: TPlatformLayout) => void; + setOverlayVisibility: ({ + overlay, + isVisible, + }: { + overlay: keyof PreviewOverlaysState; + isVisible: boolean; + }) => void; + toggleOverlayVisibility: ({ + overlay, + }: { + overlay: keyof PreviewOverlaysState; + }) => void; +} + +const DEFAULT_PREVIEW_OVERLAYS: PreviewOverlaysState = { + bookmarks: true, +}; + +export const usePreviewStore = create()( + persist( + (set) => ({ + layoutGuide: { platform: null }, + overlays: DEFAULT_PREVIEW_OVERLAYS, + setLayoutGuide: (settings) => { + set((state) => ({ + layoutGuide: { + ...state.layoutGuide, + ...settings, + }, + })); + }, + toggleLayoutGuide: (platform) => { + set((state) => ({ + layoutGuide: { + platform: state.layoutGuide.platform === platform ? null : platform, + }, + })); + }, + setOverlayVisibility: ({ overlay, isVisible }) => { + set((state) => ({ + overlays: { + ...state.overlays, + [overlay]: isVisible, + }, + })); + }, + toggleOverlayVisibility: ({ overlay }) => { + set((state) => ({ + overlays: { + ...state.overlays, + [overlay]: !state.overlays[overlay], + }, + })); + }, + }), + { + name: "preview-settings", + version: 2, + migrate: (persistedState) => { + const state = persistedState as + | { + layoutGuide?: LayoutGuideSettings; + overlays?: PreviewOverlaysState; + } + | undefined; + return { + layoutGuide: state?.layoutGuide ?? { platform: null }, + overlays: state?.overlays ?? DEFAULT_PREVIEW_OVERLAYS, + }; + }, + partialize: (state) => ({ + layoutGuide: state.layoutGuide, + overlays: state.overlays, + }), + }, + ), +); diff --git a/apps/web/src/stores/sounds-store.ts b/apps/web/src/stores/sounds-store.ts index f393416a..8edbcc97 100644 --- a/apps/web/src/stores/sounds-store.ts +++ b/apps/web/src/stores/sounds-store.ts @@ -1,261 +1,261 @@ -import { create } from "zustand"; -import type { SoundEffect, SavedSound } from "@/types/sounds"; -import { storageService } from "@/services/storage/service"; -import { toast } from "sonner"; -import { EditorCore } from "@/core"; -import { buildLibraryAudioElement } from "@/lib/timeline/element-utils"; - -interface SoundsStore { - topSoundEffects: SoundEffect[]; - isLoading: boolean; - error: string | null; - hasLoaded: boolean; - showCommercialOnly: boolean; - toggleCommercialFilter: () => void; - searchQuery: string; - searchResults: SoundEffect[]; - isSearching: boolean; - searchError: string | null; - lastSearchQuery: string; - scrollPosition: number; - currentPage: number; - hasNextPage: boolean; - totalCount: number; - isLoadingMore: boolean; - savedSounds: SavedSound[]; - isSavedSoundsLoaded: boolean; - isLoadingSavedSounds: boolean; - savedSoundsError: string | null; - - addSoundToTimeline: ({ sound }: { sound: SoundEffect }) => Promise; - setTopSoundEffects: ({ sounds }: { sounds: SoundEffect[] }) => void; - setLoading: ({ loading }: { loading: boolean }) => void; - setError: ({ error }: { error: string | null }) => void; - setHasLoaded: ({ loaded }: { loaded: boolean }) => void; - setSearchQuery: ({ query }: { query: string }) => void; - setSearchResults: ({ results }: { results: SoundEffect[] }) => void; - setSearching: ({ searching }: { searching: boolean }) => void; - setSearchError: ({ error }: { error: string | null }) => void; - setLastSearchQuery: ({ query }: { query: string }) => void; - setScrollPosition: ({ position }: { position: number }) => void; - setCurrentPage: ({ page }: { page: number }) => void; - setHasNextPage: ({ hasNext }: { hasNext: boolean }) => void; - setTotalCount: ({ count }: { count: number }) => void; - setLoadingMore: ({ loading }: { loading: boolean }) => void; - appendSearchResults: ({ results }: { results: SoundEffect[] }) => void; - appendTopSounds: ({ results }: { results: SoundEffect[] }) => void; - resetPagination: () => void; - loadSavedSounds: () => Promise; - saveSoundEffect: ({ - soundEffect, - }: { - soundEffect: SoundEffect; - }) => Promise; - removeSavedSound: ({ soundId }: { soundId: number }) => Promise; - isSoundSaved: ({ soundId }: { soundId: number }) => boolean; - toggleSavedSound: ({ - soundEffect, - }: { - soundEffect: SoundEffect; - }) => Promise; - clearSavedSounds: () => Promise; -} - -export const useSoundsStore = create((set, get) => ({ - topSoundEffects: [], - isLoading: false, - error: null, - hasLoaded: false, - showCommercialOnly: true, - - toggleCommercialFilter: () => { - set((state) => ({ showCommercialOnly: !state.showCommercialOnly })); - }, - - searchQuery: "", - searchResults: [], - isSearching: false, - searchError: null, - lastSearchQuery: "", - scrollPosition: 0, - currentPage: 1, - hasNextPage: false, - totalCount: 0, - isLoadingMore: false, - savedSounds: [], - isSavedSoundsLoaded: false, - isLoadingSavedSounds: false, - savedSoundsError: null, - - setTopSoundEffects: ({ sounds }) => set({ topSoundEffects: sounds }), - setLoading: ({ loading }) => set({ isLoading: loading }), - setError: ({ error }) => set({ error }), - setHasLoaded: ({ loaded }) => set({ hasLoaded: loaded }), - setSearchQuery: ({ query }) => set({ searchQuery: query }), - setSearchResults: ({ results }) => - set({ searchResults: results, currentPage: 1 }), - setSearching: ({ searching }) => set({ isSearching: searching }), - setSearchError: ({ error }) => set({ searchError: error }), - setLastSearchQuery: ({ query }) => set({ lastSearchQuery: query }), - setScrollPosition: ({ position }) => set({ scrollPosition: position }), - setCurrentPage: ({ page }) => set({ currentPage: page }), - setHasNextPage: ({ hasNext }) => set({ hasNextPage: hasNext }), - setTotalCount: ({ count }) => set({ totalCount: count }), - setLoadingMore: ({ loading }) => set({ isLoadingMore: loading }), - - appendSearchResults: ({ results }) => - set((state) => ({ - searchResults: [...state.searchResults, ...results], - })), - - appendTopSounds: ({ results }) => - set((state) => ({ - topSoundEffects: [...state.topSoundEffects, ...results], - })), - - resetPagination: () => - set({ - currentPage: 1, - hasNextPage: false, - totalCount: 0, - isLoadingMore: false, - }), - - loadSavedSounds: async () => { - if (get().isSavedSoundsLoaded) return; - - try { - set({ isLoadingSavedSounds: true, savedSoundsError: null }); - const savedSoundsData = await storageService.loadSavedSounds(); - set({ - savedSounds: savedSoundsData.sounds, - isSavedSoundsLoaded: true, - isLoadingSavedSounds: false, - }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Failed to load saved sounds"; - set({ - savedSoundsError: errorMessage, - isLoadingSavedSounds: false, - }); - console.error("Failed to load saved sounds:", error); - } - }, - - saveSoundEffect: async ({ soundEffect }) => { - try { - await storageService.saveSoundEffect({ soundEffect }); - - const savedSoundsData = await storageService.loadSavedSounds(); - set({ savedSounds: savedSoundsData.sounds }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Failed to save sound"; - set({ savedSoundsError: errorMessage }); - toast.error("Failed to save sound"); - console.error("Failed to save sound:", error); - } - }, - - removeSavedSound: async ({ soundId }) => { - try { - await storageService.removeSavedSound({ soundId }); - - set((state) => ({ - savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId), - })); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Failed to remove sound"; - set({ savedSoundsError: errorMessage }); - toast.error("Failed to remove sound"); - console.error("Failed to remove sound:", error); - } - }, - - isSoundSaved: ({ soundId }) => { - const { savedSounds } = get(); - return savedSounds.some((sound) => sound.id === soundId); - }, - - toggleSavedSound: async ({ soundEffect }) => { - const { isSoundSaved, saveSoundEffect, removeSavedSound } = get(); - - if (isSoundSaved({ soundId: soundEffect.id })) { - await removeSavedSound({ soundId: soundEffect.id }); - } else { - await saveSoundEffect({ soundEffect }); - } - }, - - clearSavedSounds: async () => { - try { - await storageService.clearSavedSounds(); - set({ - savedSounds: [], - savedSoundsError: null, - }); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Failed to clear saved sounds"; - set({ savedSoundsError: errorMessage }); - toast.error("Failed to clear saved sounds"); - console.error("Failed to clear saved sounds:", error); - } - }, - - addSoundToTimeline: async ({ sound }) => { - const audioUrl = sound.previewUrl; - if (!audioUrl) { - toast.error("Sound file not available"); - return false; - } - - try { - const editor = EditorCore.getInstance(); - const currentTime = editor.playback.getCurrentTime(); - const tracks = editor.timeline.getTracks(); - - const response = await fetch(audioUrl); - if (!response.ok) - throw new Error(`Failed to download audio: ${response.statusText}`); - - const arrayBuffer = await response.arrayBuffer(); - const audioContext = new AudioContext(); - const buffer = await audioContext.decodeAudioData(arrayBuffer); - - const audioTrack = tracks.find((t) => t.type === "audio"); - let trackId: string; - - if (audioTrack) { - trackId = audioTrack.id; - } else { - trackId = editor.timeline.addTrack({ type: "audio" }); - } - - const element = buildLibraryAudioElement({ - sourceUrl: audioUrl, - name: sound.name, - duration: sound.duration, - startTime: currentTime, - buffer, - }); - - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element, - }); - return true; - } catch (error) { - console.error("Failed to add sound to timeline:", error); - toast.error( - error instanceof Error - ? error.message - : "Failed to add sound to timeline", - { id: `sound-${sound.id}` }, - ); - return false; - } - }, -})); +import { create } from "zustand"; +import type { SoundEffect, SavedSound } from "@/types/sounds"; +import { storageService } from "@/services/storage/service"; +import { toast } from "sonner"; +import { EditorCore } from "@/core"; +import { buildLibraryAudioElement } from "@/lib/timeline/element-utils"; + +interface SoundsStore { + topSoundEffects: SoundEffect[]; + isLoading: boolean; + error: string | null; + hasLoaded: boolean; + showCommercialOnly: boolean; + toggleCommercialFilter: () => void; + searchQuery: string; + searchResults: SoundEffect[]; + isSearching: boolean; + searchError: string | null; + lastSearchQuery: string; + scrollPosition: number; + currentPage: number; + hasNextPage: boolean; + totalCount: number; + isLoadingMore: boolean; + savedSounds: SavedSound[]; + isSavedSoundsLoaded: boolean; + isLoadingSavedSounds: boolean; + savedSoundsError: string | null; + + addSoundToTimeline: ({ sound }: { sound: SoundEffect }) => Promise; + setTopSoundEffects: ({ sounds }: { sounds: SoundEffect[] }) => void; + setLoading: ({ loading }: { loading: boolean }) => void; + setError: ({ error }: { error: string | null }) => void; + setHasLoaded: ({ loaded }: { loaded: boolean }) => void; + setSearchQuery: ({ query }: { query: string }) => void; + setSearchResults: ({ results }: { results: SoundEffect[] }) => void; + setSearching: ({ searching }: { searching: boolean }) => void; + setSearchError: ({ error }: { error: string | null }) => void; + setLastSearchQuery: ({ query }: { query: string }) => void; + setScrollPosition: ({ position }: { position: number }) => void; + setCurrentPage: ({ page }: { page: number }) => void; + setHasNextPage: ({ hasNext }: { hasNext: boolean }) => void; + setTotalCount: ({ count }: { count: number }) => void; + setLoadingMore: ({ loading }: { loading: boolean }) => void; + appendSearchResults: ({ results }: { results: SoundEffect[] }) => void; + appendTopSounds: ({ results }: { results: SoundEffect[] }) => void; + resetPagination: () => void; + loadSavedSounds: () => Promise; + saveSoundEffect: ({ + soundEffect, + }: { + soundEffect: SoundEffect; + }) => Promise; + removeSavedSound: ({ soundId }: { soundId: number }) => Promise; + isSoundSaved: ({ soundId }: { soundId: number }) => boolean; + toggleSavedSound: ({ + soundEffect, + }: { + soundEffect: SoundEffect; + }) => Promise; + clearSavedSounds: () => Promise; +} + +export const useSoundsStore = create((set, get) => ({ + topSoundEffects: [], + isLoading: false, + error: null, + hasLoaded: false, + showCommercialOnly: true, + + toggleCommercialFilter: () => { + set((state) => ({ showCommercialOnly: !state.showCommercialOnly })); + }, + + searchQuery: "", + searchResults: [], + isSearching: false, + searchError: null, + lastSearchQuery: "", + scrollPosition: 0, + currentPage: 1, + hasNextPage: false, + totalCount: 0, + isLoadingMore: false, + savedSounds: [], + isSavedSoundsLoaded: false, + isLoadingSavedSounds: false, + savedSoundsError: null, + + setTopSoundEffects: ({ sounds }) => set({ topSoundEffects: sounds }), + setLoading: ({ loading }) => set({ isLoading: loading }), + setError: ({ error }) => set({ error }), + setHasLoaded: ({ loaded }) => set({ hasLoaded: loaded }), + setSearchQuery: ({ query }) => set({ searchQuery: query }), + setSearchResults: ({ results }) => + set({ searchResults: results, currentPage: 1 }), + setSearching: ({ searching }) => set({ isSearching: searching }), + setSearchError: ({ error }) => set({ searchError: error }), + setLastSearchQuery: ({ query }) => set({ lastSearchQuery: query }), + setScrollPosition: ({ position }) => set({ scrollPosition: position }), + setCurrentPage: ({ page }) => set({ currentPage: page }), + setHasNextPage: ({ hasNext }) => set({ hasNextPage: hasNext }), + setTotalCount: ({ count }) => set({ totalCount: count }), + setLoadingMore: ({ loading }) => set({ isLoadingMore: loading }), + + appendSearchResults: ({ results }) => + set((state) => ({ + searchResults: [...state.searchResults, ...results], + })), + + appendTopSounds: ({ results }) => + set((state) => ({ + topSoundEffects: [...state.topSoundEffects, ...results], + })), + + resetPagination: () => + set({ + currentPage: 1, + hasNextPage: false, + totalCount: 0, + isLoadingMore: false, + }), + + loadSavedSounds: async () => { + if (get().isSavedSoundsLoaded) return; + + try { + set({ isLoadingSavedSounds: true, savedSoundsError: null }); + const savedSoundsData = await storageService.loadSavedSounds(); + set({ + savedSounds: savedSoundsData.sounds, + isSavedSoundsLoaded: true, + isLoadingSavedSounds: false, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to load saved sounds"; + set({ + savedSoundsError: errorMessage, + isLoadingSavedSounds: false, + }); + console.error("Failed to load saved sounds:", error); + } + }, + + saveSoundEffect: async ({ soundEffect }) => { + try { + await storageService.saveSoundEffect({ soundEffect }); + + const savedSoundsData = await storageService.loadSavedSounds(); + set({ savedSounds: savedSoundsData.sounds }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to save sound"; + set({ savedSoundsError: errorMessage }); + toast.error("Failed to save sound"); + console.error("Failed to save sound:", error); + } + }, + + removeSavedSound: async ({ soundId }) => { + try { + await storageService.removeSavedSound({ soundId }); + + set((state) => ({ + savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId), + })); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to remove sound"; + set({ savedSoundsError: errorMessage }); + toast.error("Failed to remove sound"); + console.error("Failed to remove sound:", error); + } + }, + + isSoundSaved: ({ soundId }) => { + const { savedSounds } = get(); + return savedSounds.some((sound) => sound.id === soundId); + }, + + toggleSavedSound: async ({ soundEffect }) => { + const { isSoundSaved, saveSoundEffect, removeSavedSound } = get(); + + if (isSoundSaved({ soundId: soundEffect.id })) { + await removeSavedSound({ soundId: soundEffect.id }); + } else { + await saveSoundEffect({ soundEffect }); + } + }, + + clearSavedSounds: async () => { + try { + await storageService.clearSavedSounds(); + set({ + savedSounds: [], + savedSoundsError: null, + }); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to clear saved sounds"; + set({ savedSoundsError: errorMessage }); + toast.error("Failed to clear saved sounds"); + console.error("Failed to clear saved sounds:", error); + } + }, + + addSoundToTimeline: async ({ sound }) => { + const audioUrl = sound.previewUrl; + if (!audioUrl) { + toast.error("Sound file not available"); + return false; + } + + try { + const editor = EditorCore.getInstance(); + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.timeline.getTracks(); + + const response = await fetch(audioUrl); + if (!response.ok) + throw new Error(`Failed to download audio: ${response.statusText}`); + + const arrayBuffer = await response.arrayBuffer(); + const audioContext = new AudioContext(); + const buffer = await audioContext.decodeAudioData(arrayBuffer); + + const audioTrack = tracks.find((t) => t.type === "audio"); + let trackId: string; + + if (audioTrack) { + trackId = audioTrack.id; + } else { + trackId = editor.timeline.addTrack({ type: "audio" }); + } + + const element = buildLibraryAudioElement({ + sourceUrl: audioUrl, + name: sound.name, + duration: sound.duration, + startTime: currentTime, + buffer, + }); + + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); + return true; + } catch (error) { + console.error("Failed to add sound to timeline:", error); + toast.error( + error instanceof Error + ? error.message + : "Failed to add sound to timeline", + { id: `sound-${sound.id}` }, + ); + return false; + } + }, +})); diff --git a/apps/web/src/stores/stickers-store.ts b/apps/web/src/stores/stickers-store.ts index edc78220..3f713b3d 100644 --- a/apps/web/src/stores/stickers-store.ts +++ b/apps/web/src/stores/stickers-store.ts @@ -1,176 +1,219 @@ import { create } from "zustand"; -import { - getCollections, - getCollection, - searchIcons, - type IconSet, - type CollectionInfo, - type IconSearchResult, -} from "@/lib/iconify-api"; +import { persist } from "zustand/middleware"; import { EditorCore } from "@/core"; +import { searchStickers as searchStickersFromProviders } from "@/lib/stickers"; +import type { StickerSearchResult } from "@/lib/stickers"; import { buildStickerElement } from "@/lib/timeline/element-utils"; -import { STICKER_CATEGORY_CONFIG } from "@/constants/stickers-constants"; +import { STICKER_CATEGORIES } from "@/constants/sticker-constants"; import type { StickerCategory } from "@/types/stickers"; +import { registerDefaultStickerProviders } from "@/lib/stickers/providers"; +import { hasProvider } from "@/lib/stickers/registry"; +import { parseStickerId } from "@/lib/stickers/sticker-id"; -type ViewMode = "search" | "browse" | "collection"; +const MAX_RECENT_STICKERS = 50; + +function isValidStickerId(value: unknown): value is string { + if (typeof value !== "string") { + return false; + } + + try { + const parsed = parseStickerId({ stickerId: value }); + return hasProvider({ providerId: parsed.providerId }); + } catch { + return false; + } +} + +function sanitizeRecentStickers({ + recentStickers, +}: { + recentStickers: unknown; +}): string[] { + registerDefaultStickerProviders({}); + + if (!Array.isArray(recentStickers)) { + return []; + } + + const sanitized: string[] = []; + for (const stickerId of recentStickers) { + if (!isValidStickerId(stickerId)) { + continue; + } + if (sanitized.includes(stickerId)) { + continue; + } + sanitized.push(stickerId); + if (sanitized.length >= MAX_RECENT_STICKERS) { + break; + } + } + + return sanitized; +} + +type ViewMode = "search" | "browse"; interface StickersStore { searchQuery: string; selectedCategory: StickerCategory; - selectedCollection: string | null; viewMode: ViewMode; - collections: Record; - currentCollection: CollectionInfo | null; - searchResults: IconSearchResult | null; + searchResults: StickerSearchResult | null; recentStickers: string[]; - isLoadingCollections: boolean; - isLoadingCollection: boolean; isSearching: boolean; addingSticker: string | null; setSearchQuery: ({ query }: { query: string }) => void; setSelectedCategory: ({ category }: { category: StickerCategory }) => void; - setSelectedCollection: ({ - collection, + searchStickers: ({ query }: { query: string }) => Promise; + addStickerToTimeline: ({ + stickerId, + name, }: { - collection: string | null; + stickerId: string; + name?: string; }) => void; - setViewMode: ({ mode }: { mode: ViewMode }) => void; - loadCollections: () => Promise; - loadCollection: ({ prefix }: { prefix: string }) => Promise; - searchStickers: ({ query }: { query: string }) => Promise; - addStickerToTimeline: ({ iconName }: { iconName: string }) => void; - addToRecentStickers: ({ iconName }: { iconName: string }) => void; + addToRecentStickers: ({ stickerId }: { stickerId: string }) => void; clearRecentStickers: () => void; } -const MAX_RECENT_STICKERS = 50; - -export const useStickersStore = create((set, get) => ({ - searchQuery: "", - selectedCategory: "all", - selectedCollection: null, - viewMode: "browse", - - collections: {}, - currentCollection: null, - searchResults: null, - recentStickers: [], - - isLoadingCollections: false, - isLoadingCollection: false, - isSearching: false, - addingSticker: null, - - setSearchQuery: ({ query }) => set({ searchQuery: query }), - - setSelectedCategory: ({ category }) => - set({ - selectedCategory: category, +export const useStickersStore = create()( + persist( + (set, get) => ({ + searchQuery: "", + selectedCategory: "all", viewMode: "browse", - selectedCollection: null, - currentCollection: null, - }), - - setSelectedCollection: ({ collection }) => { - set({ - selectedCollection: collection, - viewMode: collection ? "collection" : "browse", - currentCollection: null, - }); - - if (collection) { - get().loadCollection({ prefix: collection }); - } - }, - - setViewMode: ({ mode }) => set({ viewMode: mode }), - - loadCollections: async () => { - set({ isLoadingCollections: true }); - try { - const collections = await getCollections(); - set({ collections }); - } catch (error) { - console.error("Failed to load collections:", error); - } finally { - set({ isLoadingCollections: false }); - } - }, - - loadCollection: async ({ prefix }: { prefix: string }) => { - set({ isLoadingCollection: true }); - try { - const collection = await getCollection(prefix); - set({ currentCollection: collection }); - } catch (error) { - console.error(`Failed to load collection ${prefix}:`, error); - set({ currentCollection: null }); - } finally { - set({ isLoadingCollection: false }); - } - }, - - searchStickers: async ({ query }: { query: string }) => { - if (!query.trim()) { - set({ searchResults: null, viewMode: "browse" }); - return; - } - const { selectedCategory } = get(); - - set({ isSearching: true, viewMode: "search" }); - try { - const category = STICKER_CATEGORY_CONFIG[selectedCategory]; - const results = await searchIcons(query, 100, undefined, category); - set({ searchResults: results }); - } catch (error) { - console.error("Search failed:", error); - set({ searchResults: null }); - } finally { - set({ isSearching: false }); - } - }, - - addStickerToTimeline: ({ iconName }: { iconName: string }) => { - set({ addingSticker: iconName }); - try { - const editor = EditorCore.getInstance(); - const currentTime = editor.playback.getCurrentTime(); - const tracks = editor.timeline.getTracks(); - - const stickerTrack = tracks.find((t) => t.type === "sticker"); - let trackId: string; - - if (stickerTrack) { - trackId = stickerTrack.id; - } else { - trackId = editor.timeline.addTrack({ type: "sticker" }); - } - - const element = buildStickerElement({ iconName, startTime: currentTime }); - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element, - }); - - get().addToRecentStickers({ iconName }); - } finally { - set({ addingSticker: null }); - } - }, - - addToRecentStickers: ({ iconName }: { iconName: string }) => { - set((state) => { - const recent = [ - iconName, - ...state.recentStickers.filter((s) => s !== iconName), - ]; - return { - recentStickers: recent.slice(0, MAX_RECENT_STICKERS), - }; - }); - }, - - clearRecentStickers: () => set({ recentStickers: [] }), -})); + searchResults: null, + recentStickers: [], + + isSearching: false, + addingSticker: null, + + setSearchQuery: ({ query }) => set({ searchQuery: query }), + + setSelectedCategory: ({ category }) => + set({ + selectedCategory: category in STICKER_CATEGORIES ? category : "all", + viewMode: "browse", + }), + + searchStickers: async ({ query }: { query: string }) => { + if (!query.trim()) { + set({ searchResults: null, viewMode: "browse" }); + return; + } + + const category = get().selectedCategory; + const selectedCategory = + category in STICKER_CATEGORIES ? category : "all"; + + set({ isSearching: true, viewMode: "search" }); + try { + const results = await searchStickersFromProviders({ + query, + category: selectedCategory, + limit: 100, + }); + set({ searchResults: results }); + } catch (error) { + console.error("Search failed:", error); + set({ searchResults: null }); + } finally { + set({ isSearching: false }); + } + }, + + addStickerToTimeline: ({ + stickerId, + name, + }: { + stickerId: string; + name?: string; + }) => { + set({ addingSticker: stickerId }); + try { + const editor = EditorCore.getInstance(); + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.timeline.getTracks(); + + const stickerTrack = tracks.find((t) => t.type === "sticker"); + let trackId: string; + + if (stickerTrack) { + trackId = stickerTrack.id; + } else { + trackId = editor.timeline.addTrack({ type: "sticker" }); + } + + const element = buildStickerElement({ + stickerId, + name, + startTime: currentTime, + }); + editor.timeline.insertElement({ + placement: { mode: "explicit", trackId }, + element, + }); + + get().addToRecentStickers({ stickerId }); + } finally { + set({ addingSticker: null }); + } + }, + + addToRecentStickers: ({ stickerId }: { stickerId: string }) => { + const sanitizedStickerIds = sanitizeRecentStickers({ + recentStickers: [stickerId], + }); + if (sanitizedStickerIds.length === 0) { + return; + } + + set((state) => { + const recent = [ + sanitizedStickerIds[0], + ...state.recentStickers.filter((s) => s !== sanitizedStickerIds[0]), + ]; + return { + recentStickers: recent.slice(0, MAX_RECENT_STICKERS), + }; + }); + }, + + clearRecentStickers: () => set({ recentStickers: [] }), + }), + { + name: "stickers-settings", + migrate: (persistedState) => { + if ( + typeof persistedState === "object" && + persistedState !== null && + "selectedCategory" in persistedState + ) { + const typedState = persistedState as { + selectedCategory?: string; + recentStickers?: string[]; + }; + const category = typedState.selectedCategory ?? "all"; + return { + ...typedState, + selectedCategory: + category in STICKER_CATEGORIES + ? (category as StickerCategory) + : "all", + recentStickers: sanitizeRecentStickers({ + recentStickers: typedState.recentStickers ?? [], + }), + }; + } + return persistedState; + }, + partialize: (state) => ({ + selectedCategory: state.selectedCategory, + recentStickers: state.recentStickers, + }), + }, + ), +); -- cgit v1.3.1