diff options
Diffstat (limited to 'apps/web/src/stores')
| -rw-r--r-- | apps/web/src/stores/editor-store.ts | 68 | ||||
| -rw-r--r-- | apps/web/src/stores/keybindings-store.ts | 5 | ||||
| -rw-r--r-- | apps/web/src/stores/keybindings/migrations/index.ts | 6 | ||||
| -rw-r--r-- | apps/web/src/stores/keybindings/migrations/v3-to-v4.ts | 25 | ||||
| -rw-r--r-- | apps/web/src/stores/preview-store.ts | 94 | ||||
| -rw-r--r-- | apps/web/src/stores/sounds-store.ts | 522 | ||||
| -rw-r--r-- | apps/web/src/stores/stickers-store.ts | 321 |
7 files changed, 578 insertions, 463 deletions
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<void>; - setLayoutGuide: (settings: Partial<LayoutGuideSettings>) => void; - toggleLayoutGuide: (platform: TPlatformLayout) => void; } -export const useEditorStore = create<EditorState>()( - 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<EditorState>()((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<number, MigrationFn> = { 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<string, string> = { + "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<LayoutGuideSettings>) => 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<PreviewState>()( + 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<boolean>;
- 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<void>;
- saveSoundEffect: ({
- soundEffect,
- }: {
- soundEffect: SoundEffect;
- }) => Promise<void>;
- removeSavedSound: ({ soundId }: { soundId: number }) => Promise<void>;
- isSoundSaved: ({ soundId }: { soundId: number }) => boolean;
- toggleSavedSound: ({
- soundEffect,
- }: {
- soundEffect: SoundEffect;
- }) => Promise<void>;
- clearSavedSounds: () => Promise<void>;
-}
-
-export const useSoundsStore = create<SoundsStore>((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<boolean>; + 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<void>; + saveSoundEffect: ({ + soundEffect, + }: { + soundEffect: SoundEffect; + }) => Promise<void>; + removeSavedSound: ({ soundId }: { soundId: number }) => Promise<void>; + isSoundSaved: ({ soundId }: { soundId: number }) => boolean; + toggleSavedSound: ({ + soundEffect, + }: { + soundEffect: SoundEffect; + }) => Promise<void>; + clearSavedSounds: () => Promise<void>; +} + +export const useSoundsStore = create<SoundsStore>((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<string, IconSet>; - 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<void>; + addStickerToTimeline: ({ + stickerId, + name, }: { - collection: string | null; + stickerId: string; + name?: string; }) => void; - setViewMode: ({ mode }: { mode: ViewMode }) => void; - loadCollections: () => Promise<void>; - loadCollection: ({ prefix }: { prefix: string }) => Promise<void>; - searchStickers: ({ query }: { query: string }) => Promise<void>; - 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<StickersStore>((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<StickersStore>()( + persist( + (set, get) => ({ + searchQuery: "", + selectedCategory: "all", viewMode: "browse", - selectedCollection: null, - currentCollection: null, - }), - setSelectedCollection: ({ collection }) => { - set({ - selectedCollection: collection, - viewMode: collection ? "collection" : "browse", - currentCollection: null, - }); + searchResults: null, + recentStickers: [], - if (collection) { - get().loadCollection({ prefix: collection }); - } - }, + isSearching: false, + addingSticker: null, - setViewMode: ({ mode }) => set({ viewMode: mode }), + setSearchQuery: ({ query }) => set({ searchQuery: query }), - 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 }); - } - }, + setSelectedCategory: ({ category }) => + set({ + selectedCategory: category in STICKER_CATEGORIES ? category : "all", + viewMode: "browse", + }), - 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; + } - 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"; - const { selectedCategory } = get(); + 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 }); + } + }, - 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: ({ + stickerId, + name, + }: { + stickerId: string; + name?: string; + }) => { + set({ addingSticker: stickerId }); + try { + const editor = EditorCore.getInstance(); + const currentTime = editor.playback.getCurrentTime(); + const tracks = editor.timeline.getTracks(); - 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; - const stickerTrack = tracks.find((t) => t.type === "sticker"); - let trackId: string; + if (stickerTrack) { + trackId = stickerTrack.id; + } else { + trackId = editor.timeline.addTrack({ type: "sticker" }); + } - 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, + }); - const element = buildStickerElement({ iconName, startTime: currentTime }); - editor.timeline.insertElement({ - placement: { mode: "explicit", trackId }, - element, - }); + get().addToRecentStickers({ stickerId }); + } finally { + set({ addingSticker: null }); + } + }, - get().addToRecentStickers({ iconName }); - } finally { - set({ addingSticker: null }); - } - }, + addToRecentStickers: ({ stickerId }: { stickerId: string }) => { + const sanitizedStickerIds = sanitizeRecentStickers({ + recentStickers: [stickerId], + }); + if (sanitizedStickerIds.length === 0) { + return; + } - addToRecentStickers: ({ iconName }: { iconName: string }) => { - set((state) => { - const recent = [ - iconName, - ...state.recentStickers.filter((s) => s !== iconName), - ]; - return { - recentStickers: recent.slice(0, MAX_RECENT_STICKERS), - }; - }); - }, + set((state) => { + const recent = [ + sanitizedStickerIds[0], + ...state.recentStickers.filter((s) => s !== sanitizedStickerIds[0]), + ]; + return { + recentStickers: recent.slice(0, MAX_RECENT_STICKERS), + }; + }); + }, - clearRecentStickers: () => set({ recentStickers: [] }), -})); + 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, + }), + }, + ), +); |
