summaryrefslogtreecommitdiff
path: root/apps/web/src/stores
diff options
context:
space:
mode:
authorMaze <167211895+mazeincoding@users.noreply.github.com>2026-02-23 03:24:02 +0100
committerGitHub <noreply@github.com>2026-02-23 03:24:02 +0100
commit93d1e3383ce47e45157d70f9ba6251239dae6bc2 (patch)
tree1e33a2d7533b65f0e7c8927f03905a1a99937bd1 /apps/web/src/stores
parentfca99d6126c31fbb18ed9f1034cee6f940b040e8 (diff)
downloadopencut-93d1e3383ce47e45157d70f9ba6251239dae6bc2.tar.gz
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
Diffstat (limited to 'apps/web/src/stores')
-rw-r--r--apps/web/src/stores/editor-store.ts68
-rw-r--r--apps/web/src/stores/keybindings-store.ts5
-rw-r--r--apps/web/src/stores/keybindings/migrations/index.ts6
-rw-r--r--apps/web/src/stores/keybindings/migrations/v3-to-v4.ts25
-rw-r--r--apps/web/src/stores/preview-store.ts94
-rw-r--r--apps/web/src/stores/sounds-store.ts522
-rw-r--r--apps/web/src/stores/stickers-store.ts321
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,
+ }),
+ },
+ ),
+);