summaryrefslogtreecommitdiff
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
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
-rw-r--r--.cursor/commands/review.md33
-rw-r--r--.cursor/rules/codebase-index.mdc2315
-rw-r--r--.cursor/settings.json7
-rw-r--r--.github/workflows/bun-ci.yml11
-rw-r--r--.vscode/settings.json36
-rw-r--r--apps/web/.gitignore4
-rw-r--r--apps/web/components.json38
-rw-r--r--apps/web/drizzle.config.ts24
-rw-r--r--apps/web/migrations/meta/0000_snapshot.json684
-rw-r--r--apps/web/migrations/meta/_journal.json22
-rw-r--r--apps/web/next.config.ts84
-rw-r--r--apps/web/package.json201
-rw-r--r--apps/web/postcss.config.mjs6
-rw-r--r--apps/web/public/countries.json1794
-rw-r--r--apps/web/public/fonts/font-atlas.json11348
-rw-r--r--apps/web/public/fonts/font-chunk-0.avifbin0 -> 140054 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-1.avifbin0 -> 178357 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-10.avifbin0 -> 183944 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-11.avifbin0 -> 155514 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-12.avifbin0 -> 159098 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-13.avifbin0 -> 141771 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-14.avifbin0 -> 91326 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-2.avifbin0 -> 150129 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-3.avifbin0 -> 138705 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-4.avifbin0 -> 141217 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-5.avifbin0 -> 132681 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-6.avifbin0 -> 126973 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-7.avifbin0 -> 141286 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-8.avifbin0 -> 136232 bytes
-rw-r--r--apps/web/public/fonts/font-chunk-9.avifbin0 -> 144794 bytes
-rw-r--r--apps/web/public/manifest.json84
-rw-r--r--apps/web/public/shapes/circle.svg3
-rw-r--r--apps/web/public/shapes/diamond.svg3
-rw-r--r--apps/web/public/shapes/hexagon.svg3
-rw-r--r--apps/web/public/shapes/square.svg3
-rw-r--r--apps/web/public/shapes/star.svg3
-rw-r--r--apps/web/public/shapes/triangle.svg3
-rw-r--r--apps/web/scripts/generate-font-sprites.ts281
-rw-r--r--apps/web/src/app/api/sounds/search/route.ts560
-rw-r--r--apps/web/src/app/blog/[slug]/page.tsx4
-rw-r--r--apps/web/src/app/editor/[project_id]/page.tsx2
-rw-r--r--apps/web/src/app/globals.css447
-rw-r--r--apps/web/src/app/projects/page.tsx2
-rw-r--r--apps/web/src/components/editor/editor-header.tsx20
-rw-r--r--apps/web/src/components/editor/export-button.tsx147
-rw-r--r--apps/web/src/components/editor/panels/assets/draggable-item.tsx4
-rw-r--r--apps/web/src/components/editor/panels/assets/index.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/tabbar.tsx3
-rw-r--r--apps/web/src/components/editor/panels/assets/views/assets.tsx (renamed from apps/web/src/components/editor/panels/assets/views/media.tsx)366
-rw-r--r--apps/web/src/components/editor/panels/assets/views/base-view.tsx52
-rw-r--r--apps/web/src/components/editor/panels/assets/views/captions.tsx9
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx332
-rw-r--r--apps/web/src/components/editor/panels/assets/views/settings.tsx354
-rw-r--r--apps/web/src/components/editor/panels/assets/views/sounds.tsx2
-rw-r--r--apps/web/src/components/editor/panels/assets/views/stickers.tsx689
-rw-r--r--apps/web/src/components/editor/panels/assets/views/text.tsx6
-rw-r--r--apps/web/src/components/editor/panels/panel-base-view.tsx85
-rw-r--r--apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx59
-rw-r--r--apps/web/src/components/editor/panels/preview/context-menu.tsx35
-rw-r--r--apps/web/src/components/editor/panels/preview/index.tsx181
-rw-r--r--apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx (renamed from apps/web/src/components/editor/layout-guide-overlay.tsx)54
-rw-r--r--apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx52
-rw-r--r--apps/web/src/components/editor/panels/preview/snap-guides.tsx65
-rw-r--r--apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx149
-rw-r--r--apps/web/src/components/editor/panels/preview/toolbar.tsx80
-rw-r--r--apps/web/src/components/editor/panels/preview/transform-handles.tsx283
-rw-r--r--apps/web/src/components/editor/panels/properties/empty-view.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts72
-rw-r--r--apps/web/src/components/editor/panels/properties/property-item.tsx132
-rw-r--r--apps/web/src/components/editor/panels/properties/section.tsx161
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/blending.tsx196
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/index.tsx2
-rw-r--r--apps/web/src/components/editor/panels/properties/sections/transform.tsx248
-rw-r--r--apps/web/src/components/editor/panels/properties/text-properties.tsx806
-rw-r--r--apps/web/src/components/editor/panels/timeline/bookmarks.tsx409
-rw-r--r--apps/web/src/components/editor/panels/timeline/index.tsx29
-rw-r--r--apps/web/src/components/editor/panels/timeline/snap-indicator.tsx2
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-element.tsx25
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx12
-rw-r--r--apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx5
-rw-r--r--apps/web/src/components/gitHub-contribute-section.tsx5
-rw-r--r--apps/web/src/components/providers/editor-provider.tsx2
-rw-r--r--apps/web/src/components/theme-toggle.tsx75
-rw-r--r--apps/web/src/components/ui/alert.tsx11
-rw-r--r--apps/web/src/components/ui/avatar.tsx8
-rw-r--r--apps/web/src/components/ui/breadcrumb.tsx159
-rw-r--r--apps/web/src/components/ui/button.tsx24
-rw-r--r--apps/web/src/components/ui/calendar.tsx4
-rw-r--r--apps/web/src/components/ui/carousel.tsx262
-rw-r--r--apps/web/src/components/ui/chart.tsx370
-rw-r--r--apps/web/src/components/ui/color-picker.tsx472
-rw-r--r--apps/web/src/components/ui/command.tsx152
-rw-r--r--apps/web/src/components/ui/context-menu.tsx179
-rw-r--r--apps/web/src/components/ui/drawer.tsx118
-rw-r--r--apps/web/src/components/ui/dropdown-menu.tsx68
-rw-r--r--apps/web/src/components/ui/font-picker.tsx317
-rw-r--r--apps/web/src/components/ui/input-otp.tsx75
-rw-r--r--apps/web/src/components/ui/input-with-back.tsx173
-rw-r--r--apps/web/src/components/ui/input.tsx15
-rw-r--r--apps/web/src/components/ui/number-field.tsx169
-rw-r--r--apps/web/src/components/ui/pagination.tsx117
-rw-r--r--apps/web/src/components/ui/phone-input.tsx167
-rw-r--r--apps/web/src/components/ui/popover.tsx6
-rw-r--r--apps/web/src/components/ui/prose.tsx91
-rw-r--r--apps/web/src/components/ui/select.tsx70
-rw-r--r--apps/web/src/components/ui/sidebar.tsx725
-rw-r--r--apps/web/src/components/ui/spinner.tsx20
-rw-r--r--apps/web/src/components/ui/split-button.tsx180
-rw-r--r--apps/web/src/components/ui/textarea.tsx4
-rw-r--r--apps/web/src/components/ui/tooltip.tsx1
-rw-r--r--apps/web/src/constants/editor-constants.ts6
-rw-r--r--apps/web/src/constants/font-constants.ts88
-rw-r--r--apps/web/src/constants/site-constants.ts2
-rw-r--r--apps/web/src/constants/sticker-constants.ts8
-rw-r--r--apps/web/src/constants/stickers-constants.ts16
-rw-r--r--apps/web/src/constants/text-constants.ts78
-rw-r--r--apps/web/src/constants/timeline-constants.tsx15
-rw-r--r--apps/web/src/core/managers/audio-manager.ts12
-rw-r--r--apps/web/src/core/managers/commands.ts5
-rw-r--r--apps/web/src/core/managers/project-manager.ts5
-rw-r--r--apps/web/src/core/managers/scenes-manager.ts47
-rw-r--r--apps/web/src/core/managers/timeline-manager.ts53
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-interaction.ts40
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-resize.ts314
-rw-r--r--apps/web/src/hooks/timeline/element/use-element-selection.ts4
-rw-r--r--apps/web/src/hooks/timeline/use-bookmark-drag.ts289
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-drag-drop.ts168
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-playhead.ts40
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-snapping.ts32
-rw-r--r--apps/web/src/hooks/timeline/use-timeline-zoom.ts50
-rw-r--r--apps/web/src/hooks/use-focus-lock.ts79
-rw-r--r--apps/web/src/hooks/use-infinite-scroll.ts70
-rw-r--r--apps/web/src/hooks/use-paste-media.ts99
-rw-r--r--apps/web/src/hooks/use-preview-interaction.ts328
-rw-r--r--apps/web/src/hooks/use-shift-key.ts35
-rw-r--r--apps/web/src/hooks/use-sound-search.ts308
-rw-r--r--apps/web/src/hooks/use-transform-handles.ts331
-rw-r--r--apps/web/src/lib/commands/index.ts1
-rw-r--r--apps/web/src/lib/commands/preview-tracker.ts23
-rw-r--r--apps/web/src/lib/commands/scene/index.ts2
-rw-r--r--apps/web/src/lib/commands/scene/move-bookmark.ts59
-rw-r--r--apps/web/src/lib/commands/scene/update-bookmark.ts55
-rw-r--r--apps/web/src/lib/commands/timeline/clipboard/paste.ts4
-rw-r--r--apps/web/src/lib/commands/timeline/element/duplicate-elements.ts8
-rw-r--r--apps/web/src/lib/commands/timeline/element/insert-element.ts4
-rw-r--r--apps/web/src/lib/commands/timeline/element/split-elements.ts8
-rw-r--r--apps/web/src/lib/commands/timeline/index.ts2
-rw-r--r--apps/web/src/lib/commands/timeline/tracks-snapshot.ts20
-rw-r--r--apps/web/src/lib/fonts/google-fonts.ts90
-rw-r--r--apps/web/src/lib/preview/element-bounds.ts219
-rw-r--r--apps/web/src/lib/preview/hit-test.ts60
-rw-r--r--apps/web/src/lib/preview/preview-coords.ts76
-rw-r--r--apps/web/src/lib/preview/preview-snap.ts292
-rw-r--r--apps/web/src/lib/stickers/__tests__/sticker-id.test.ts34
-rw-r--r--apps/web/src/lib/stickers/index.ts175
-rw-r--r--apps/web/src/lib/stickers/providers/emoji.ts119
-rw-r--r--apps/web/src/lib/stickers/providers/flags.ts167
-rw-r--r--apps/web/src/lib/stickers/providers/icons.ts122
-rw-r--r--apps/web/src/lib/stickers/providers/index.ts26
-rw-r--r--apps/web/src/lib/stickers/providers/shapes.ts118
-rw-r--r--apps/web/src/lib/stickers/registry.ts31
-rw-r--r--apps/web/src/lib/stickers/resolver.ts22
-rw-r--r--apps/web/src/lib/stickers/sticker-id.ts34
-rw-r--r--apps/web/src/lib/stickers/types.ts8
-rw-r--r--apps/web/src/lib/timeline/bookmarks.ts112
-rw-r--r--apps/web/src/lib/timeline/drag-utils.ts19
-rw-r--r--apps/web/src/lib/timeline/element-utils.ts96
-rw-r--r--apps/web/src/proxy.ts (renamed from apps/web/src/middleware.ts)2
-rw-r--r--apps/web/src/services/renderer/nodes/sticker-node.ts14
-rw-r--r--apps/web/src/services/renderer/nodes/text-node.ts173
-rw-r--r--apps/web/src/services/renderer/nodes/visual-node.ts7
-rw-r--r--apps/web/src/services/renderer/scene-builder.ts6
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts1
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts54
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts140
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts137
-rw-r--r--apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts92
-rw-r--r--apps/web/src/services/storage/migrations/index.ts8
-rw-r--r--apps/web/src/services/storage/migrations/runner.ts6
-rw-r--r--apps/web/src/services/storage/migrations/transformers/index.ts2
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts189
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts177
-rw-r--r--apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts88
-rw-r--r--apps/web/src/services/storage/migrations/v3-to-v4.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v4-to-v5.ts16
-rw-r--r--apps/web/src/services/storage/migrations/v5-to-v6.ts16
-rw-r--r--apps/web/src/services/storage/service.ts27
-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
-rw-r--r--apps/web/src/types/drag.ts2
-rw-r--r--apps/web/src/types/eyedropper.d.ts13
-rw-r--r--apps/web/src/types/fonts.ts24
-rw-r--r--apps/web/src/types/rendering.ts18
-rw-r--r--apps/web/src/types/sounds.ts78
-rw-r--r--apps/web/src/types/stickers.ts55
-rw-r--r--apps/web/src/types/timeline.ts22
-rw-r--r--apps/web/src/utils/color.ts182
-rw-r--r--apps/web/src/utils/math.ts16
-rw-r--r--apps/web/tsconfig.json72
-rw-r--r--biome.json4
-rw-r--r--bun.lock287
-rw-r--r--docs/countries-search.md51
-rw-r--r--package.json61
-rw-r--r--packages/env/package.json38
-rw-r--r--packages/env/src/tools.ts36
-rw-r--r--packages/ui/package.json34
-rw-r--r--packages/ui/src/icons/ui.tsx173
-rw-r--r--packages/ui/tsconfig.json36
-rw-r--r--tsconfig.json10
-rw-r--r--turbo.json84
215 files changed, 26913 insertions, 8297 deletions
diff --git a/.cursor/commands/review.md b/.cursor/commands/review.md
index d7c0c45c..ab36f7c4 100644
--- a/.cursor/commands/review.md
+++ b/.cursor/commands/review.md
@@ -49,7 +49,7 @@ Review every point below carefully to ensure files follow consistent code style
- [ ] Readability over brevity — use `element` not `el`, `event` not `e`
- [ ] Booleans are named `isSomething`, `hasSomething`, or `shouldSomething` — not `something`
-- [ ] No title case in text/UI — use `Hello world` not `Hello World`
+- [ ] No title case for multi-word text/UI — use `Hello world` not `Hello World`
## Tailwind & Styling
@@ -61,12 +61,15 @@ Review every point below carefully to ensure files follow consistent code style
<PlusIcon className="!size-6" /> {/* ✅ correct */}
<PlusIcon className="size-6" /> {/* ❌ wrong */}
<PlusIcon className="!size-4" /> {/* ❌ unnecessary, size-4 is default */}
+ <PlusIcon className="size-4" />{" "}
+ {/* ❌ completely wrong, 1) doesn't override and 2) size-4 is default */}
</Button>
```
## State Management (Zustand)
- [ ] React components never use `someStore.getState()` — use the `useSomeStore` hook instead
+- [ ] High-frequency stores (timeline, playback, selections) use selectors — `useStore((s) => s.value)` not `const { value } = useStore()`
- [ ] Store/manager methods are not passed as props — sub-components access them directly
```tsx
@@ -91,6 +94,11 @@ Review every point below carefully to ensure files follow consistent code style
- [ ] Code is scannable — use variables and helper functions to make intent clear at a glance
- [ ] Complex logic is extracted into well-named variables or helpers
+- [ ] No magic numbers or magic values — extract inline literals into named constants
+ - Applies to colors, durations, thresholds, sizes, config values, etc.
+ - If it's domain-specific to one file, a `const` at the top of that file is fine
+ - If it's generic enough, it belongs in `constants/`
+
- [ ] No redundant single/plural function variants — if a function can operate on multiple items, it should accept an array and handle both cases. Don't create `doThing()` + `doThings()`.
```tsx
@@ -116,4 +124,25 @@ Review every point below carefully to ensure files follow consistent code style
---
-> Every decision, every edit must be carefully considered. Everything matters.
+## Review Methodology
+
+Do NOT review by reading the file top-to-bottom and noting what jumps out. Instead:
+
+1. Go through each checklist section **one at a time**
+2. For each section, scan the **entire file** for violations of that specific rule
+3. Only move to the next section after you've exhausted the current one
+4. After all sections are checked, do a final pass: re-read every checklist item and confirm you didn't skip it
+
+Before outputting the table, list each checklist section and confirm you checked it:
+`Signatures ✓ | TypeScript ✓ | JSX ✓ | Organization ✓ | Comments ✓ | Naming ✓ | Tailwind ✓ | State ✓ | Quality ✓ | Keywords ✓`
+
+---
+
+## IMPORTANT: Review Rules
+
+- **ONLY** flag issues that are explicitly covered by a checklist item above.
+- Do **NOT** invent your own rules, suggestions, or "nice to haves" as primary issues.
+- The review output must be a table of issues, each one mapping to a specific checklist item.
+- If something looks off but isn't covered by the checklist, you can mention it as a brief side note at the end — but keep it clearly separate from the actual review. Always default to fixing the issues covered by the checklist above, unless the user says otherwise.
+
+> You WILL miss things if you try to review the whole file in one pass. Iterate rule by rule.
diff --git a/.cursor/rules/codebase-index.mdc b/.cursor/rules/codebase-index.mdc
index c63ad1e7..635e547b 100644
--- a/.cursor/rules/codebase-index.mdc
+++ b/.cursor/rules/codebase-index.mdc
@@ -19,7 +19,6 @@ When implementing new features:
## apps/web/src/constants
editor-constants.ts
- export const PLATFORM_LAYOUTS: Record<TPlatformLayout, string>
export const PANEL_CONFIG
export-constants.ts
@@ -27,19 +26,8 @@ export-constants.ts
export const EXPORT_MIME_TYPES
font-constants.ts
- export interface FontOption {
- value: string
- label: string
- category: "system" | "google" | "custom"
- weights?: number[]
- hasClassName?: boolean
- }
- export const FONT_OPTIONS: FontOption[]
export const DEFAULT_FONT
- export type FontFamily = (typeof FONT_OPTIONS)[number]["value"]
- export const getFontByValue = (value: string): FontOption | undefined => ...
- export const getGoogleFonts = (): FontOption[] => ...
- export const getSystemFonts = (): FontOption[] => ...
+ export const SYSTEM_FONTS
language-constants.ts
export const LANGUAGES
@@ -56,37 +44,43 @@ project-constants.ts
site-constants.ts
export const SITE_URL
export const SITE_INFO
- export type ExternalTool = {
- name: string;
- description: string;
- url: string;
- icon: React.ElementType;
+ export type ExternalTool = {
+ name: string;
+ description: string;
+ url: string;
+ icon: React.ElementType;
}
export const EXTERNAL_TOOLS: ExternalTool[]
export const DEFAULT_LOGO_URL
export const SOCIAL_LINKS
- export type Sponsor = {
- name: string;
- url: string;
- logo: string;
- description: string;
+ export type Sponsor = {
+ name: string;
+ url: string;
+ logo: string;
+ description: string;
}
export const SPONSORS: Sponsor[]
-stickers-constants.ts
+sticker-constants.ts
export const STICKER_CATEGORIES
- export const STICKER_CATEGORY_CONFIG: Record<
- (typeof STICKER_CATEGORIES)[number],
- string | undefined
- >
text-constants.ts
+ export const MIN_FONT_SIZE
+ export const MAX_FONT_SIZE
+ export const FONT_SIZE_SCALE_REFERENCE
+ export const DEFAULT_LETTER_SPACING
+ export const DEFAULT_LINE_HEIGHT
export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id">
timeline-constants.tsx
+ export const DEFAULT_TRANSFORM: Transform
+ export const DEFAULT_OPACITY
+ export const DEFAULT_BLEND_MODE: BlendMode
+ export const DEFAULT_BOOKMARK_COLOR
export const TRACK_COLORS: Record<TrackType, { background: string }>
export const TRACK_HEIGHTS: Record<TrackType, number>
export const TRACK_GAP
+ export const DRAG_THRESHOLD_PX
export const TIMELINE_CONSTANTS
export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState
export const TRACK_ICONS: Record<TrackType, React.ReactNode>
@@ -121,23 +115,50 @@ index.ts
## apps/web/src/hooks
+use-container-size.ts
+ export function useContainerSize({
+ containerRef,
+ }: {
+ containerRef: React.RefObject<HTMLElement | null>;
+ })
+
use-editor.ts
export function useEditor(): EditorCore
use-file-upload.ts
- export function useFileUpload({
- accept,
- multiple,
- onFilesSelected,
+ export function useFileUpload({
+ accept,
+ multiple,
+ onFilesSelected,
}: UseFileUploadOptions = {})
+use-focus-lock.ts
+ export function useFocusLock({
+ isActive,
+ onDismiss,
+ cursor = "default",
+ allowSelector,
+ }: {
+ isActive: boolean;
+ onDismiss: () => void;
+ cursor?: FocusLockCursor;
+ allowSelector?: string;
+ })
+
+use-fullscreen.ts
+ export function useFullscreen({
+ containerRef,
+ }: {
+ containerRef: React.RefObject<HTMLElement | null>;
+ })
+
use-infinite-scroll.ts
- export function useInfiniteScroll({
- onLoadMore,
- hasMore,
- isLoading,
- threshold = 200,
- enabled = true,
+ export function useInfiniteScroll({
+ onLoadMore,
+ hasMore,
+ isLoading,
+ threshold = 200,
+ enabled = true,
}: UseInfiniteScrollOptions)
use-keybindings.ts
@@ -158,32 +179,52 @@ use-keyboard-shortcuts-help.ts
use-mobile.ts
export function useIsMobile()
+use-paste-media.ts
+ export function usePasteMedia()
+
+use-preview-interaction.ts
+ export function usePreviewInteraction({
+ canvasRef,
+ }: {
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ })
+
use-raf-loop.ts
export function useRafLoop(callback: ({ time }: { time: number }) => void)
use-reveal-item.ts
- export function useRevealItem(
- highlightId: string | null,
- onClearHighlight: () => void,
- highlightDuration = 1000,
+ export function useRevealItem(
+ highlightId: string | null,
+ onClearHighlight: () => void,
+ highlightDuration = 1000,
)
+use-shift-key.ts
+ export function useShiftKey(): RefObject<boolean>
+
use-sound-search.ts
- export function useSoundSearch({
- query,
- commercialOnly,
+ export function useSoundSearch({
+ query,
+ commercialOnly,
+ }: {
+ query: string;
+ commercialOnly: boolean;
+ })
+
+use-transform-handles.ts
+ export function useTransformHandles({
+ canvasRef,
}: {
- query: string;
- commercialOnly: boolean;
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
})
## apps/web/src/hooks/actions
use-action-handler.ts
- export function useActionHandler(
- action: A,
- handler: TActionFunc<A>,
- isActive: TActionHandlerOptions,
+ export function useActionHandler(
+ action: A,
+ handler: TActionFunc<A>,
+ isActive: TActionHandlerOptions,
)
use-editor-actions.ts
@@ -192,91 +233,111 @@ use-editor-actions.ts
## apps/web/src/hooks/storage
use-local-storage.ts
- export function useLocalStorage({
- key,
- defaultValue,
- }: {
- key: string;
- defaultValue: T;
- }): [
- T,
- ({ value }: { value: T | ((previousValue: T) => T) }) => void,
- boolean,
+ export function useLocalStorage({
+ key,
+ defaultValue,
+ }: {
+ key: string;
+ defaultValue: T;
+ }): [
+ T,
+ ({ value }: { value: T | ((previousValue: T) => T) }) => void,
+ boolean,
]
## apps/web/src/hooks/timeline
+use-bookmark-drag.ts
+ export interface BookmarkDragState {
+ isDragging: boolean
+ bookmarkTime: number | null
+ currentTime: number
+ }
+ export function useBookmarkDrag({
+ zoomLevel,
+ scrollRef,
+ snappingEnabled,
+ onSnapPointChange,
+ }: UseBookmarkDragProps)
+
use-edge-auto-scroll.ts
- export function useEdgeAutoScroll({
- isActive,
- getMouseClientX,
- rulerScrollRef,
- tracksScrollRef,
- contentWidth,
- edgeThreshold = 100,
- maxScrollSpeed = 15,
+ export function useEdgeAutoScroll({
+ isActive,
+ getMouseClientX,
+ rulerScrollRef,
+ tracksScrollRef,
+ contentWidth,
+ edgeThreshold = 100,
+ maxScrollSpeed = 15,
}: UseEdgeAutoScrollParams): void
+use-scroll-position.ts
+ export function useScrollPosition({
+ scrollRef,
+ }: {
+ scrollRef: React.RefObject<HTMLElement | null>;
+ }): UseScrollPositionReturn
+
use-scroll-sync.ts
- export function useScrollSync({
- tracksScrollRef,
- rulerScrollRef,
- trackLabelsScrollRef,
- bookmarksScrollRef,
+ export function useScrollSync({
+ tracksScrollRef,
+ rulerScrollRef,
+ trackLabelsScrollRef,
+ bookmarksScrollRef,
}: UseScrollSyncProps)
use-selection-box.ts
- export function useSelectionBox({
- containerRef,
- onSelectionComplete,
- isEnabled = true,
- tracksScrollRef,
- zoomLevel,
+ export function useSelectionBox({
+ containerRef,
+ onSelectionComplete,
+ isEnabled = true,
+ tracksScrollRef,
+ zoomLevel,
}: UseSelectionBoxProps)
use-snap-indicator-position.ts
- export function useSnapIndicatorPosition({
- snapPoint,
- zoomLevel,
- tracks,
- timelineRef,
- trackLabelsRef,
- tracksScrollRef,
+ export function useSnapIndicatorPosition({
+ snapPoint,
+ zoomLevel,
+ tracks,
+ timelineRef,
+ trackLabelsRef,
+ tracksScrollRef,
}: UseSnapIndicatorPositionParams): SnapIndicatorPosition
use-timeline-drag-drop.ts
- export function useTimelineDragDrop({
- containerRef,
- headerRef,
- zoomLevel,
+ export function useTimelineDragDrop({
+ containerRef,
+ headerRef,
+ zoomLevel,
}: UseTimelineDragDropProps)
use-timeline-playhead.ts
- export function useTimelinePlayhead({
- zoomLevel,
- rulerRef,
- rulerScrollRef,
- tracksScrollRef,
- playheadRef,
+ export function useTimelinePlayhead({
+ zoomLevel,
+ rulerRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ playheadRef,
}: UseTimelinePlayheadProps)
use-timeline-seek.ts
- export function useTimelineSeek({
- playheadRef,
- trackLabelsRef,
- rulerScrollRef,
- tracksScrollRef,
- zoomLevel,
- duration,
- isSelecting,
- clearSelectedElements,
- seek,
+ export function useTimelineSeek({
+ playheadRef,
+ trackLabelsRef,
+ rulerScrollRef,
+ tracksScrollRef,
+ zoomLevel,
+ duration,
+ isSelecting,
+ clearSelectedElements,
+ seek,
}: UseTimelineSeekProps)
use-timeline-snapping.ts
export interface SnapPoint {
time: number
- type: "element-start" | "element-end" | "playhead"
+ type: "element-start" | "element-end" | "playhead" | "bookmark"
elementId?: string
trackId?: string
}
@@ -289,35 +350,37 @@ use-timeline-snapping.ts
snapThreshold?: number
enableElementSnapping?: boolean
enablePlayheadSnapping?: boolean
+ enableBookmarkSnapping?: boolean
}
- export function useTimelineSnapping({
- snapThreshold = 10,
- enableElementSnapping = true,
- enablePlayheadSnapping = true,
+ export function useTimelineSnapping({
+ snapThreshold = 10,
+ enableElementSnapping = true,
+ enablePlayheadSnapping = true,
+ enableBookmarkSnapping = true,
}: UseTimelineSnappingOptions = {})
use-timeline-zoom.ts
- export function useTimelineZoom({
- containerRef,
- minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
- initialZoom,
- initialScrollLeft,
- initialPlayheadTime,
- tracksScrollRef,
- rulerScrollRef,
+ export function useTimelineZoom({
+ containerRef,
+ minZoom = TIMELINE_CONSTANTS.ZOOM_MIN,
+ initialZoom,
+ initialScrollLeft,
+ initialPlayheadTime,
+ tracksScrollRef,
+ rulerScrollRef,
}: UseTimelineZoomProps): UseTimelineZoomReturn
## apps/web/src/hooks/timeline/element
use-element-interaction.ts
- export function useElementInteraction({
- zoomLevel,
- timelineRef,
- tracksContainerRef,
- tracksScrollRef,
- headerRef,
- snappingEnabled,
- onSnapPointChange,
+ export function useElementInteraction({
+ zoomLevel,
+ timelineRef,
+ tracksContainerRef,
+ tracksScrollRef,
+ headerRef,
+ snappingEnabled,
+ onSnapPointChange,
}: UseElementInteractionProps)
use-element-resize.ts
@@ -330,12 +393,12 @@ use-element-resize.ts
initialStartTime: number
initialDuration: number
}
- export function useTimelineElementResize({
- element,
- track,
- zoomLevel,
- onSnapPointChange,
- onResizeStateChange,
+ export function useTimelineElementResize({
+ element,
+ track,
+ zoomLevel,
+ onSnapPointChange,
+ onResizeStateChange,
}: UseTimelineElementResizeProps)
use-element-selection.ts
@@ -344,35 +407,35 @@ use-element-selection.ts
## apps/web/src/lib
drag-data.ts
- export function setDragData({
- dataTransfer,
- dragData,
- }: {
- dataTransfer: DataTransfer;
- dragData: TimelineDragData;
+ export function setDragData({
+ dataTransfer,
+ dragData,
+ }: {
+ dataTransfer: DataTransfer;
+ dragData: TimelineDragData;
}): void
- export function getDragData({
- dataTransfer,
- }: {
- dataTransfer: DataTransfer;
+ export function getDragData({
+ dataTransfer,
+ }: {
+ dataTransfer: DataTransfer;
}): TimelineDragData | null
- export function hasDragData({
- dataTransfer,
- }: {
- dataTransfer: DataTransfer;
+ export function hasDragData({
+ dataTransfer,
+ }: {
+ dataTransfer: DataTransfer;
}): boolean
export function clearDragData(): void
export.ts
- export function getExportMimeType({
- format,
- }: {
- format: ExportFormat;
+ export function getExportMimeType({
+ format,
+ }: {
+ format: ExportFormat;
}): string
- export function getExportFileExtension({
- format,
- }: {
- format: ExportFormat;
+ export function getExportFileExtension({
+ format,
+ }: {
+ format: ExportFormat;
}): string
iconify-api.ts
@@ -381,14 +444,14 @@ iconify-api.ts
prefix: string
name: string
total: number
- author?: {
- name: string;
- url?: string;
+ author?: {
+ name: string;
+ url?: string;
}
- license?: {
- title: string;
- spdx?: string;
- url?: string;
+ license?: {
+ title: string;
+ spdx?: string;
+ url?: string;
}
samples?: string[]
category?: string
@@ -410,41 +473,41 @@ iconify-api.ts
hidden?: string[]
aliases?: Record<string, string>
}
- export function getCollections(
- category?: string,
+ export function getCollections(
+ category?: string,
): Promise<Record<string, IconSet>>
- export function getCollection(
- prefix: string,
+ export function getCollection(
+ prefix: string,
): Promise<CollectionInfo | null>
- export function searchIcons(
- query: string,
- limit: number = 64,
- prefixes?: string[],
- category?: string,
+ export function searchIcons(
+ query: string,
+ limit: number = 64,
+ prefixes?: string[],
+ category?: string,
): Promise<IconSearchResult>
- export function buildIconSvgUrl(
- host: string,
- iconName: string,
- params?: {
- color?: string;
- width?: number;
- height?: number;
- flip?: "horizontal" | "vertical" | "horizontal,vertical";
- rotate?: number | string;
- },
+ export function buildIconSvgUrl(
+ host: string,
+ iconName: string,
+ params?: {
+ color?: string;
+ width?: number;
+ height?: number;
+ flip?: "horizontal" | "vertical" | "horizontal,vertical";
+ rotate?: number | string;
+ },
): string
- export function getIconSvgUrl(
- iconName: string,
- params?: Parameters<typeof buildIconSvgUrl>[2],
+ export function getIconSvgUrl(
+ iconName: string,
+ params?: Parameters<typeof buildIconSvgUrl>[2],
): string
- export function downloadSvgAsText(
- iconName: string,
- params?: Parameters<typeof getIconSvgUrl>[1],
+ export function downloadSvgAsText(
+ iconName: string,
+ params?: Parameters<typeof getIconSvgUrl>[1],
): Promise<string>
export function svgToFile(svgText: string, fileName: string): File
export const POPULAR_COLLECTIONS
- export function getCategoriesFromCollections(
- collections: Record<string, IconSet>,
+ export function getCategoriesFromCollections(
+ collections: Record<string, IconSet>,
): string[]
rate-limit.ts
@@ -454,127 +517,127 @@ rate-limit.ts
scenes.ts
export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null
export function ensureMainScene({ scenes }: { scenes: TScene[] }): TScene[]
- export function buildDefaultScene({
- name,
- isMain,
- }: {
- name: string;
- isMain: boolean;
+ export function buildDefaultScene({
+ name,
+ isMain,
+ }: {
+ name: string;
+ isMain: boolean;
}): TScene
- export function canDeleteScene({ scene }: { scene: TScene }): {
- canDelete: boolean;
- reason?: string;
+ export function canDeleteScene({ scene }: { scene: TScene }): {
+ canDelete: boolean;
+ reason?: string;
}
- export function getFallbackSceneAfterDelete({
- scenes,
- deletedSceneId,
- currentSceneId,
- }: {
- scenes: TScene[];
- deletedSceneId: string;
- currentSceneId: string | null;
+ export function getFallbackSceneAfterDelete({
+ scenes,
+ deletedSceneId,
+ currentSceneId,
+ }: {
+ scenes: TScene[];
+ deletedSceneId: string;
+ currentSceneId: string | null;
}): TScene | null
- export function findCurrentScene({
- scenes,
- currentSceneId,
- }: {
- scenes: TScene[];
- currentSceneId: string;
+ export function findCurrentScene({
+ scenes,
+ currentSceneId,
+ }: {
+ scenes: TScene[];
+ currentSceneId: string;
}): TScene | null
- export function getProjectDurationFromScenes({
- scenes,
- }: {
- scenes: TScene[];
+ export function getProjectDurationFromScenes({
+ scenes,
+ }: {
+ scenes: TScene[];
}): number
- export function updateSceneInArray({
- scenes,
- sceneId,
- updates,
- }: {
- scenes: TScene[];
- sceneId: string;
- updates: Partial<TScene>;
+ export function updateSceneInArray({
+ scenes,
+ sceneId,
+ updates,
+ }: {
+ scenes: TScene[];
+ sceneId: string;
+ updates: Partial<TScene>;
}): TScene[]
time.ts
- export function roundToFrame({
- time,
- fps,
- }: {
- time: number;
- fps: number;
+ export function roundToFrame({
+ time,
+ fps,
+ }: {
+ time: number;
+ fps: number;
}): number
- export function formatTimeCode({
- timeInSeconds,
- format = "HH:MM:SS:CS",
- fps,
- }: {
- timeInSeconds: number;
- format?: TTimeCode;
- fps?: number;
+ export function formatTimeCode({
+ timeInSeconds,
+ format = "HH:MM:SS:CS",
+ fps,
+ }: {
+ timeInSeconds: number;
+ format?: TTimeCode;
+ fps?: number;
}): string
- export function parseTimeCode({
- timeCode,
- format = "HH:MM:SS:CS",
- fps,
- }: {
- timeCode: string;
- format?: TTimeCode;
- fps: number;
+ export function parseTimeCode({
+ timeCode,
+ format = "HH:MM:SS:CS",
+ fps,
+ }: {
+ timeCode: string;
+ format?: TTimeCode;
+ fps: number;
}): number | null
- export function guessTimeCodeFormat({
- timeCode,
- }: {
- timeCode: string;
+ export function guessTimeCodeFormat({
+ timeCode,
+ }: {
+ timeCode: string;
}): TTimeCode | null
- export function timeToFrame({
- time,
- fps,
- }: {
- time: number;
- fps: number;
+ export function timeToFrame({
+ time,
+ fps,
+ }: {
+ time: number;
+ fps: number;
}): number
- export function frameToTime({
- frame,
- fps,
- }: {
- frame: number;
- fps: number;
+ export function frameToTime({
+ frame,
+ fps,
+ }: {
+ frame: number;
+ fps: number;
}): number
- export function snapTimeToFrame({
- time,
- fps,
- }: {
- time: number;
- fps: number;
+ export function snapTimeToFrame({
+ time,
+ fps,
+ }: {
+ time: number;
+ fps: number;
}): number
- export function getSnappedSeekTime({
- rawTime,
- duration,
- fps,
- }: {
- rawTime: number;
- duration: number;
- fps: number;
+ export function getSnappedSeekTime({
+ rawTime,
+ duration,
+ fps,
+ }: {
+ rawTime: number;
+ duration: number;
+ fps: number;
}): number
- export function getLastFrameTime({
- duration,
- fps,
- }: {
- duration: number;
- fps: number;
+ export function getLastFrameTime({
+ duration,
+ fps,
+ }: {
+ duration: number;
+ fps: number;
}): number
## apps/web/src/lib/actions
definitions.ts
- export type TActionCategory = | "playback"
- | "navigation"
- | "editing"
- | "selection"
- | "history"
- | "timeline"
- | "con...
+ export type TActionCategory = | "playback"
+ | "navigation"
+ | "editing"
+ | "selection"
+ | "history"
+ | "timeline"
+ | "controls"
export interface TActionDefinition {
description: string
category: TActionCategory
@@ -587,40 +650,40 @@ definitions.ts
export function getDefaultShortcuts(): Record<ShortcutKey, TAction>
registry.ts
- export function bindAction(
- action: A,
- handler: TActionFunc<A>,
+ export function bindAction(
+ action: A,
+ handler: TActionFunc<A>,
)
- export function unbindAction(
- action: A,
- handler: TActionFunc<A>,
+ export function unbindAction(
+ action: A,
+ handler: TActionFunc<A>,
)
- export const invokeAction = (
- action: A,
- args?: TArgOfAction<A>,
- trigger?: TInvocationTrigger,
+ export const invokeAction = (
+ action: A,
+ args?: TArgOfAction<A>,
+ trigger?: TInvocationTrigger,
) => ...
types.ts
- export type TActionArgsMap = {
- "seek-forward": { seconds: number } | undefined;
- "seek-backward": { seconds: number } | und...
+ export type TActionArgsMap = {
+ "seek-forward": { seconds: number } | undefined;
+ "seek-backward": { seconds: number } | undef...
export type TActionWithArgs = keyof TActionArgsMap
- export type TActionWithOptionalArgs = | TActionWithNoArgs
+ export type TActionWithOptionalArgs = | TActionWithNoArgs
| TKeysWithValueUndefined<TActionArgsMap>
export type TActionWithNoArgs = Exclude<TAction, TActionWithArgs>
- export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
- ? TActionArgsMap[A]
+ export type TArgOfAction<A extends TAction> = A extends TActionWithArgs
+ ? TActionArgsMap[A]
: undefined
- export type TActionFunc<A extends TAction> = A extends TActionWithArgs
- ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
- : (_...
+ export type TActionFunc<A extends TAction> = A extends TActionWithArgs
+ ? (arg: TArgOfAction<A>, trigger?: TInvocationTrigger) => void
+ : (_?:...
export type TInvocationTrigger = "keypress" | "mouseclick"
- export type TBoundActionList = {
- [A in TAction]?: Array<TActionFunc<A>>;
+ export type TBoundActionList = {
+ [A in TAction]?: Array<TActionFunc<A>>;
}
- export type TActionHandlerOptions = | MutableRefObject<boolean>
- | boolean
+ export type TActionHandlerOptions = | MutableRefObject<boolean>
+ | boolean
| undefined
## apps/web/src/lib/auth
@@ -637,10 +700,10 @@ query.ts
export function getSinglePost({ slug }: { slug: string })
export function getCategories()
export function getAuthors()
- export function processHtmlContent({
- html,
- }: {
- html: string;
+ export function processHtmlContent({
+ html,
+ }: {
+ html: string;
}): Promise<string>
## apps/web/src/lib/db
@@ -654,62 +717,82 @@ schema.ts
export const accounts
export const verifications
+## apps/web/src/lib/fonts
+
+google-fonts.ts
+ export function getCachedFontAtlas(): FontAtlas | null
+ export function clearFontAtlasCache(): void
+ export function prefetchFontAtlas(): Promise<FontAtlas | null>
+ export function loadFullFont({
+ family,
+ weights = [400, 700],
+ }: {
+ family: string;
+ weights?: number[];
+ }): Promise<void>
+ export function loadFonts({
+ families,
+ }: {
+ families: string[];
+ }): Promise<void>
+
## apps/web/src/lib/gradients
canvas.ts
- export function drawCssBackground({
- ctx,
- width,
- height,
- css,
- }: {
- ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
- width: number;
- height: number;
- css: string;
+ export function drawCssBackground({
+ ctx,
+ width,
+ height,
+ css,
+ }: {
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ width: number;
+ height: number;
+ css: string;
}): void
parser.ts
export type GradientOrientation = LinearOrientation | Array<RadialOrientation>
- export type Color = | { type: "hex"; value: string }
- | { type: "literal"; value: string }
- | { type: "rgb"; value:...
+ export type Color = | { type: "hex"; value: string }
+ | { type: "literal"; value: string }
+ | { type: "rgb"; value: A...
export type ColorStop = Color & { length?: Distance }
- export type GradientAst = {
- type: GradientType;
- orientation: GradientOrientation | undefined;
- colorStops: Array<Color...
- export const parseGradient = ({
- code,
- }: {
- code: string;
+ export type GradientAst = {
+ type: GradientType;
+ orientation: GradientOrientation | undefined;
+ colorStops: Array<ColorSto...
+ export const parseGradient = ({
+ code,
+ }: {
+ code: string;
}): Array<GradientAst> => ...
export const GradientParser
## apps/web/src/lib/media
audio.ts
- export type CollectedAudioElement = Omit<
- AudioElement,
- "type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl...
+ export type CollectedAudioElement = Omit<
+ AudioElement,
+ "type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl"
+ ...
export function createAudioContext(): AudioContext
export interface DecodedAudio {
samples: Float32Array
sampleRate: number
}
- export function decodeAudioToFloat32({
- audioBlob,
- }: {
- audioBlob: Blob;
+ export function decodeAudioToFloat32({
+ audioBlob,
+ }: {
+ audioBlob: Blob;
}): Promise<DecodedAudio>
- export function collectAudioElements({
- tracks,
- mediaAssets,
- audioContext,
- }: {
- tracks: TimelineTrack[];
- mediaAssets: MediaAsset[];
- audioContext: AudioContext;
+ export function collectAudioElements({
+ tracks,
+ mediaAssets,
+ audioContext,
+ }: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+ audioContext: AudioContext;
}): Promise<CollectedAudioElement[]>
export interface AudioClipSource {
id: string
@@ -721,265 +804,553 @@ audio.ts
trimEnd: number
muted: boolean
}
- export function collectAudioMixSources({
- tracks,
- mediaAssets,
- }: {
- tracks: TimelineTrack[];
- mediaAssets: MediaAsset[];
+ export function collectAudioMixSources({
+ tracks,
+ mediaAssets,
+ }: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
}): Promise<AudioMixSource[]>
- export function collectAudioClips({
- tracks,
- mediaAssets,
- }: {
- tracks: TimelineTrack[];
- mediaAssets: MediaAsset[];
+ export function collectAudioClips({
+ tracks,
+ mediaAssets,
+ }: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
}): Promise<AudioClipSource[]>
- export function createTimelineAudioBuffer({
- tracks,
- mediaAssets,
- duration,
- sampleRate = 44100,
- audioContext,
- }: {
- tracks: TimelineTrack[];
- mediaAssets: MediaAsset[];
- duration: number;
- sampleRate?: number;
- audioContext?: AudioContext;
+ export function createTimelineAudioBuffer({
+ tracks,
+ mediaAssets,
+ duration,
+ sampleRate = 44100,
+ audioContext,
+ }: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+ duration: number;
+ sampleRate?: number;
+ audioContext?: AudioContext;
}): Promise<AudioBuffer | null>
media-utils.ts
export const SUPPORTS_AUDIO: readonly MediaType[]
- export function mediaSupportsAudio({
- media,
- }: {
- media: MediaAsset | null | undefined;
+ export function mediaSupportsAudio({
+ media,
+ }: {
+ media: MediaAsset | null | undefined;
}): boolean
- export const getMediaTypeFromFile = ({
- file,
- }: {
- file: File;
+ export const getMediaTypeFromFile = ({
+ file,
+ }: {
+ file: File;
}): MediaType | null => ...
mediabunny.ts
- export function getVideoInfo({
- videoFile,
- }: {
- videoFile: File;
- }): Promise<{
- duration: number;
- width: number;
- height: number;
- fps: number;
+ export function getVideoInfo({
+ videoFile,
+ }: {
+ videoFile: File;
+ }): Promise<{
+ duration: number;
+ width: number;
+ height: number;
+ fps: number;
}>
- export const extractTimelineAudio = ({
- tracks,
- mediaAssets,
- totalDuration,
- onProgress,
- }: {
- tracks: TimelineTrack[];
- mediaAssets: MediaAsset[];
- totalDuration: number;
- onProgress?: (progress: number) => void;
+ export const extractTimelineAudio = ({
+ tracks,
+ mediaAssets,
+ totalDuration,
+ onProgress,
+ }: {
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+ totalDuration: number;
+ onProgress?: (progress: number) => void;
}): Promise<Blob> => ...
processing.ts
export interface ProcessedMediaAsset extends Omit<MediaAsset, "id">
- export function generateThumbnail({
- videoFile,
- timeInSeconds,
- }: {
- videoFile: File;
- timeInSeconds: number;
+ export function generateThumbnail({
+ videoFile,
+ timeInSeconds,
+ }: {
+ videoFile: File;
+ timeInSeconds: number;
}): Promise<string>
- export function generateImageThumbnail({
- imageFile,
- }: {
- imageFile: File;
+ export function generateImageThumbnail({
+ imageFile,
+ }: {
+ imageFile: File;
}): Promise<string>
- export function processMediaAssets({
- files,
- onProgress,
- }: {
- files: FileList | File[];
- onProgress?: ({ progress }: { progress: number }) => void;
+ export function processMediaAssets({
+ files,
+ onProgress,
+ }: {
+ files: FileList | File[];
+ onProgress?: ({ progress }: { progress: number }) => void;
}): Promise<ProcessedMediaAsset[]>
-## apps/web/src/lib/timeline
+## apps/web/src/lib/preview
-bookmarks.ts
- export function findBookmarkIndex({
- bookmarks,
- frameTime,
+element-bounds.ts
+ export interface ElementBounds {
+ cx: number
+ cy: number
+ width: number
+ height: number
+ rotation: number
+ }
+ export interface ElementWithBounds {
+ trackId: string
+ elementId: string
+ element: TimelineElement
+ bounds: ElementBounds
+ }
+ export function getElementBounds({
+ element,
+ canvasSize,
+ mediaAsset,
+ }: {
+ element: TimelineElement;
+ canvasSize: { width: number; height: number };
+ mediaAsset?: MediaAsset | null;
+ }): ElementBounds | null
+ export function getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+ }: {
+ tracks: TimelineTrack[];
+ currentTime: number;
+ canvasSize: { width: number; height: number };
+ mediaAssets: MediaAsset[];
+ }): ElementWithBounds[]
+
+hit-test.ts
+ export function hitTest({
+ canvasX,
+ canvasY,
+ elementsWithBounds,
}: {
- bookmarks: number[];
- frameTime: number;
- }): number
- export function isBookmarkAtTime({
- bookmarks,
- frameTime,
+ canvasX: number;
+ canvasY: number;
+ elementsWithBounds: ElementWithBounds[];
+ }): ElementWithBounds | null
+
+preview-coords.ts
+ export function screenToCanvas({
+ clientX,
+ clientY,
+ canvas,
}: {
- bookmarks: number[];
- frameTime: number;
- }): boolean
- export function toggleBookmarkInArray({
- bookmarks,
- frameTime,
+ clientX: number;
+ clientY: number;
+ canvas: HTMLCanvasElement;
+ }): { x: number; y: number }
+ export function canvasToOverlay({
+ canvasX,
+ canvasY,
+ canvasRect,
+ containerRect,
+ canvasSize,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ canvasRect: DOMRect;
+ containerRect: DOMRect;
+ canvasSize: { width: number; height: number };
+ }): { x: number; y: number }
+ export function positionToOverlay({
+ positionX,
+ positionY,
+ canvasRect,
+ containerRect,
+ canvasSize,
+ }: {
+ positionX: number;
+ positionY: number;
+ canvasRect: DOMRect;
+ containerRect: DOMRect;
+ canvasSize: { width: number; height: number };
+ }): { x: number; y: number }
+ export function getDisplayScale({
+ canvasRect,
+ canvasSize,
}: {
- bookmarks: number[];
- frameTime: number;
- }): number[]
- export function removeBookmarkFromArray({
- bookmarks,
- frameTime,
+ canvasRect: DOMRect;
+ canvasSize: { width: number; height: number };
+ }): { x: number; y: number }
+
+preview-snap.ts
+ export interface SnapLine {
+ type: "horizontal" | "vertical"
+ position: number
+ }
+ export const MIN_SCALE
+ export interface SnapResult {
+ snappedPosition: { x: number; y: number }
+ activeLines: SnapLine[]
+ }
+ export function snapPosition({
+ proposedPosition,
+ canvasSize,
+ elementSize,
}: {
- bookmarks: number[];
- frameTime: number;
- }): number[]
- export function getFrameTime({
- time,
- fps,
+ proposedPosition: { x: number; y: number };
+ canvasSize: { width: number; height: number };
+ elementSize: { width: number; height: number };
+ }): SnapResult
+ export interface ScaleSnapResult {
+ snappedScale: number
+ activeLines: SnapLine[]
+ }
+ export function snapScale({
+ proposedScale,
+ position,
+ baseWidth,
+ baseHeight,
+ canvasSize,
+ }: {
+ proposedScale: number;
+ position: { x: number; y: number };
+ baseWidth: number;
+ baseHeight: number;
+ canvasSize: { width: number; height: number };
+ }): ScaleSnapResult
+ export interface RotationSnapResult {
+ snappedRotation: number
+ isSnapped: boolean
+ }
+ export function snapRotation({
+ proposedRotation,
}: {
- time: number;
- fps: number;
+ proposedRotation: number;
+ }): RotationSnapResult
+
+## apps/web/src/lib/stickers
+
+index.ts
+ export function searchStickers({
+ query,
+ category,
+ limit = DEFAULT_SEARCH_LIMIT,
+ }: {
+ query: string;
+ category: StickerCategory;
+ limit?: number;
+ }): Promise<StickerSearchResult>
+ export function browseStickers({
+ category,
+ page = 1,
+ limit = DEFAULT_SEARCH_LIMIT,
+ }: {
+ category: StickerCategory;
+ page?: number;
+ limit?: number;
+ }): Promise<StickerSearchResult>
+
+registry.ts
+ export function registerProvider({
+ provider,
+ }: {
+ provider: StickerProvider;
+ }): void
+ export function hasProvider({ providerId }: { providerId: string }): boolean
+ export function getProvider({
+ providerId,
+ }: {
+ providerId: string;
+ }): StickerProvider
+ export function getAllProviders(): StickerProvider[]
+
+resolver.ts
+ export function resolveStickerId({
+ stickerId,
+ options,
+ }: {
+ stickerId: string;
+ options?: StickerResolveOptions;
+ }): string
+
+sticker-id.ts
+ export function parseStickerId({ stickerId }: { stickerId: string }): {
+ providerId: string;
+ providerValue: string;
+ }
+ export function buildStickerId({
+ providerId,
+ providerValue,
+ }: {
+ providerId: string;
+ providerValue: string;
+ }): string
+
+## apps/web/src/lib/stickers/providers
+
+emoji.ts
+ export const emojiProvider: StickerProvider
+
+flags.ts
+ export const flagsProvider: StickerProvider
+
+icons.ts
+ export const iconsProvider: StickerProvider
+
+index.ts
+ export function registerDefaultStickerProviders({
+ providersToRegister = defaultProviders,
+ }: {
+ providersToRegister?: StickerProvider[];
+ } = {}): void
+
+shapes.ts
+ export const shapesProvider: StickerProvider
+
+## apps/web/src/lib/timeline
+
+bookmarks.ts
+ export const BOOKMARK_TIME_EPSILON
+ export function findBookmarkIndex({
+ bookmarks,
+ frameTime,
+ }: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+ }): number
+ export function isBookmarkAtTime({
+ bookmarks,
+ frameTime,
+ }: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+ }): boolean
+ export function toggleBookmarkInArray({
+ bookmarks,
+ frameTime,
+ }: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+ }): Bookmark[]
+ export function removeBookmarkFromArray({
+ bookmarks,
+ frameTime,
+ }: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+ }): Bookmark[]
+ export function updateBookmarkInArray({
+ bookmarks,
+ frameTime,
+ updates,
+ }: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+ updates: Partial<Omit<Bookmark, "time">>;
+ }): Bookmark[]
+ export function moveBookmarkInArray({
+ bookmarks,
+ fromTime,
+ toTime,
+ }: {
+ bookmarks: Bookmark[];
+ fromTime: number;
+ toTime: number;
+ }): Bookmark[]
+ export function getFrameTime({
+ time,
+ fps,
+ }: {
+ time: number;
+ fps: number;
}): number
+ export function getBookmarkAtTime({
+ bookmarks,
+ frameTime,
+ }: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+ }): Bookmark | null
+ export function getBookmarksActiveAtTime({
+ bookmarks,
+ time,
+ }: {
+ bookmarks: Bookmark[];
+ time: number;
+ }): Bookmark[]
-drop-utils.ts
- export function computeDropTarget({
- elementType,
- mouseX,
- mouseY,
- tracks,
- playheadTime,
- isExternalDrop,
- elementDuration,
- pixelsPerSecond,
+drag-utils.ts
+ export function getMouseTimeFromClientX({
+ clientX,
+ containerRect,
zoomLevel,
- verticalDragDirection,
- startTimeOverride,
- excludeElementId,
- }: ComputeDropTargetParams): DropTarget
- export function getDropLineY({
- dropTarget,
- tracks,
+ scrollLeft,
}: {
- dropTarget: DropTarget;
- tracks: TimelineTrack[];
+ clientX: number;
+ containerRect: DOMRect;
+ zoomLevel: number;
+ scrollLeft: number;
+ }): number
+
+drop-utils.ts
+ export function computeDropTarget({
+ elementType,
+ mouseX,
+ mouseY,
+ tracks,
+ playheadTime,
+ isExternalDrop,
+ elementDuration,
+ pixelsPerSecond,
+ zoomLevel,
+ verticalDragDirection,
+ startTimeOverride,
+ excludeElementId,
+ }: ComputeDropTargetParams): DropTarget
+ export function getDropLineY({
+ dropTarget,
+ tracks,
+ }: {
+ dropTarget: DropTarget;
+ tracks: TimelineTrack[];
}): number
element-utils.ts
- export function canElementHaveAudio(
- element: TimelineElement,
+ export function canElementHaveAudio(
+ element: TimelineElement,
)
- export function canElementBeHidden(
- element: TimelineElement,
+ export function isVisualElement(
+ element: TimelineElement,
)
- export function hasMediaId(
- element: TimelineElement,
+ export function canElementBeHidden(
+ element: TimelineElement,
)
- export function requiresMediaId({
- element,
- }: {
- element: CreateTimelineElement;
+ export function hasMediaId(
+ element: TimelineElement,
+ )
+ export function requiresMediaId({
+ element,
+ }: {
+ element: CreateTimelineElement;
}): boolean
- export function checkElementOverlaps({
- elements,
- }: {
- elements: TimelineElement[];
+ export function checkElementOverlaps({
+ elements,
+ }: {
+ elements: TimelineElement[];
}): boolean
- export function resolveElementOverlaps({
- elements,
- }: {
- elements: TimelineElement[];
+ export function resolveElementOverlaps({
+ elements,
+ }: {
+ elements: TimelineElement[];
}): TimelineElement[]
- export function wouldElementOverlap({
- elements,
- startTime,
- endTime,
- excludeElementId,
- }: {
- elements: TimelineElement[];
- startTime: number;
- endTime: number;
- excludeElementId?: string;
+ export function wouldElementOverlap({
+ elements,
+ startTime,
+ endTime,
+ excludeElementId,
+ }: {
+ elements: TimelineElement[];
+ startTime: number;
+ endTime: number;
+ excludeElementId?: string;
}): boolean
- export function buildTextElement({
- raw,
- startTime,
- }: {
- raw: Partial<Omit<TextElement, "type" | "id">>;
- startTime: number;
+ export function buildTextElement({
+ raw,
+ startTime,
+ }: {
+ raw: Partial<Omit<TextElement, "type" | "id">>;
+ startTime: number;
}): CreateTimelineElement
- export function buildStickerElement({
- iconName,
- startTime,
- }: {
- iconName: string;
- startTime: number;
+ export function buildStickerElement({
+ stickerId,
+ name,
+ startTime,
+ }: {
+ stickerId: string;
+ name?: string;
+ startTime: number;
}): CreateStickerElement
- export function buildVideoElement({
- mediaId,
- name,
- duration,
- startTime,
- }: {
- mediaId: string;
- name: string;
- duration: number;
- startTime: number;
+ export function buildVideoElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+ }: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
}): CreateVideoElement
- export function buildImageElement({
- mediaId,
- name,
- duration,
- startTime,
- }: {
- mediaId: string;
- name: string;
- duration: number;
- startTime: number;
+ export function buildImageElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+ }: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
}): CreateImageElement
- export function buildUploadAudioElement({
- mediaId,
- name,
- duration,
- startTime,
- buffer,
- }: {
- mediaId: string;
- name: string;
- duration: number;
- startTime: number;
- buffer?: AudioBuffer;
+ export function buildUploadAudioElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+ buffer,
+ }: {
+ mediaId: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
}): CreateUploadAudioElement
- export function buildLibraryAudioElement({
- sourceUrl,
- name,
- duration,
- startTime,
- buffer,
- }: {
- sourceUrl: string;
- name: string;
- duration: number;
- startTime: number;
- buffer?: AudioBuffer;
+ export function buildElementFromMedia({
+ mediaId,
+ mediaType,
+ name,
+ duration,
+ startTime,
+ buffer,
+ }: {
+ mediaId: string;
+ mediaType: MediaType;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+ }): CreateTimelineElement
+ export function buildLibraryAudioElement({
+ sourceUrl,
+ name,
+ duration,
+ startTime,
+ buffer,
+ }: {
+ sourceUrl: string;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
}): CreateLibraryAudioElement
- export function getElementsAtTime({
- tracks,
- time,
- }: {
- tracks: TimelineTrack[];
- time: number;
+ export function getElementsAtTime({
+ tracks,
+ time,
+ }: {
+ tracks: TimelineTrack[];
+ time: number;
}): { trackId: string; elementId: string }[]
+ export function collectFontFamilies({
+ tracks,
+ }: {
+ tracks: TimelineTrack[];
+ }): string[]
index.ts
- export function calculateTotalDuration({
- tracks,
- }: {
- tracks: TimelineTrack[];
+ export function calculateTotalDuration({
+ tracks,
+ }: {
+ tracks: TimelineTrack[];
}): number
ruler-utils.ts
@@ -987,162 +1358,180 @@ ruler-utils.ts
labelIntervalSeconds: number
tickIntervalSeconds: number
}
- export function getRulerConfig({
- zoomLevel,
- fps,
- }: {
- zoomLevel: number;
- fps: number;
+ export function getRulerConfig({
+ zoomLevel,
+ fps,
+ }: {
+ zoomLevel: number;
+ fps: number;
}): RulerConfig
- export function shouldShowLabel({
- time,
- labelIntervalSeconds,
- }: {
- time: number;
- labelIntervalSeconds: number;
+ export function shouldShowLabel({
+ time,
+ labelIntervalSeconds,
+ }: {
+ time: number;
+ labelIntervalSeconds: number;
}): boolean
- export function formatRulerLabel({
- timeInSeconds,
- fps,
- }: {
- timeInSeconds: number;
- fps: number;
+ export function formatRulerLabel({
+ timeInSeconds,
+ fps,
+ }: {
+ timeInSeconds: number;
+ fps: number;
}): string
track-utils.ts
- export function canTracktHaveAudio(
- track: TimelineTrack,
+ export function canTracktHaveAudio(
+ track: TimelineTrack,
)
- export function canTrackBeHidden(
- track: TimelineTrack,
+ export function canTrackBeHidden(
+ track: TimelineTrack,
)
export function getTrackColor({ type }: { type: TrackType })
export function getTrackClasses({ type }: { type: TrackType })
export function getTrackHeight({ type }: { type: TrackType }): number
- export function getCumulativeHeightBefore({
- tracks,
- trackIndex,
- }: {
- tracks: Array<{ type: TrackType }>;
- trackIndex: number;
+ export function getCumulativeHeightBefore({
+ tracks,
+ trackIndex,
+ }: {
+ tracks: Array<{ type: TrackType }>;
+ trackIndex: number;
}): number
- export function getTotalTracksHeight({
- tracks,
- }: {
- tracks: Array<{ type: TrackType }>;
+ export function getTotalTracksHeight({
+ tracks,
+ }: {
+ tracks: Array<{ type: TrackType }>;
}): number
- export function buildEmptyTrack({
- id,
- type,
- name,
- }: {
- id: string;
- type: TrackType;
- name?: string;
+ export function buildEmptyTrack({
+ id,
+ type,
+ name,
+ }: {
+ id: string;
+ type: TrackType;
+ name?: string;
}): TimelineTrack
- export function getDefaultInsertIndexForTrack({
- tracks,
- trackType,
- }: {
- tracks: TimelineTrack[];
- trackType: TrackType;
+ export function getDefaultInsertIndexForTrack({
+ tracks,
+ trackType,
+ }: {
+ tracks: TimelineTrack[];
+ trackType: TrackType;
}): number
- export function getHighestInsertIndexForTrack({
- tracks,
- trackType,
- }: {
- tracks: TimelineTrack[];
- trackType: TrackType;
+ export function getHighestInsertIndexForTrack({
+ tracks,
+ trackType,
+ }: {
+ tracks: TimelineTrack[];
+ trackType: TrackType;
}): number
export function isMainTrack(track: TimelineTrack)
- export function getMainTrack({
- tracks,
- }: {
- tracks: TimelineTrack[];
+ export function getMainTrack({
+ tracks,
+ }: {
+ tracks: TimelineTrack[];
}): TimelineTrack | null
- export function ensureMainTrack({
- tracks,
- }: {
- tracks: TimelineTrack[];
+ export function ensureMainTrack({
+ tracks,
+ }: {
+ tracks: TimelineTrack[];
}): TimelineTrack[]
- export function canElementGoOnTrack({
- elementType,
- trackType,
- }: {
- elementType: ElementType;
- trackType: TrackType;
+ export function canElementGoOnTrack({
+ elementType,
+ trackType,
+ }: {
+ elementType: ElementType;
+ trackType: TrackType;
}): boolean
- export function validateElementTrackCompatibility({
- element,
- track,
- }: {
- element: { type: ElementType };
- track: { type: TrackType };
+ export function validateElementTrackCompatibility({
+ element,
+ track,
+ }: {
+ element: { type: ElementType };
+ track: { type: TrackType };
}): { isValid: boolean; errorMessage?: string }
+ export function getEarliestMainTrackElement({
+ tracks,
+ excludeElementId,
+ }: {
+ tracks: TimelineTrack[];
+ excludeElementId?: string;
+ }): TimelineElement | null
+ export function enforceMainTrackStart({
+ tracks,
+ targetTrackId,
+ requestedStartTime,
+ excludeElementId,
+ }: {
+ tracks: TimelineTrack[];
+ targetTrackId: string;
+ requestedStartTime: number;
+ excludeElementId?: string;
+ }): number
zoom-utils.ts
- export function getTimelineZoomMin({
- duration,
- containerWidth,
- }: {
- duration: number;
- containerWidth: number | null | undefined;
+ export function getTimelineZoomMin({
+ duration,
+ containerWidth,
+ }: {
+ duration: number;
+ containerWidth: number | null | undefined;
}): number
- export function getTimelinePaddingPx({
- containerWidth,
- zoomLevel,
- minZoom,
- }: {
- containerWidth: number;
- zoomLevel: number;
- minZoom: number;
+ export function getTimelinePaddingPx({
+ containerWidth,
+ zoomLevel,
+ minZoom,
+ }: {
+ containerWidth: number;
+ zoomLevel: number;
+ minZoom: number;
}): number
- export function getZoomPercent({
- zoomLevel,
- minZoom,
- }: {
- zoomLevel: number;
- minZoom: number;
+ export function getZoomPercent({
+ zoomLevel,
+ minZoom,
+ }: {
+ zoomLevel: number;
+ minZoom: number;
}): number
- export function sliderToZoom({
- sliderPosition,
- minZoom,
- maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
- }: {
- sliderPosition: number;
- minZoom: number;
- maxZoom?: number;
+ export function sliderToZoom({
+ sliderPosition,
+ minZoom,
+ maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
+ }: {
+ sliderPosition: number;
+ minZoom: number;
+ maxZoom?: number;
}): number
- export function zoomToSlider({
- zoomLevel,
- minZoom,
- maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
- }: {
- zoomLevel: number;
- minZoom: number;
- maxZoom?: number;
+ export function zoomToSlider({
+ zoomLevel,
+ minZoom,
+ maxZoom = TIMELINE_CONSTANTS.ZOOM_MAX,
+ }: {
+ zoomLevel: number;
+ minZoom: number;
+ maxZoom?: number;
}): number
## apps/web/src/lib/transcription
caption.ts
- export function buildCaptionChunks({
- segments,
- wordsPerChunk = DEFAULT_WORDS_PER_CAPTION,
- minDuration = MIN_CAPTION_DURATION_SECONDS,
- }: {
- segments: TranscriptionSegment[];
- wordsPerChunk?: number;
- minDuration?: number;
+ export function buildCaptionChunks({
+ segments,
+ wordsPerChunk = DEFAULT_WORDS_PER_CAPTION,
+ minDuration = MIN_CAPTION_DURATION_SECONDS,
+ }: {
+ segments: TranscriptionSegment[];
+ wordsPerChunk?: number;
+ minDuration?: number;
}): CaptionChunk[]
## apps/web/src/services/renderer
canvas-renderer.ts
- export type CanvasRendererParams = {
- width: number;
- height: number;
- fps: number;
+ export type CanvasRendererParams = {
+ width: number;
+ height: number;
+ fps: number;
}
export class CanvasRenderer {
canvas: OffscreenCanvas | HTMLCanvasElement
@@ -1153,33 +1542,33 @@ canvas-renderer.ts
constructor({ width, height, fps }: CanvasRendererParams)
setSize({ width, height }: { width: number; height: number })
async render({ node, time }: { node: BaseNode; time: number })
- async renderToCanvas({
- node,
- time,
- targetCanvas,
- }: {
- node: BaseNode;
- time: number;
- targetCanvas: HTMLCanvasElement;
+ async renderToCanvas({
+ node,
+ time,
+ targetCanvas,
+ }: {
+ node: BaseNode;
+ time: number;
+ targetCanvas: HTMLCanvasElement;
})
}
scene-builder.ts
- export type BuildSceneParams = {
- canvasSize: TCanvasSize;
- tracks: TimelineTrack[];
- mediaAssets: MediaAsset[];
- duration: ...
+ export type BuildSceneParams = {
+ canvasSize: TCanvasSize;
+ tracks: TimelineTrack[];
+ mediaAssets: MediaAsset[];
+ duration: numb...
export function buildScene(params: BuildSceneParams)
scene-exporter.ts
export type ExportFormat = "mp4" | "webm"
export type ExportQuality = "low" | "medium" | "high" | "very_high"
- export type SceneExporterEvents = {
- progress: [progress: number];
- complete: [buffer: ArrayBuffer];
- error: [error: Error];
- c...
+ export type SceneExporterEvents = {
+ progress: [progress: number];
+ complete: [buffer: ArrayBuffer];
+ error: [error: Error];
+ cance...
export class SceneExporter extends EventEmitter<SceneExporterEvents> {
renderer: CanvasRenderer
format: ExportFormat
@@ -1187,20 +1576,20 @@ scene-exporter.ts
shouldIncludeAudio: boolean
audioBuffer: AudioBuffer
isCancelled
- constructor({
- width,
- height,
- fps,
- format,
- quality,
- shouldIncludeAudio,
- audioBuffer,
+ constructor({
+ width,
+ height,
+ fps,
+ format,
+ quality,
+ shouldIncludeAudio,
+ audioBuffer,
}: ExportParams)
cancel(): void
- async export({
- rootNode,
- }: {
- rootNode: RootNode;
+ async export({
+ rootNode,
+ }: {
+ rootNode: RootNode;
}): Promise<ArrayBuffer | null>
}
@@ -1219,10 +1608,10 @@ indexeddb-adapter.ts
async getAll(): Promise<T[]>
async clear(): Promise<void>
}
- export function deleteDatabase({
- dbName,
- }: {
- dbName: string;
+ export function deleteDatabase({
+ dbName,
+ }: {
+ dbName: string;
}): Promise<void>
opfs-adapter.ts
@@ -1261,19 +1650,20 @@ types.ts
ephemeral?: boolean
thumbnailUrl?: string
}
- export type SerializedScene = Omit<TScene, "createdAt" | "updatedAt"> & {
- createdAt: string;
- updatedAt: string;
+ export type SerializedScene = Omit<TScene, "createdAt" | "updatedAt"> & {
+ createdAt: string;
+ updatedAt: string;
+ }
+ export type SerializedProjectMetadata = Omit<
+ TProjectMetadata,
+ "createdAt" | "updatedAt"
+ > & {
+ createdAt: string;
+ updatedAt: string;
}
- export type SerializedProjectMetadata = Omit<
- TProjectMetadata,
- "createdAt" | "updatedAt"
- > & {
- createdAt: string;
- updatedAt: st...
- export type SerializedProject = Omit<TProject, "metadata" | "scenes"> & {
- metadata: SerializedProjectMetadata;
- scenes: Serial...
+ export type SerializedProject = Omit<TProject, "metadata" | "scenes"> & {
+ metadata: SerializedProjectMetadata;
+ scenes: Serializ...
export interface StorageConfig {
projectsDb: string
mediaDb: string
@@ -1287,11 +1677,11 @@ service.ts
export const transcriptionService
worker.ts
- export type WorkerMessage = | { type: "init"; modelId: string }
- | { type: "transcribe"; audio: Float32Array; language: stri...
- export type WorkerResponse = | { type: "init-progress"; progress: number }
- | { type: "init-complete" }
- | { type: "init-err...
+ export type WorkerMessage = | { type: "init"; modelId: string }
+ | { type: "transcribe"; audio: Float32Array; language: strin...
+ export type WorkerResponse = | { type: "init-progress"; progress: number }
+ | { type: "init-complete" }
+ | { type: "init-error...
## apps/web/src/services/video-cache
@@ -1299,14 +1689,14 @@ service.ts
export class VideoCache {
sinks
initPromises
- async getFrameAt({
- mediaId,
- file,
- time,
- }: {
- mediaId: string;
- file: File;
- time: number;
+ async getFrameAt({
+ mediaId,
+ file,
+ time,
+ }: {
+ mediaId: string;
+ file: File;
+ time: number;
}): Promise<WrappedCanvas | null>
clearVideo({ mediaId }: { mediaId: string }): void
clearAll(): void
@@ -1345,22 +1735,15 @@ panel-store.ts
export type PanelId = keyof PanelSizes
export const usePanelStore
+preview-store.ts
+ export const usePreviewStore
+
sounds-store.ts
export const useSoundsStore
stickers-store.ts
export const useStickersStore
-text-properties-store.ts
- export type TextPropertiesTab = "text" | "transform"
- export interface TextPropertiesTabMeta {
- value: TextPropertiesTab
- label: string
- }
- export const TEXT_PROPERTIES_TABS: ReadonlyArray<TextPropertiesTabMeta>
- export function isTextPropertiesTab(value: string)
- export const useTextPropertiesStore
-
timeline-store.ts
export const useTimelineStore
@@ -1374,61 +1757,61 @@ assets.ts
}
blog.ts
- export type Post = {
- id: string;
- slug: string;
- title: string;
- content: string;
- description: string;
- cove...
- export type Pagination = {
- limit: number;
- currpage: number;
- nextPage: number | null;
- prevPage: number | null;
- to...
- export type MarblePostList = {
- posts: Post[];
- pagination: Pagination;
+ export type Post = {
+ id: string;
+ slug: string;
+ title: string;
+ content: string;
+ description: string;
+ coverImage...
+ export type Pagination = {
+ limit: number;
+ currpage: number;
+ nextPage: number | null;
+ prevPage: number | null;
+ totalIt...
+ export type MarblePostList = {
+ posts: Post[];
+ pagination: Pagination;
}
- export type MarblePost = {
- post: Post;
+ export type MarblePost = {
+ post: Post;
}
- export type Tag = {
- id: string;
- name: string;
- slug: string;
+ export type Tag = {
+ id: string;
+ name: string;
+ slug: string;
}
- export type MarbleTag = {
- tag: Tag;
+ export type MarbleTag = {
+ tag: Tag;
}
- export type MarbleTagList = {
- tags: Tag[];
- pagination: Pagination;
+ export type MarbleTagList = {
+ tags: Tag[];
+ pagination: Pagination;
}
- export type Category = {
- id: string;
- name: string;
- slug: string;
+ export type Category = {
+ id: string;
+ name: string;
+ slug: string;
}
- export type MarbleCategory = {
- category: Category;
+ export type MarbleCategory = {
+ category: Category;
}
- export type MarbleCategoryList = {
- categories: Category[];
- pagination: Pagination;
+ export type MarbleCategoryList = {
+ categories: Category[];
+ pagination: Pagination;
}
- export type Author = {
- id: string;
- name: string;
- image: string;
+ export type Author = {
+ id: string;
+ name: string;
+ image: string;
}
- export type MarbleAuthor = {
- author: Author;
+ export type MarbleAuthor = {
+ author: Author;
}
- export type MarbleAuthorList = {
- authors: Author[];
- pagination: Pagination;
+ export type MarbleAuthorList = {
+ authors: Author[];
+ pagination: Pagination;
}
drag.ts
@@ -1442,7 +1825,7 @@ drag.ts
}
export interface StickerDragData extends BaseDragData {
type: "sticker"
- iconName: string
+ stickerId: string
}
export type TimelineDragData = MediaDragData | TextDragData | StickerDragData
@@ -1469,32 +1852,57 @@ export.ts
cancelled?: boolean
}
+fonts.ts
+ export interface FontOption {
+ value: string
+ label: string
+ category: "system" | "google" | "custom"
+ weights?: number[]
+ hasClassName?: boolean
+ }
+ export interface GoogleFontMeta {
+ family: string
+ category: string
+ }
+ export interface FontAtlasEntry {
+ x: number
+ y: number
+ w: number
+ ch: number
+ s: string[]
+ }
+ export interface FontAtlas {
+ fonts: Record<string, FontAtlasEntry>
+ }
+
keybinding.ts
- export type ModifierKeys = | "ctrl"
- | "alt"
- | "shift"
- | "ctrl+shift"
- | "alt+shift"
- | "ctrl+alt"
+ export type ModifierKeys = | "ctrl"
+ | "alt"
+ | "shift"
+ | "ctrl+shift"
+ | "alt+shift"
+ | "ctrl+alt"
| "ctrl+alt+shift"
- export type Key = | "a"
- | "b"
- | "c"
- | "d"
- | "e"
- | "f"
- | "g"
- | "h"
- | "i"
- | "j"
- | "k"
- | "l"
- |...
+ export type Key = | "a"
+ | "b"
+ | "c"
+ | "d"
+ | "e"
+ | "f"
+ | "g"
+ | "h"
+ | "i"
+ | "j"
+ | "k"
+ | "l"
+ | "m"
+ | "n"
+ ...
export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}`
export type SingleCharacterShortcutKey = `${Key}`
export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey
- export type KeybindingConfig = {
- [key in ShortcutKey]?: TActionWithOptionalArgs;
+ export type KeybindingConfig = {
+ [key in ShortcutKey]?: TActionWithOptionalArgs;
}
language.ts
@@ -1502,13 +1910,14 @@ language.ts
export type LanguageCode = Language["code"]
project.ts
- export type TBackground = | {
- type: "color";
- color: string;
- }
- | {
- type: "blur";
- blurIntensity: number...
+ export type TBackground = | {
+ type: "color";
+ color: string;
+ }
+ | {
+ type: "blur";
+ blurIntensity: number;
+ }
export interface TCanvasSize {
width: number
height: number
@@ -1544,6 +1953,15 @@ project.ts
export type TSortOrder = "asc" | "desc"
export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}`
+rendering.ts
+ export type BlendMode = | "normal"
+ | "darken"
+ | "multiply"
+ | "color-burn"
+ | "lighten"
+ | "screen"
+ | "plus-ligh...
+
sounds.ts
export interface SoundEffect {
id: number
@@ -1584,18 +2002,69 @@ sounds.ts
}
stickers.ts
- export type StickerCategory = (typeof STICKER_CATEGORIES)[number]
+ export type StickerCategory = keyof typeof STICKER_CATEGORIES
+ export interface StickerItem {
+ id: string
+ provider: string
+ name: string
+ previewUrl: string
+ metadata: Record<string, unknown>
+ }
+ export interface StickerSearchResult {
+ items: StickerItem[]
+ total: number
+ hasMore: boolean
+ }
+ export interface StickerProviderSearchOptions {
+ limit?: number
+ }
+ export interface StickerProviderBrowseOptions {
+ page?: number
+ limit?: number
+ }
+ export interface StickerResolveOptions {
+ width?: number
+ height?: number
+ }
+ export interface StickerProvider {
+ id: string
+ search({
+ query,
+ options,
+ }: {
+ query: string;
+ options?: StickerProviderSearchOptions;
+ }): Promise<StickerSearchResult>
+ browse({
+ options,
+ }: {
+ options?: StickerProviderBrowseOptions;
+ }): Promise<StickerSearchResult>
+ resolveUrl({
+ stickerId,
+ options,
+ }: {
+ stickerId: string;
+ options?: StickerResolveOptions;
+ }): string
+ }
time.ts
export type TTimeCode = "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF"
timeline.ts
+ export interface Bookmark {
+ time: number
+ note?: string
+ color?: string
+ duration?: number
+ }
export interface TScene {
id: string
name: string
isMain: boolean
tracks: TimelineTrack[]
- bookmarks: number[]
+ bookmarks: Bookmark[]
createdAt: Date
updatedAt: Date
}
@@ -1625,9 +2094,9 @@ timeline.ts
export type TimelineTrack = VideoTrack | TextTrack | AudioTrack | StickerTrack
export interface Transform {
scale: number
- position: {
- x: number;
- y: number;
+ position: {
+ x: number;
+ y: number;
}
rotate: number
}
@@ -1647,6 +2116,7 @@ timeline.ts
hidden?: boolean
transform: Transform
opacity: number
+ blendMode?: BlendMode
}
export interface ImageElement extends BaseTimelineElement {
type: "image"
@@ -1654,6 +2124,7 @@ timeline.ts
hidden?: boolean
transform: Transform
opacity: number
+ blendMode?: BlendMode
}
export interface TextElement extends BaseTimelineElement {
type: "text"
@@ -1666,37 +2137,40 @@ timeline.ts
fontWeight: "normal" | "bold"
fontStyle: "normal" | "italic"
textDecoration: "none" | "underline" | "line-through"
+ letterSpacing?: number
+ lineHeight?: number
hidden?: boolean
transform: Transform
opacity: number
+ blendMode?: BlendMode
}
export interface StickerElement extends BaseTimelineElement {
type: "sticker"
- iconName: string
+ stickerId: string
hidden?: boolean
transform: Transform
opacity: number
- color?: string
+ blendMode?: BlendMode
}
- export type TimelineElement = | AudioElement
- | VideoElement
- | ImageElement
- | TextElement
+ export type TimelineElement = | AudioElement
+ | VideoElement
+ | ImageElement
+ | TextElement
| StickerElement
export type ElementType = TimelineElement["type"]
export type CreateUploadAudioElement = Omit<UploadAudioElement, "id">
export type CreateLibraryAudioElement = Omit<LibraryAudioElement, "id">
- export type CreateAudioElement = | CreateUploadAudioElement
+ export type CreateAudioElement = | CreateUploadAudioElement
| CreateLibraryAudioElement
export type CreateVideoElement = Omit<VideoElement, "id">
export type CreateImageElement = Omit<ImageElement, "id">
export type CreateTextElement = Omit<TextElement, "id">
export type CreateStickerElement = Omit<StickerElement, "id">
- export type CreateTimelineElement = | CreateAudioElement
- | CreateVideoElement
- | CreateImageElement
- | CreateTextElement
- | Crea...
+ export type CreateTimelineElement = | CreateAudioElement
+ | CreateVideoElement
+ | CreateImageElement
+ | CreateTextElement
+ | CreateSt...
export interface ElementDragState {
isDragging: boolean
elementId: string | null
@@ -1746,19 +2220,19 @@ transcription.ts
segments: TranscriptionSegment[]
language: string
}
- export type TranscriptionStatus = | "idle"
- | "loading-model"
- | "transcribing"
- | "complete"
+ export type TranscriptionStatus = | "idle"
+ | "loading-model"
+ | "transcribing"
+ | "complete"
| "error"
export interface TranscriptionProgress {
status: TranscriptionStatus
progress: number
message?: string
}
- export type TranscriptionModelId = | "whisper-tiny"
- | "whisper-small"
- | "whisper-medium"
+ export type TranscriptionModelId = | "whisper-tiny"
+ | "whisper-small"
+ | "whisper-medium"
| "whisper-large-v3-turbo"
export interface TranscriptionModel {
id: TranscriptionModelId
@@ -1775,37 +2249,76 @@ transcription.ts
## apps/web/src/utils
browser.ts
- export function isTypableDOMElement({
- element,
- }: {
- element: HTMLElement;
+ export function isTypableDOMElement({
+ element,
+ }: {
+ element: HTMLElement;
}): boolean
+color.ts
+ export type ColorFormat = "hex" | "rgb" | "hsl" | "hsv"
+ export function hexToHsv({ hex }: { hex: string }): [number, number, number]
+ export function hsvToHex({
+ h,
+ s,
+ v,
+ }: { h: number; s: number; v: number }): string
+ export function parseHexAlpha({ hex }: { hex: string }): {
+ rgb: string;
+ alpha: number;
+ }
+ export function appendAlpha({
+ rgbHex,
+ alpha,
+ }: { rgbHex: string; alpha: number }): string
+ export function extractColorFromText({
+ text,
+ }: { text: string }): string | null
+ export function formatColorValue({
+ hex,
+ format,
+ }: {
+ hex: string;
+ format: ColorFormat;
+ }): string
+ export function parseColorInput({
+ input,
+ format,
+ }: {
+ input: string;
+ format: ColorFormat;
+ }): string | null
+
date.ts
export function formatDate({ date }: { date: Date }): string
geometry.ts
- export function dimensionToAspectRatio({
- width,
- height,
- }: {
- width: number;
- height: number;
+ export function dimensionToAspectRatio({
+ width,
+ height,
+ }: {
+ width: number;
+ height: number;
}): string
id.ts
export function generateUUID(): string
math.ts
- export function clamp({
- value,
- min,
- max,
- }: {
- value: number;
- min: number;
- max: number;
+ export function clamp({
+ value,
+ min,
+ max,
+ }: {
+ value: number;
+ min: number;
+ max: number;
}): number
+ export function evaluateMathExpression({
+ input,
+ }: {
+ input: string;
+ }): number | null
platform.ts
export function getPlatformSpecialKey(): string
@@ -1823,28 +2336,56 @@ ui.ts
brand.tsx
export function OcVercelIcon({ className }: { className?: string })
- export function OcMarbleIcon({
- className = "",
- size = 32,
- }: {
- className?: string;
- size?: number;
+ export function OcMarbleIcon({
+ className = "",
+ size = 32,
+ }: {
+ className?: string;
+ size?: number;
})
- export function OcDataBuddyIcon({
- className = "",
- size = 32,
- }: {
- className?: string;
- size?: number;
+ export function OcDataBuddyIcon({
+ className = "",
+ size = 32,
+ }: {
+ className?: string;
+ size?: number;
})
ui.tsx
- export function OcVideoIcon({
- className = "",
- size = 32,
- }: {
- className?: string;
- size?: number;
+ export function OcVideoIcon({
+ className = "",
+ size = 32,
+ }: {
+ className?: string;
+ size?: number;
+ })
+ export function OcCheckerboardIcon({
+ className = "",
+ size = 32,
+ }: {
+ className?: string;
+ size?: number;
+ })
+ export function OcFontWeightIcon({
+ className = "",
+ size = 32,
+ }: {
+ className?: string;
+ size?: number;
+ })
+ export function OcSlidersVerticalIcon({
+ className = "",
+ size = 32,
+ }: {
+ className?: string;
+ size?: number;
+ })
+ export function OcSocialIcon({
+ className = "",
+ size = 32,
+ }: {
+ className?: string;
+ size?: number;
})
```
diff --git a/.cursor/settings.json b/.cursor/settings.json
new file mode 100644
index 00000000..28a77607
--- /dev/null
+++ b/.cursor/settings.json
@@ -0,0 +1,7 @@
+{
+ "plugins": {
+ "figma": {
+ "enabled": true
+ }
+ }
+}
diff --git a/.github/workflows/bun-ci.yml b/.github/workflows/bun-ci.yml
index d3e4dcb7..83c3eac9 100644
--- a/.github/workflows/bun-ci.yml
+++ b/.github/workflows/bun-ci.yml
@@ -27,7 +27,16 @@ jobs:
NEXT_PUBLIC_SITE_URL: "http://localhost:3000"
UPSTASH_REDIS_REST_URL: "https://your-upstash-redis-url"
UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token"
-
+ NEXT_PUBLIC_MARBLE_API_URL: "https://placeholder.example.com"
+ MARBLE_WORKSPACE_KEY: "placeholder"
+ FREESOUND_CLIENT_ID: "placeholder"
+ FREESOUND_API_KEY: "placeholder"
+ CLOUDFLARE_ACCOUNT_ID: "placeholder"
+ R2_ACCESS_KEY_ID: "placeholder"
+ R2_SECRET_ACCESS_KEY: "placeholder"
+ R2_BUCKET_NAME: "placeholder"
+ MODAL_TRANSCRIPTION_URL: "https://placeholder.example.com"
+
steps:
- name: Checkout repository
uses: actions/checkout@v4
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 7672666a..6caa16da 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,20 +1,20 @@
{
- "editor.defaultFormatter": "esbenp.prettier-vscode",
- "[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": {
- "editor.defaultFormatter": "esbenp.prettier-vscode"
- },
- "typescript.tsdk": "node_modules/typescript/lib",
- "editor.formatOnSave": true,
- "editor.formatOnPaste": true,
- "editor.codeActionsOnSave": {
- "source.fixAll.biome": "explicit",
- "source.organizeImports.biome": "explicit"
- },
- "emmet.showExpandedAbbreviation": "never",
- "[typescriptreact]": {
- "editor.defaultFormatter": "biomejs.biome"
- },
- "[typescript]": {
- "editor.defaultFormatter": "biomejs.biome"
- }
+ "editor.defaultFormatter": "esbenp.prettier-vscode",
+ "[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": {
+ "editor.defaultFormatter": "esbenp.prettier-vscode"
+ },
+ "typescript.tsdk": "node_modules/typescript/lib",
+ "editor.formatOnSave": true,
+ "editor.formatOnPaste": true,
+ "editor.codeActionsOnSave": {
+ "source.fixAll.biome": "explicit",
+ "source.organizeImports.biome": "explicit"
+ },
+ "emmet.showExpandedAbbreviation": "never",
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ },
+ "[typescript]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ }
}
diff --git a/apps/web/.gitignore b/apps/web/.gitignore
index dc069c57..4b8a9fe9 100644
--- a/apps/web/.gitignore
+++ b/apps/web/.gitignore
@@ -5,4 +5,6 @@
.env*
!.env.example
-.next/ \ No newline at end of file
+.next/
+
+.font-cache/ \ No newline at end of file
diff --git a/apps/web/components.json b/apps/web/components.json
index 3289f237..5bcedb31 100644
--- a/apps/web/components.json
+++ b/apps/web/components.json
@@ -1,21 +1,21 @@
{
- "$schema": "https://ui.shadcn.com/schema.json",
- "style": "new-york",
- "rsc": true,
- "tsx": true,
- "tailwind": {
- "config": "",
- "css": "src/app/globals.css",
- "baseColor": "neutral",
- "cssVariables": true,
- "prefix": ""
- },
- "aliases": {
- "components": "@/components",
- "utils": "@/lib/utils",
- "ui": "@/components/ui",
- "lib": "@/lib",
- "hooks": "@/hooks"
- },
- "iconLibrary": "lucide"
+ "$schema": "https://ui.shadcn.com/schema.json",
+ "style": "new-york",
+ "rsc": true,
+ "tsx": true,
+ "tailwind": {
+ "config": "",
+ "css": "src/app/globals.css",
+ "baseColor": "neutral",
+ "cssVariables": true,
+ "prefix": ""
+ },
+ "aliases": {
+ "components": "@/components",
+ "utils": "@/lib/utils",
+ "ui": "@/components/ui",
+ "lib": "@/lib",
+ "hooks": "@/hooks"
+ },
+ "iconLibrary": "lucide"
}
diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts
index 2d53d6b9..be2a59fc 100644
--- a/apps/web/drizzle.config.ts
+++ b/apps/web/drizzle.config.ts
@@ -4,20 +4,20 @@ import { webEnv } from "@opencut/env/web";
// Load the right env file based on environment
if (webEnv.NODE_ENV === "production") {
- dotenv.config({ path: ".env.production" });
+ dotenv.config({ path: ".env.production" });
} else {
- dotenv.config({ path: ".env.local" });
+ dotenv.config({ path: ".env.local" });
}
export default {
- schema: "./src/schema.ts",
- dialect: "postgresql",
- migrations: {
- table: "drizzle_migrations",
- },
- dbCredentials: {
- url: webEnv.DATABASE_URL,
- },
- out: "./migrations",
- strict: webEnv.NODE_ENV === "production",
+ schema: "./src/schema.ts",
+ dialect: "postgresql",
+ migrations: {
+ table: "drizzle_migrations",
+ },
+ dbCredentials: {
+ url: webEnv.DATABASE_URL,
+ },
+ out: "./migrations",
+ strict: webEnv.NODE_ENV === "production",
} satisfies Config;
diff --git a/apps/web/migrations/meta/0000_snapshot.json b/apps/web/migrations/meta/0000_snapshot.json
index 8d505274..2f6c9b30 100644
--- a/apps/web/migrations/meta/0000_snapshot.json
+++ b/apps/web/migrations/meta/0000_snapshot.json
@@ -1,344 +1,344 @@
{
- "id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6",
- "prevId": "00000000-0000-0000-0000-000000000000",
- "version": "7",
- "dialect": "postgresql",
- "tables": {
- "public.accounts": {
- "name": "accounts",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "text",
- "primaryKey": true,
- "notNull": true
- },
- "account_id": {
- "name": "account_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "provider_id": {
- "name": "provider_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "user_id": {
- "name": "user_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "access_token": {
- "name": "access_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "refresh_token": {
- "name": "refresh_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "id_token": {
- "name": "id_token",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "access_token_expires_at": {
- "name": "access_token_expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "refresh_token_expires_at": {
- "name": "refresh_token_expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "scope": {
- "name": "scope",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "password": {
- "name": "password",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "accounts_user_id_users_id_fk": {
- "name": "accounts_user_id_users_id_fk",
- "tableFrom": "accounts",
- "tableTo": "users",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": true
- },
- "public.sessions": {
- "name": "sessions",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "text",
- "primaryKey": true,
- "notNull": true
- },
- "expires_at": {
- "name": "expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "token": {
- "name": "token",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "ip_address": {
- "name": "ip_address",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "user_agent": {
- "name": "user_agent",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "user_id": {
- "name": "user_id",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {
- "sessions_user_id_users_id_fk": {
- "name": "sessions_user_id_users_id_fk",
- "tableFrom": "sessions",
- "tableTo": "users",
- "columnsFrom": ["user_id"],
- "columnsTo": ["id"],
- "onDelete": "cascade",
- "onUpdate": "no action"
- }
- },
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "sessions_token_unique": {
- "name": "sessions_token_unique",
- "nullsNotDistinct": false,
- "columns": ["token"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": true
- },
- "public.users": {
- "name": "users",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "text",
- "primaryKey": true,
- "notNull": true
- },
- "name": {
- "name": "name",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "email_verified": {
- "name": "email_verified",
- "type": "boolean",
- "primaryKey": false,
- "notNull": true
- },
- "image": {
- "name": "image",
- "type": "text",
- "primaryKey": false,
- "notNull": false
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "users_email_unique": {
- "name": "users_email_unique",
- "nullsNotDistinct": false,
- "columns": ["email"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": true
- },
- "public.verifications": {
- "name": "verifications",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "text",
- "primaryKey": true,
- "notNull": true
- },
- "identifier": {
- "name": "identifier",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "value": {
- "name": "value",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "expires_at": {
- "name": "expires_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- },
- "updated_at": {
- "name": "updated_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": false
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {},
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": true
- },
- "public.waitlist": {
- "name": "waitlist",
- "schema": "",
- "columns": {
- "id": {
- "name": "id",
- "type": "text",
- "primaryKey": true,
- "notNull": true
- },
- "email": {
- "name": "email",
- "type": "text",
- "primaryKey": false,
- "notNull": true
- },
- "created_at": {
- "name": "created_at",
- "type": "timestamp",
- "primaryKey": false,
- "notNull": true
- }
- },
- "indexes": {},
- "foreignKeys": {},
- "compositePrimaryKeys": {},
- "uniqueConstraints": {
- "waitlist_email_unique": {
- "name": "waitlist_email_unique",
- "nullsNotDistinct": false,
- "columns": ["email"]
- }
- },
- "policies": {},
- "checkConstraints": {},
- "isRLSEnabled": true
- }
- },
- "enums": {},
- "schemas": {},
- "sequences": {},
- "roles": {},
- "policies": {},
- "views": {},
- "_meta": {
- "columns": {},
- "schemas": {},
- "tables": {}
- }
+ "id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6",
+ "prevId": "00000000-0000-0000-0000-000000000000",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.accounts": {
+ "name": "accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "accounts_user_id_users_id_fk": {
+ "name": "accounts_user_id_users_id_fk",
+ "tableFrom": "accounts",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.verifications": {
+ "name": "verifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ },
+ "public.waitlist": {
+ "name": "waitlist",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "waitlist_email_unique": {
+ "name": "waitlist_email_unique",
+ "nullsNotDistinct": false,
+ "columns": ["email"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": true
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
}
diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json
index c7d30f16..a3cb0863 100644
--- a/apps/web/migrations/meta/_journal.json
+++ b/apps/web/migrations/meta/_journal.json
@@ -1,13 +1,13 @@
{
- "version": "7",
- "dialect": "postgresql",
- "entries": [
- {
- "idx": 0,
- "version": "7",
- "when": 1750753385927,
- "tag": "0000_brainy_saracen",
- "breakpoints": true
- }
- ]
+ "version": "7",
+ "dialect": "postgresql",
+ "entries": [
+ {
+ "idx": 0,
+ "version": "7",
+ "when": 1750753385927,
+ "tag": "0000_brainy_saracen",
+ "breakpoints": true
+ }
+ ]
}
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index cd79efb7..fa1ac7f5 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -2,48 +2,48 @@ import type { NextConfig } from "next";
import { withBotId } from "botid/next/config";
const nextConfig: NextConfig = {
- compiler: {
- removeConsole: process.env.NODE_ENV === "production",
- },
- reactStrictMode: true,
- productionBrowserSourceMaps: true,
- output: "standalone",
- images: {
- remotePatterns: [
- {
- protocol: "https",
- hostname: "plus.unsplash.com",
- },
- {
- protocol: "https",
- hostname: "images.unsplash.com",
- },
- {
- protocol: "https",
- hostname: "images.marblecms.com",
- },
- {
- protocol: "https",
- hostname: "lh3.googleusercontent.com",
- },
- {
- protocol: "https",
- hostname: "avatars.githubusercontent.com",
- },
- {
- protocol: "https",
- hostname: "api.iconify.design",
- },
- {
- protocol: "https",
- hostname: "api.simplesvg.com",
- },
- {
- protocol: "https",
- hostname: "api.unisvg.com",
- },
- ],
- },
+ compiler: {
+ removeConsole: process.env.NODE_ENV === "production",
+ },
+ reactStrictMode: true,
+ productionBrowserSourceMaps: true,
+ output: "standalone",
+ images: {
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "plus.unsplash.com",
+ },
+ {
+ protocol: "https",
+ hostname: "images.unsplash.com",
+ },
+ {
+ protocol: "https",
+ hostname: "images.marblecms.com",
+ },
+ {
+ protocol: "https",
+ hostname: "lh3.googleusercontent.com",
+ },
+ {
+ protocol: "https",
+ hostname: "avatars.githubusercontent.com",
+ },
+ {
+ protocol: "https",
+ hostname: "api.iconify.design",
+ },
+ {
+ protocol: "https",
+ hostname: "api.simplesvg.com",
+ },
+ {
+ protocol: "https",
+ hostname: "api.unisvg.com",
+ },
+ ],
+ },
};
export default withBotId(nextConfig);
diff --git a/apps/web/package.json b/apps/web/package.json
index 31582be1..9a57a379 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,98 +1,107 @@
{
- "name": "@opencut/web",
- "version": "0.1.0",
- "private": true,
- "packageManager": "bun@1.2.18",
- "scripts": {
- "dev": "next dev --turbopack",
- "build": "next build",
- "start": "next start",
- "lint": "biome check src/",
- "lint:fix": "biome check src/ --write",
- "format": "biome format src/ --write",
- "db:generate": "drizzle-kit generate",
- "db:migrate": "drizzle-kit migrate",
- "db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
- "db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
- },
- "dependencies": {
- "@ffmpeg/core": "^0.12.10",
- "@ffmpeg/ffmpeg": "^0.12.15",
- "@ffmpeg/util": "^0.12.2",
- "@hello-pangea/dnd": "^18.0.1",
- "@hookform/resolvers": "^3.9.1",
- "@hugeicons/core-free-icons": "^3.1.1",
- "@hugeicons/react": "^1.1.4",
- "@huggingface/transformers": "^3.8.1",
- "@opencut/env": "workspace:*",
- "@opencut/ui": "workspace:*",
- "@radix-ui/react-dialog": "^1.1.15",
- "@radix-ui/react-select": "^2.2.6",
- "@radix-ui/react-separator": "^1.1.8",
- "@radix-ui/react-slot": "^1.2.4",
- "@radix-ui/react-tooltip": "^1.2.8",
- "@upstash/ratelimit": "^2.0.6",
- "@upstash/redis": "^1.35.4",
- "@vercel/analytics": "^1.4.1",
- "aws4fetch": "^1.0.20",
- "better-auth": "^1.2.7",
- "botid": "^1.4.2",
- "class-variance-authority": "^0.7.1",
- "clsx": "^2.1.1",
- "cmdk": "^1.0.0",
- "dayjs": "^1.11.13",
- "drizzle-orm": "^0.44.2",
- "embla-carousel-react": "^8.5.1",
- "eventemitter3": "^5.0.1",
- "feed": "^5.1.0",
- "input-otp": "^1.4.1",
- "lucide-react": "^0.562.0",
- "mediabunny": "^1.29.1",
- "motion": "^12.18.1",
- "nanoid": "^5.1.5",
- "next": "16.1.3",
- "next-themes": "^0.4.4",
- "pg": "^8.16.2",
- "postgres": "^3.4.5",
- "radix-ui": "^1.4.2",
- "react": "^19.0.0",
- "react-day-picker": "^8.10.1",
- "react-dom": "^19.0.0",
- "react-hook-form": "^7.54.0",
- "react-icons": "^5.4.0",
- "react-markdown": "^10.1.0",
- "react-phone-number-input": "^3.4.11",
- "react-resizable-panels": "^2.1.7",
- "recharts": "^2.14.1",
- "rehype-autolink-headings": "^7.1.0",
- "rehype-parse": "^9.0.1",
- "rehype-sanitize": "^6.0.0",
- "rehype-slug": "^6.0.0",
- "rehype-stringify": "^10.0.1",
- "sonner": "^1.7.1",
- "tailwind-merge": "^2.5.5",
- "tailwindcss-animate": "^1.0.7",
- "unified": "^11.0.5",
- "use-deep-compare-effect": "^1.8.1",
- "vaul": "^1.1.1",
- "wavesurfer.js": "^7.9.8",
- "zod": "^3.25.67",
- "zustand": "^5.0.2"
- },
- "devDependencies": {
- "@tailwindcss/postcss": "^4.1.11",
- "@tailwindcss/typography": "^0.5.16",
- "@types/bun": "latest",
- "@types/node": "^24.2.1",
- "@types/pg": "^8.15.4",
- "@types/react": "^19",
- "@types/react-dom": "^19",
- "cross-env": "^7.0.3",
- "drizzle-kit": "^0.31.4",
- "dotenv": "^16.5.0",
- "postcss": "^8",
- "tailwindcss": "^4.1.11",
- "tsx": "^4.7.1",
- "typescript": "^5.8.3"
- }
+ "name": "@opencut/web",
+ "version": "0.1.0",
+ "private": true,
+ "packageManager": "bun@1.2.18",
+ "scripts": {
+ "dev": "next dev --turbopack",
+ "build": "next build",
+ "start": "next start",
+ "lint": "biome check src/",
+ "lint:fix": "biome check src/ --write",
+ "format": "biome format src/ --write",
+ "db:generate": "drizzle-kit generate",
+ "db:migrate": "drizzle-kit migrate",
+ "db:push:local": "cross-env NODE_ENV=development drizzle-kit push",
+ "db:push:prod": "cross-env NODE_ENV=production drizzle-kit push"
+ },
+ "dependencies": {
+ "@ffmpeg/core": "^0.12.10",
+ "@ffmpeg/ffmpeg": "^0.12.15",
+ "@ffmpeg/util": "^0.12.2",
+ "@hello-pangea/dnd": "^18.0.1",
+ "@hookform/resolvers": "^3.9.1",
+ "@hugeicons/core-free-icons": "^3.1.1",
+ "@hugeicons/react": "^1.1.4",
+ "@huggingface/transformers": "^3.8.1",
+ "@opencut/env": "workspace:*",
+ "@opencut/ui": "workspace:*",
+ "@radix-ui/react-accordion": "^1.2.12",
+ "@radix-ui/react-checkbox": "^1.3.3",
+ "@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-primitive": "^2.1.4",
+ "@radix-ui/react-select": "^2.2.6",
+ "@radix-ui/react-separator": "^1.1.8",
+ "@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-tooltip": "^1.2.8",
+ "@types/culori": "^4.0.1",
+ "@upstash/ratelimit": "^2.0.6",
+ "@upstash/redis": "^1.35.4",
+ "@vercel/analytics": "^1.4.1",
+ "aws4fetch": "^1.0.20",
+ "better-auth": "^1.2.7",
+ "botid": "^1.4.2",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "cmdk": "^1.0.0",
+ "culori": "^4.0.2",
+ "dayjs": "^1.11.13",
+ "drizzle-orm": "^0.44.2",
+ "embla-carousel-react": "^8.5.1",
+ "eventemitter3": "^5.0.1",
+ "feed": "^5.1.0",
+ "input-otp": "^1.4.1",
+ "lucide-react": "^0.562.0",
+ "mediabunny": "^1.29.1",
+ "motion": "^12.18.1",
+ "nanoid": "^5.1.5",
+ "next": "16.1.3",
+ "next-themes": "^0.4.4",
+ "pg": "^8.16.2",
+ "postgres": "^3.4.5",
+ "radix-ui": "^1.4.3",
+ "react": "^19.0.0",
+ "react-day-picker": "^8.10.1",
+ "react-dom": "^19.0.0",
+ "react-hook-form": "^7.54.0",
+ "react-icons": "^5.4.0",
+ "react-markdown": "^10.1.0",
+ "react-phone-number-input": "^3.4.11",
+ "react-resizable-panels": "^2.1.7",
+ "react-window": "^2.2.7",
+ "recharts": "^2.14.1",
+ "rehype-autolink-headings": "^7.1.0",
+ "rehype-parse": "^9.0.1",
+ "rehype-sanitize": "^6.0.0",
+ "rehype-slug": "^6.0.0",
+ "rehype-stringify": "^10.0.1",
+ "sonner": "^1.7.1",
+ "tailwind-merge": "^2.5.5",
+ "tailwindcss-animate": "^1.0.7",
+ "unified": "^11.0.5",
+ "use-deep-compare-effect": "^1.8.1",
+ "vaul": "^1.1.2",
+ "wavesurfer.js": "^7.9.8",
+ "zod": "^3.25.67",
+ "zustand": "^5.0.2"
+ },
+ "devDependencies": {
+ "@napi-rs/canvas": "^0.1.92",
+ "@tailwindcss/postcss": "^4.1.11",
+ "@tailwindcss/typography": "^0.5.16",
+ "@types/bun": "latest",
+ "@types/node": "^24.2.1",
+ "@types/pg": "^8.15.4",
+ "@types/react": "^19",
+ "@types/react-dom": "^19",
+ "cross-env": "^7.0.3",
+ "dotenv": "^16.5.0",
+ "drizzle-kit": "^0.31.4",
+ "postcss": "^8",
+ "sharp": "^0.34.5",
+ "tailwindcss": "^4.1.11",
+ "tsx": "^4.7.1",
+ "typescript": "^5.8.3"
+ }
}
diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs
index 79bcf135..6ba14df9 100644
--- a/apps/web/postcss.config.mjs
+++ b/apps/web/postcss.config.mjs
@@ -1,8 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
- plugins: {
- "@tailwindcss/postcss": {},
- },
+ plugins: {
+ "@tailwindcss/postcss": {},
+ },
};
export default config;
diff --git a/apps/web/public/countries.json b/apps/web/public/countries.json
new file mode 100644
index 00000000..8d53cfae
--- /dev/null
+++ b/apps/web/public/countries.json
@@ -0,0 +1,1794 @@
+[
+ {
+ "name": "Andorra",
+ "code": "AD",
+ "languages": ["catalan"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "United Arab Emirates",
+ "code": "AE",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "green", "white", "black"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Afghanistan",
+ "code": "AF",
+ "languages": ["dari", "pashto"],
+ "flag_colors": ["black", "red", "green"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Antigua and Barbuda",
+ "code": "AG",
+ "languages": ["english"],
+ "flag_colors": ["red", "black", "blue", "white", "yellow"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Anguilla",
+ "code": "AI",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "orange"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Albania",
+ "code": "AL",
+ "languages": ["albanian"],
+ "flag_colors": ["red", "black"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Armenia",
+ "code": "AM",
+ "languages": ["armenian"],
+ "flag_colors": ["red", "blue", "orange"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Angola",
+ "code": "AO",
+ "languages": ["portuguese"],
+ "flag_colors": ["red", "black", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Antarctica",
+ "code": "AQ",
+ "languages": [],
+ "flag_colors": [],
+ "region": "Antarctica"
+ },
+ {
+ "name": "Argentina",
+ "code": "AR",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "American Samoa",
+ "code": "AS",
+ "languages": ["english", "samoan"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Austria",
+ "code": "AT",
+ "languages": ["german"],
+ "flag_colors": ["red", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Australia",
+ "code": "AU",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Aruba",
+ "code": "AW",
+ "languages": ["dutch", "papiamento"],
+ "flag_colors": ["blue", "yellow", "red", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Aland Islands",
+ "code": "AX",
+ "languages": ["swedish"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Azerbaijan",
+ "code": "AZ",
+ "languages": ["azerbaijani"],
+ "flag_colors": ["blue", "red", "green", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Bosnia and Herzegovina",
+ "code": "BA",
+ "languages": ["bosnian", "croatian", "serbian"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Barbados",
+ "code": "BB",
+ "languages": ["english"],
+ "flag_colors": ["blue", "yellow", "black"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Bangladesh",
+ "code": "BD",
+ "languages": ["bengali"],
+ "flag_colors": ["green", "red"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Belgium",
+ "code": "BE",
+ "languages": ["french", "dutch", "german"],
+ "flag_colors": ["black", "yellow", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Burkina Faso",
+ "code": "BF",
+ "languages": ["french"],
+ "flag_colors": ["red", "green", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Bulgaria",
+ "code": "BG",
+ "languages": ["bulgarian"],
+ "flag_colors": ["white", "green", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Bahrain",
+ "code": "BH",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Burundi",
+ "code": "BI",
+ "languages": ["kirundi", "french", "english"],
+ "flag_colors": ["red", "green", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Benin",
+ "code": "BJ",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Saint Barthelemy",
+ "code": "BL",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Bermuda",
+ "code": "BM",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "North Atlantic"
+ },
+ {
+ "name": "Brunei",
+ "code": "BN",
+ "languages": ["malay"],
+ "flag_colors": ["yellow", "white", "black", "red"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Bolivia",
+ "code": "BO",
+ "languages": ["spanish", "quechua", "aymara"],
+ "flag_colors": ["red", "yellow", "green"],
+ "region": "South America"
+ },
+ {
+ "name": "Sint Eustatius",
+ "code": "BQ-SE",
+ "languages": ["dutch", "english"],
+ "flag_colors": ["blue", "white", "red", "yellow"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Caribbean Netherlands",
+ "code": "BQ",
+ "languages": ["dutch", "papiamento", "english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Brazil",
+ "code": "BR",
+ "languages": ["portuguese"],
+ "flag_colors": ["green", "yellow", "blue", "white"],
+ "region": "South America"
+ },
+ {
+ "name": "Bahamas",
+ "code": "BS",
+ "languages": ["english"],
+ "flag_colors": ["blue", "yellow", "black"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Bhutan",
+ "code": "BT",
+ "languages": ["dzongkha"],
+ "flag_colors": ["yellow", "orange", "white"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Bouvet Island",
+ "code": "BV",
+ "languages": [],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Antarctica"
+ },
+ {
+ "name": "Botswana",
+ "code": "BW",
+ "languages": ["english", "tswana"],
+ "flag_colors": ["blue", "black", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Belarus",
+ "code": "BY",
+ "languages": ["belarusian", "russian"],
+ "flag_colors": ["red", "green", "white"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Belize",
+ "code": "BZ",
+ "languages": ["english"],
+ "flag_colors": ["blue", "red", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Canada",
+ "code": "CA",
+ "languages": ["english", "french"],
+ "flag_colors": ["red", "white"],
+ "region": "North America"
+ },
+ {
+ "name": "Cocos (Keeling) Islands",
+ "code": "CC",
+ "languages": ["english"],
+ "flag_colors": ["green", "red", "yellow", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Democratic Republic of the Congo",
+ "code": "CD",
+ "languages": ["french", "lingala", "swahili", "tshiluba", "kikongo"],
+ "flag_colors": ["blue", "red", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Central African Republic",
+ "code": "CF",
+ "languages": ["french", "sango"],
+ "flag_colors": ["blue", "white", "green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Republic of the Congo",
+ "code": "CG",
+ "languages": ["french", "lingala", "kituba"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Switzerland",
+ "code": "CH",
+ "languages": ["german", "french", "italian"],
+ "flag_colors": ["red", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Cote d'Ivoire",
+ "code": "CI",
+ "languages": ["french"],
+ "flag_colors": ["orange", "white", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Cook Islands",
+ "code": "CK",
+ "languages": ["english", "cook islands maori"],
+ "flag_colors": ["blue", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Chile",
+ "code": "CL",
+ "languages": ["spanish"],
+ "flag_colors": ["white", "red", "blue"],
+ "region": "South America"
+ },
+ {
+ "name": "Cameroon",
+ "code": "CM",
+ "languages": ["english", "french"],
+ "flag_colors": ["green", "red", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "China",
+ "code": "CN",
+ "languages": ["mandarin"],
+ "flag_colors": ["red", "yellow"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Colombia",
+ "code": "CO",
+ "languages": ["spanish"],
+ "flag_colors": ["yellow", "blue", "red"],
+ "region": "South America"
+ },
+ {
+ "name": "Costa Rica",
+ "code": "CR",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Central America"
+ },
+ {
+ "name": "Cuba",
+ "code": "CU",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Cape Verde",
+ "code": "CV",
+ "languages": ["portuguese"],
+ "flag_colors": ["blue", "white", "red", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Curacao",
+ "code": "CW",
+ "languages": ["dutch", "papiamento", "english"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Christmas Island",
+ "code": "CX",
+ "languages": ["english"],
+ "flag_colors": ["green", "blue", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Cyprus",
+ "code": "CY",
+ "languages": ["greek", "turkish"],
+ "flag_colors": ["white", "orange", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Czechia",
+ "code": "CZ",
+ "languages": ["czech"],
+ "flag_colors": ["white", "red", "blue"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Germany",
+ "code": "DE",
+ "languages": ["german"],
+ "flag_colors": ["black", "red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Djibouti",
+ "code": "DJ",
+ "languages": ["arabic", "french", "afar", "somali"],
+ "flag_colors": ["blue", "green", "white", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Denmark",
+ "code": "DK",
+ "languages": ["danish"],
+ "flag_colors": ["red", "white"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Dominica",
+ "code": "DM",
+ "languages": ["english"],
+ "flag_colors": ["green", "yellow", "black", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Dominican Republic",
+ "code": "DO",
+ "languages": ["spanish"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Algeria",
+ "code": "DZ",
+ "languages": ["arabic", "tamazight"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Ecuador",
+ "code": "EC",
+ "languages": ["spanish"],
+ "flag_colors": ["yellow", "blue", "red"],
+ "region": "South America"
+ },
+ {
+ "name": "Estonia",
+ "code": "EE",
+ "languages": ["estonian"],
+ "flag_colors": ["blue", "black", "white"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Egypt",
+ "code": "EG",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "black", "yellow"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Western Sahara",
+ "code": "EH",
+ "languages": ["arabic", "spanish"],
+ "flag_colors": ["black", "white", "green", "red"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Eritrea",
+ "code": "ER",
+ "languages": ["tigrinya", "arabic", "english"],
+ "flag_colors": ["red", "green", "blue", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Spain",
+ "code": "ES",
+ "languages": ["spanish"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Ethiopia",
+ "code": "ET",
+ "languages": ["amharic", "oromo"],
+ "flag_colors": ["green", "yellow", "red", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "European Union",
+ "code": "EU",
+ "languages": ["english", "french", "german"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Finland",
+ "code": "FI",
+ "languages": ["finnish", "swedish"],
+ "flag_colors": ["white", "blue"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Fiji",
+ "code": "FJ",
+ "languages": ["english", "fijian", "hindi"],
+ "flag_colors": ["blue", "white", "red", "yellow"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Falkland Islands",
+ "code": "FK",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "Micronesia",
+ "code": "FM",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Faroe Islands",
+ "code": "FO",
+ "languages": ["faroese", "danish"],
+ "flag_colors": ["white", "red", "blue"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "France",
+ "code": "FR",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Gabon",
+ "code": "GA",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "England",
+ "code": "GB-ENG",
+ "languages": ["english"],
+ "flag_colors": ["red", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Northern Ireland",
+ "code": "GB-NIR",
+ "languages": ["english", "irish"],
+ "flag_colors": ["white", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Scotland",
+ "code": "GB-SCT",
+ "languages": ["english", "scottish gaelic"],
+ "flag_colors": ["blue", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Wales",
+ "code": "GB-WLS",
+ "languages": ["english", "welsh"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "United Kingdom",
+ "code": "GB",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Grenada",
+ "code": "GD",
+ "languages": ["english"],
+ "flag_colors": ["red", "yellow", "green", "black"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Georgia",
+ "code": "GE",
+ "languages": ["georgian"],
+ "flag_colors": ["white", "red"],
+ "region": "Middle East"
+ },
+ {
+ "name": "French Guiana",
+ "code": "GF",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "South America"
+ },
+ {
+ "name": "Guernsey",
+ "code": "GG",
+ "languages": ["english", "french"],
+ "flag_colors": ["white", "red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Ghana",
+ "code": "GH",
+ "languages": ["english"],
+ "flag_colors": ["red", "yellow", "green", "black"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Gibraltar",
+ "code": "GI",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Greenland",
+ "code": "GL",
+ "languages": ["kalaallisut", "danish"],
+ "flag_colors": ["red", "white"],
+ "region": "North America"
+ },
+ {
+ "name": "Gambia",
+ "code": "GM",
+ "languages": ["english"],
+ "flag_colors": ["red", "blue", "green", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Guinea",
+ "code": "GN",
+ "languages": ["french"],
+ "flag_colors": ["red", "yellow", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Guadeloupe",
+ "code": "GP",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Equatorial Guinea",
+ "code": "GQ",
+ "languages": ["spanish", "french", "portuguese"],
+ "flag_colors": ["green", "white", "red", "blue", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Greece",
+ "code": "GR",
+ "languages": ["greek"],
+ "flag_colors": ["blue", "white"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "South Georgia and the South Sandwich Islands",
+ "code": "GS",
+ "languages": [],
+ "flag_colors": ["blue", "white", "yellow"],
+ "region": "Atlantic Ocean"
+ },
+ {
+ "name": "Guatemala",
+ "code": "GT",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Guam",
+ "code": "GU",
+ "languages": ["english", "chamorro"],
+ "flag_colors": ["blue", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Guinea-Bissau",
+ "code": "GW",
+ "languages": ["portuguese"],
+ "flag_colors": ["red", "yellow", "green", "black"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Guyana",
+ "code": "GY",
+ "languages": ["english"],
+ "flag_colors": ["green", "yellow", "red", "black", "white"],
+ "region": "South America"
+ },
+ {
+ "name": "Hong Kong",
+ "code": "HK",
+ "languages": ["cantonese", "english"],
+ "flag_colors": ["red", "white"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Heard Island and McDonald Islands",
+ "code": "HM",
+ "languages": [],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Honduras",
+ "code": "HN",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Croatia",
+ "code": "HR",
+ "languages": ["croatian"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Haiti",
+ "code": "HT",
+ "languages": ["haitian creole", "french"],
+ "flag_colors": ["blue", "red", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Hungary",
+ "code": "HU",
+ "languages": ["hungarian"],
+ "flag_colors": ["red", "white", "green"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Indonesia",
+ "code": "ID",
+ "languages": ["indonesian"],
+ "flag_colors": ["red", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Ireland",
+ "code": "IE",
+ "languages": ["english", "irish"],
+ "flag_colors": ["green", "white", "orange"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Israel",
+ "code": "IL",
+ "languages": ["hebrew", "arabic"],
+ "flag_colors": ["blue", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Isle of Man",
+ "code": "IM",
+ "languages": ["english", "manx"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "India",
+ "code": "IN",
+ "languages": ["hindi", "english"],
+ "flag_colors": ["orange", "white", "green", "blue"],
+ "region": "South Asia"
+ },
+ {
+ "name": "British Indian Ocean Territory",
+ "code": "IO",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red", "yellow"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Iraq",
+ "code": "IQ",
+ "languages": ["arabic", "kurdish"],
+ "flag_colors": ["red", "white", "black", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Iran",
+ "code": "IR",
+ "languages": ["persian"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Iceland",
+ "code": "IS",
+ "languages": ["icelandic"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Italy",
+ "code": "IT",
+ "languages": ["italian"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Jersey",
+ "code": "JE",
+ "languages": ["english", "french"],
+ "flag_colors": ["white", "red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Jamaica",
+ "code": "JM",
+ "languages": ["english"],
+ "flag_colors": ["green", "yellow", "black"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Jordan",
+ "code": "JO",
+ "languages": ["arabic"],
+ "flag_colors": ["black", "white", "green", "red"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Japan",
+ "code": "JP",
+ "languages": ["japanese"],
+ "flag_colors": ["red", "white"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Kenya",
+ "code": "KE",
+ "languages": ["english", "swahili"],
+ "flag_colors": ["black", "red", "green", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Kyrgyzstan",
+ "code": "KG",
+ "languages": ["kyrgyz", "russian"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Cambodia",
+ "code": "KH",
+ "languages": ["khmer"],
+ "flag_colors": ["blue", "red", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Kiribati",
+ "code": "KI",
+ "languages": ["english", "kiribati"],
+ "flag_colors": ["red", "blue", "white", "yellow"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Comoros",
+ "code": "KM",
+ "languages": ["comorian", "arabic", "french"],
+ "flag_colors": ["green", "yellow", "white", "red", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Saint Kitts and Nevis",
+ "code": "KN",
+ "languages": ["english"],
+ "flag_colors": ["green", "red", "black", "yellow", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "North Korea",
+ "code": "KP",
+ "languages": ["korean"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "East Asia"
+ },
+ {
+ "name": "South Korea",
+ "code": "KR",
+ "languages": ["korean"],
+ "flag_colors": ["white", "red", "blue", "black"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Kuwait",
+ "code": "KW",
+ "languages": ["arabic"],
+ "flag_colors": ["green", "white", "red", "black"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Cayman Islands",
+ "code": "KY",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Kazakhstan",
+ "code": "KZ",
+ "languages": ["kazakh", "russian"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Laos",
+ "code": "LA",
+ "languages": ["lao"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Lebanon",
+ "code": "LB",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Saint Lucia",
+ "code": "LC",
+ "languages": ["english"],
+ "flag_colors": ["blue", "yellow", "black", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Liechtenstein",
+ "code": "LI",
+ "languages": ["german"],
+ "flag_colors": ["blue", "red", "yellow"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Sri Lanka",
+ "code": "LK",
+ "languages": ["sinhala", "tamil"],
+ "flag_colors": ["yellow", "green", "orange", "red"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Liberia",
+ "code": "LR",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Lesotho",
+ "code": "LS",
+ "languages": ["sesotho", "english"],
+ "flag_colors": ["blue", "white", "green", "black"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Lithuania",
+ "code": "LT",
+ "languages": ["lithuanian"],
+ "flag_colors": ["yellow", "green", "red"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Luxembourg",
+ "code": "LU",
+ "languages": ["luxembourgish", "french", "german"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Latvia",
+ "code": "LV",
+ "languages": ["latvian"],
+ "flag_colors": ["red", "white"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Libya",
+ "code": "LY",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "black", "green", "white"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Morocco",
+ "code": "MA",
+ "languages": ["arabic", "tamazight"],
+ "flag_colors": ["red", "green"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Monaco",
+ "code": "MC",
+ "languages": ["french"],
+ "flag_colors": ["red", "white"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Moldova",
+ "code": "MD",
+ "languages": ["romanian"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Montenegro",
+ "code": "ME",
+ "languages": ["montenegrin"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Saint Martin",
+ "code": "MF",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Madagascar",
+ "code": "MG",
+ "languages": ["malagasy", "french"],
+ "flag_colors": ["white", "red", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Marshall Islands",
+ "code": "MH",
+ "languages": ["marshallese", "english"],
+ "flag_colors": ["blue", "white", "orange"],
+ "region": "Oceania"
+ },
+ {
+ "name": "North Macedonia",
+ "code": "MK",
+ "languages": ["macedonian", "albanian"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Mali",
+ "code": "ML",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Myanmar",
+ "code": "MM",
+ "languages": ["burmese"],
+ "flag_colors": ["yellow", "green", "red", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Mongolia",
+ "code": "MN",
+ "languages": ["mongolian"],
+ "flag_colors": ["red", "blue", "yellow"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Macao",
+ "code": "MO",
+ "languages": ["cantonese", "portuguese"],
+ "flag_colors": ["green", "white", "yellow"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Northern Mariana Islands",
+ "code": "MP",
+ "languages": ["english", "chamorro", "carolinian"],
+ "flag_colors": ["blue", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Martinique",
+ "code": "MQ",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Mauritania",
+ "code": "MR",
+ "languages": ["arabic"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Montserrat",
+ "code": "MS",
+ "languages": ["english"],
+ "flag_colors": ["blue", "green", "white", "yellow", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Malta",
+ "code": "MT",
+ "languages": ["maltese", "english"],
+ "flag_colors": ["white", "red"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Mauritius",
+ "code": "MU",
+ "languages": ["english", "french", "mauritian creole"],
+ "flag_colors": ["red", "blue", "yellow", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Maldives",
+ "code": "MV",
+ "languages": ["divehi"],
+ "flag_colors": ["red", "green", "white"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Malawi",
+ "code": "MW",
+ "languages": ["english", "chichewa"],
+ "flag_colors": ["black", "red", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Mexico",
+ "code": "MX",
+ "languages": ["spanish"],
+ "flag_colors": ["green", "white", "red"],
+ "region": "North America"
+ },
+ {
+ "name": "Malaysia",
+ "code": "MY",
+ "languages": ["malay"],
+ "flag_colors": ["red", "white", "blue", "yellow"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Mozambique",
+ "code": "MZ",
+ "languages": ["portuguese"],
+ "flag_colors": ["green", "black", "yellow", "white", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Namibia",
+ "code": "NA",
+ "languages": ["english"],
+ "flag_colors": ["blue", "red", "green", "white", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "New Caledonia",
+ "code": "NC",
+ "languages": ["french"],
+ "flag_colors": ["blue", "red", "green", "yellow", "black"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Niger",
+ "code": "NE",
+ "languages": ["french"],
+ "flag_colors": ["orange", "white", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Norfolk Island",
+ "code": "NF",
+ "languages": ["english", "norfuk"],
+ "flag_colors": ["green", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Nigeria",
+ "code": "NG",
+ "languages": ["english"],
+ "flag_colors": ["green", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Nicaragua",
+ "code": "NI",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Netherlands",
+ "code": "NL",
+ "languages": ["dutch"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Western Europe"
+ },
+ {
+ "name": "Norway",
+ "code": "NO",
+ "languages": ["norwegian"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Nepal",
+ "code": "NP",
+ "languages": ["nepali"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Nauru",
+ "code": "NR",
+ "languages": ["nauruan", "english"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Niue",
+ "code": "NU",
+ "languages": ["niuean", "english"],
+ "flag_colors": ["yellow", "blue", "white", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "New Zealand",
+ "code": "NZ",
+ "languages": ["english", "maori"],
+ "flag_colors": ["blue", "red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Oman",
+ "code": "OM",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Panama",
+ "code": "PA",
+ "languages": ["spanish"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Central America"
+ },
+ {
+ "name": "Peru",
+ "code": "PE",
+ "languages": ["spanish", "quechua", "aymara"],
+ "flag_colors": ["red", "white"],
+ "region": "South America"
+ },
+ {
+ "name": "French Polynesia",
+ "code": "PF",
+ "languages": ["french", "tahitian"],
+ "flag_colors": ["red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Papua New Guinea",
+ "code": "PG",
+ "languages": ["english", "tok pisin", "hiri motu"],
+ "flag_colors": ["red", "black", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Philippines",
+ "code": "PH",
+ "languages": ["filipino", "english"],
+ "flag_colors": ["blue", "red", "white", "yellow"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Pakistan",
+ "code": "PK",
+ "languages": ["urdu", "english"],
+ "flag_colors": ["green", "white"],
+ "region": "South Asia"
+ },
+ {
+ "name": "Poland",
+ "code": "PL",
+ "languages": ["polish"],
+ "flag_colors": ["white", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Saint Pierre and Miquelon",
+ "code": "PM",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red", "yellow", "black"],
+ "region": "North America"
+ },
+ {
+ "name": "Pitcairn Islands",
+ "code": "PN",
+ "languages": ["english", "pitkern"],
+ "flag_colors": ["blue", "green", "yellow", "red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Puerto Rico",
+ "code": "PR",
+ "languages": ["spanish", "english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Palestine",
+ "code": "PS",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "green", "white", "black"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Portugal",
+ "code": "PT",
+ "languages": ["portuguese"],
+ "flag_colors": ["green", "red", "yellow"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Palau",
+ "code": "PW",
+ "languages": ["palauan", "english"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Paraguay",
+ "code": "PY",
+ "languages": ["spanish", "guarani"],
+ "flag_colors": ["red", "white", "blue", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "Qatar",
+ "code": "QA",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Reunion",
+ "code": "RE",
+ "languages": ["french", "reunion creole"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Romania",
+ "code": "RO",
+ "languages": ["romanian"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Serbia",
+ "code": "RS",
+ "languages": ["serbian"],
+ "flag_colors": ["red", "blue", "white", "yellow"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Russia",
+ "code": "RU",
+ "languages": ["russian"],
+ "flag_colors": ["white", "blue", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Rwanda",
+ "code": "RW",
+ "languages": ["kinyarwanda", "french", "english"],
+ "flag_colors": ["blue", "yellow", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Saudi Arabia",
+ "code": "SA",
+ "languages": ["arabic"],
+ "flag_colors": ["green", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Solomon Islands",
+ "code": "SB",
+ "languages": ["english"],
+ "flag_colors": ["blue", "green", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Seychelles",
+ "code": "SC",
+ "languages": ["seychellois creole", "english", "french"],
+ "flag_colors": ["blue", "yellow", "red", "white", "green"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Sudan",
+ "code": "SD",
+ "languages": ["arabic", "english"],
+ "flag_colors": ["red", "white", "black", "green"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Sweden",
+ "code": "SE",
+ "languages": ["swedish"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Singapore",
+ "code": "SG",
+ "languages": ["english", "malay", "mandarin", "tamil"],
+ "flag_colors": ["red", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Saint Helena",
+ "code": "SH",
+ "languages": ["english"],
+ "flag_colors": ["blue", "red", "white", "yellow", "green"],
+ "region": "Atlantic Ocean"
+ },
+ {
+ "name": "Slovenia",
+ "code": "SI",
+ "languages": ["slovenian"],
+ "flag_colors": ["white", "blue", "red"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Svalbard and Jan Mayen",
+ "code": "SJ",
+ "languages": ["norwegian"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Northern Europe"
+ },
+ {
+ "name": "Slovakia",
+ "code": "SK",
+ "languages": ["slovak"],
+ "flag_colors": ["white", "blue", "red"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Sierra Leone",
+ "code": "SL",
+ "languages": ["english"],
+ "flag_colors": ["green", "white", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "San Marino",
+ "code": "SM",
+ "languages": ["italian"],
+ "flag_colors": ["white", "blue"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Senegal",
+ "code": "SN",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Somalia",
+ "code": "SO",
+ "languages": ["somali", "arabic"],
+ "flag_colors": ["blue", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Suriname",
+ "code": "SR",
+ "languages": ["dutch"],
+ "flag_colors": ["green", "white", "red", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "South Sudan",
+ "code": "SS",
+ "languages": ["english"],
+ "flag_colors": ["black", "red", "green", "blue", "white", "yellow"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Sao Tome and Principe",
+ "code": "ST",
+ "languages": ["portuguese"],
+ "flag_colors": ["green", "yellow", "red", "black"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "El Salvador",
+ "code": "SV",
+ "languages": ["spanish"],
+ "flag_colors": ["blue", "white"],
+ "region": "Central America"
+ },
+ {
+ "name": "Sint Maarten",
+ "code": "SX",
+ "languages": ["dutch", "english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Syria",
+ "code": "SY",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "black", "green"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Eswatini",
+ "code": "SZ",
+ "languages": ["swazi", "english"],
+ "flag_colors": ["blue", "red", "yellow", "black", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Turks and Caicos Islands",
+ "code": "TC",
+ "languages": ["english"],
+ "flag_colors": ["blue", "red", "white", "yellow", "green"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Chad",
+ "code": "TD",
+ "languages": ["french", "arabic"],
+ "flag_colors": ["blue", "yellow", "red"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "French Southern Territories",
+ "code": "TF",
+ "languages": ["french"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Togo",
+ "code": "TG",
+ "languages": ["french"],
+ "flag_colors": ["green", "yellow", "red", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Thailand",
+ "code": "TH",
+ "languages": ["thai"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Tajikistan",
+ "code": "TJ",
+ "languages": ["tajik"],
+ "flag_colors": ["red", "white", "green", "yellow"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Tokelau",
+ "code": "TK",
+ "languages": ["tokelauan", "english"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Timor-Leste",
+ "code": "TL",
+ "languages": ["tetum", "portuguese"],
+ "flag_colors": ["red", "yellow", "black", "white"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Turkmenistan",
+ "code": "TM",
+ "languages": ["turkmen"],
+ "flag_colors": ["green", "red", "white"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Tunisia",
+ "code": "TN",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white"],
+ "region": "North Africa"
+ },
+ {
+ "name": "Tonga",
+ "code": "TO",
+ "languages": ["tongan", "english"],
+ "flag_colors": ["red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Turkey",
+ "code": "TR",
+ "languages": ["turkish"],
+ "flag_colors": ["red", "white"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Trinidad and Tobago",
+ "code": "TT",
+ "languages": ["english"],
+ "flag_colors": ["red", "black", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Tuvalu",
+ "code": "TV",
+ "languages": ["tuvaluan", "english"],
+ "flag_colors": ["blue", "yellow", "red", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Taiwan",
+ "code": "TW",
+ "languages": ["mandarin"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "East Asia"
+ },
+ {
+ "name": "Tanzania",
+ "code": "TZ",
+ "languages": ["swahili", "english"],
+ "flag_colors": ["green", "yellow", "black", "blue"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Ukraine",
+ "code": "UA",
+ "languages": ["ukrainian"],
+ "flag_colors": ["blue", "yellow"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Uganda",
+ "code": "UG",
+ "languages": ["english", "swahili"],
+ "flag_colors": ["black", "yellow", "red", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "United States Minor Outlying Islands",
+ "code": "UM",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Oceania"
+ },
+ {
+ "name": "United States",
+ "code": "US",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "North America"
+ },
+ {
+ "name": "Uruguay",
+ "code": "UY",
+ "languages": ["spanish"],
+ "flag_colors": ["white", "blue", "yellow"],
+ "region": "South America"
+ },
+ {
+ "name": "Uzbekistan",
+ "code": "UZ",
+ "languages": ["uzbek"],
+ "flag_colors": ["blue", "white", "green", "red"],
+ "region": "Central Asia"
+ },
+ {
+ "name": "Vatican City",
+ "code": "VA",
+ "languages": ["italian", "latin"],
+ "flag_colors": ["yellow", "white"],
+ "region": "Southern Europe"
+ },
+ {
+ "name": "Saint Vincent and the Grenadines",
+ "code": "VC",
+ "languages": ["english"],
+ "flag_colors": ["blue", "yellow", "green", "white"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Venezuela",
+ "code": "VE",
+ "languages": ["spanish"],
+ "flag_colors": ["yellow", "blue", "red", "white"],
+ "region": "South America"
+ },
+ {
+ "name": "British Virgin Islands",
+ "code": "VG",
+ "languages": ["english"],
+ "flag_colors": ["blue", "white", "red"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "U.S. Virgin Islands",
+ "code": "VI",
+ "languages": ["english"],
+ "flag_colors": ["red", "white", "blue", "yellow"],
+ "region": "Caribbean"
+ },
+ {
+ "name": "Vietnam",
+ "code": "VN",
+ "languages": ["vietnamese"],
+ "flag_colors": ["red", "yellow"],
+ "region": "Southeast Asia"
+ },
+ {
+ "name": "Vanuatu",
+ "code": "VU",
+ "languages": ["bislama", "english", "french"],
+ "flag_colors": ["red", "green", "black", "yellow"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Wallis and Futuna",
+ "code": "WF",
+ "languages": ["french", "wallisian", "futunan"],
+ "flag_colors": ["red", "white", "blue"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Samoa",
+ "code": "WS",
+ "languages": ["samoan", "english"],
+ "flag_colors": ["red", "blue", "white"],
+ "region": "Oceania"
+ },
+ {
+ "name": "Kosovo",
+ "code": "XK",
+ "languages": ["albanian", "serbian"],
+ "flag_colors": ["blue", "yellow", "white"],
+ "region": "Eastern Europe"
+ },
+ {
+ "name": "Yemen",
+ "code": "YE",
+ "languages": ["arabic"],
+ "flag_colors": ["red", "white", "black"],
+ "region": "Middle East"
+ },
+ {
+ "name": "Mayotte",
+ "code": "YT",
+ "languages": ["french", "shimaore", "kibushi"],
+ "flag_colors": ["white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "South Africa",
+ "code": "ZA",
+ "languages": ["zulu", "xhosa", "afrikaans", "english"],
+ "flag_colors": ["red", "blue", "green", "yellow", "black", "white"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Zambia",
+ "code": "ZM",
+ "languages": ["english"],
+ "flag_colors": ["green", "red", "black", "orange"],
+ "region": "Sub-Saharan Africa"
+ },
+ {
+ "name": "Zimbabwe",
+ "code": "ZW",
+ "languages": ["english", "shona", "ndebele"],
+ "flag_colors": ["green", "yellow", "red", "black", "white"],
+ "region": "Sub-Saharan Africa"
+ }
+]
diff --git a/apps/web/public/fonts/font-atlas.json b/apps/web/public/fonts/font-atlas.json
new file mode 100644
index 00000000..deb0eb91
--- /dev/null
+++ b/apps/web/public/fonts/font-atlas.json
@@ -0,0 +1,11348 @@
+{
+ "fonts": {
+ "42dot Sans": {
+ "x": 0,
+ "y": 0,
+ "w": 131,
+ "ch": 0,
+ "s": ["300", "400", "500", "600", "700", "800"]
+ },
+ "ABeeZee": { "x": 145, "y": 0, "w": 106, "ch": 0, "s": ["400", "400i"] },
+ "Abel": { "x": 265, "y": 0, "w": 47, "ch": 0, "s": ["400"] },
+ "Abhaya Libre": {
+ "x": 326,
+ "y": 0,
+ "w": 139,
+ "ch": 0,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Aboreto": { "x": 479, "y": 0, "w": 124, "ch": 0, "s": ["400"] },
+ "Abril Fatface": { "x": 617, "y": 0, "w": 152, "ch": 0, "s": ["400"] },
+ "Abyssinica SIL": { "x": 783, "y": 0, "w": 165, "ch": 0, "s": ["400"] },
+ "Aclonica": { "x": 962, "y": 0, "w": 122, "ch": 0, "s": ["400"] },
+ "Acme": { "x": 1098, "y": 0, "w": 63, "ch": 0, "s": ["400"] },
+ "Actor": { "x": 0, "y": 40, "w": 64, "ch": 0, "s": ["400"] },
+ "Adamina": { "x": 78, "y": 40, "w": 113, "ch": 0, "s": ["400"] },
+ "ADLaM Display": { "x": 205, "y": 40, "w": 183, "ch": 0, "s": ["400"] },
+ "Advent Pro": {
+ "x": 402,
+ "y": 40,
+ "w": 107,
+ "ch": 0,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Afacad": {
+ "x": 523,
+ "y": 40,
+ "w": 77,
+ "ch": 0,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Afacad Flux": {
+ "x": 614,
+ "y": 40,
+ "w": 118,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Agbalumo": { "x": 746, "y": 40, "w": 119, "ch": 0, "s": ["400"] },
+ "Agdasima": { "x": 879, "y": 40, "w": 81, "ch": 0, "s": ["400", "700"] },
+ "Agu Display": { "x": 974, "y": 40, "w": 140, "ch": 0, "s": ["400"] },
+ "Aguafina Script": { "x": 0, "y": 80, "w": 120, "ch": 0, "s": ["400"] },
+ "Akatab": {
+ "x": 134,
+ "y": 80,
+ "w": 81,
+ "ch": 0,
+ "s": ["400", "500", "600", "700", "800", "900"]
+ },
+ "Akaya Kanadaka": { "x": 229, "y": 80, "w": 176, "ch": 0, "s": ["400"] },
+ "Akaya Telivigala": { "x": 419, "y": 80, "w": 165, "ch": 0, "s": ["400"] },
+ "Akronim": { "x": 598, "y": 80, "w": 79, "ch": 0, "s": ["400"] },
+ "Akshar": {
+ "x": 691,
+ "y": 80,
+ "w": 71,
+ "ch": 0,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Aladin": { "x": 776, "y": 80, "w": 61, "ch": 0, "s": ["400"] },
+ "Alan Sans": {
+ "x": 851,
+ "y": 80,
+ "w": 116,
+ "ch": 0,
+ "s": ["300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Alata": { "x": 981, "y": 80, "w": 68, "ch": 0, "s": ["400"] },
+ "Alatsi": { "x": 1063, "y": 80, "w": 66, "ch": 0, "s": ["400"] },
+ "Albert Sans": {
+ "x": 0,
+ "y": 120,
+ "w": 133,
+ "ch": 0,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Aldrich": { "x": 147, "y": 120, "w": 94, "ch": 0, "s": ["400"] },
+ "Alef": { "x": 255, "y": 120, "w": 54, "ch": 0, "s": ["400", "700"] },
+ "Alegreya": {
+ "x": 323,
+ "y": 120,
+ "w": 92,
+ "ch": 0,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Alegreya Sans": {
+ "x": 429,
+ "y": 120,
+ "w": 137,
+ "ch": 0,
+ "s": [
+ "100",
+ "100i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Alegreya Sans SC": {
+ "x": 580,
+ "y": 120,
+ "w": 179,
+ "ch": 0,
+ "s": [
+ "100",
+ "100i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Alegreya SC": {
+ "x": 773,
+ "y": 120,
+ "w": 137,
+ "ch": 0,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Aleo": {
+ "x": 924,
+ "y": 120,
+ "w": 59,
+ "ch": 0,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Alex Brush": { "x": 997, "y": 120, "w": 117, "ch": 0, "s": ["400"] },
+ "Alexandria": {
+ "x": 0,
+ "y": 160,
+ "w": 136,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Alfa Slab One": { "x": 150, "y": 160, "w": 180, "ch": 0, "s": ["400"] },
+ "Alice": { "x": 344, "y": 160, "w": 60, "ch": 0, "s": ["400"] },
+ "Alike": { "x": 418, "y": 160, "w": 62, "ch": 0, "s": ["400"] },
+ "Alike Angular": { "x": 494, "y": 160, "w": 155, "ch": 0, "s": ["400"] },
+ "Alkalami": { "x": 663, "y": 160, "w": 109, "ch": 0, "s": ["400"] },
+ "Alkatra": {
+ "x": 786,
+ "y": 160,
+ "w": 84,
+ "ch": 0,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Allan": { "x": 884, "y": 160, "w": 48, "ch": 0, "s": ["400", "700"] },
+ "Allerta": { "x": 946, "y": 160, "w": 93, "ch": 0, "s": ["400"] },
+ "Allerta Stencil": { "x": 0, "y": 200, "w": 182, "ch": 0, "s": ["400"] },
+ "Allison": { "x": 196, "y": 200, "w": 56, "ch": 0, "s": ["400"] },
+ "Allura": { "x": 266, "y": 200, "w": 69, "ch": 0, "s": ["400"] },
+ "Almarai": {
+ "x": 349,
+ "y": 200,
+ "w": 88,
+ "ch": 0,
+ "s": ["300", "400", "700", "800"]
+ },
+ "Almendra": {
+ "x": 451,
+ "y": 200,
+ "w": 107,
+ "ch": 0,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Almendra Display": { "x": 572, "y": 200, "w": 192, "ch": 0, "s": ["400"] },
+ "Almendra SC": { "x": 778, "y": 200, "w": 142, "ch": 0, "s": ["400"] },
+ "Alumni Sans": {
+ "x": 934,
+ "y": 200,
+ "w": 96,
+ "ch": 0,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Alumni Sans Collegiate One": {
+ "x": 0,
+ "y": 240,
+ "w": 201,
+ "ch": 0,
+ "s": ["400", "400i"]
+ },
+ "Alumni Sans Inline One": {
+ "x": 215,
+ "y": 240,
+ "w": 180,
+ "ch": 0,
+ "s": ["400", "400i"]
+ },
+ "Alumni Sans Pinstripe": {
+ "x": 409,
+ "y": 240,
+ "w": 159,
+ "ch": 0,
+ "s": ["400", "400i"]
+ },
+ "Alumni Sans SC": {
+ "x": 582,
+ "y": 240,
+ "w": 125,
+ "ch": 0,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Amarante": { "x": 721, "y": 240, "w": 108, "ch": 0, "s": ["400"] },
+ "Amaranth": {
+ "x": 843,
+ "y": 240,
+ "w": 110,
+ "ch": 0,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Amarna": {
+ "x": 967,
+ "y": 240,
+ "w": 92,
+ "ch": 0,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Amatic SC": { "x": 1073, "y": 240, "w": 77, "ch": 0, "s": ["400", "700"] },
+ "Amethysta": { "x": 0, "y": 280, "w": 133, "ch": 0, "s": ["400"] },
+ "Amiko": {
+ "x": 147,
+ "y": 280,
+ "w": 84,
+ "ch": 0,
+ "s": ["400", "600", "700"]
+ },
+ "Amiri": {
+ "x": 245,
+ "y": 280,
+ "w": 63,
+ "ch": 0,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Amiri Quran": { "x": 322, "y": 280, "w": 131, "ch": 0, "s": ["400"] },
+ "Amita": { "x": 467, "y": 280, "w": 73, "ch": 0, "s": ["400", "700"] },
+ "Anaheim": {
+ "x": 554,
+ "y": 280,
+ "w": 93,
+ "ch": 0,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Ancizar Sans": {
+ "x": 661,
+ "y": 280,
+ "w": 126,
+ "ch": 0,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Ancizar Serif": {
+ "x": 801,
+ "y": 280,
+ "w": 136,
+ "ch": 0,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Andada Pro": {
+ "x": 951,
+ "y": 280,
+ "w": 137,
+ "ch": 0,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Andika": {
+ "x": 1102,
+ "y": 280,
+ "w": 85,
+ "ch": 0,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Anek Bangla": {
+ "x": 0,
+ "y": 320,
+ "w": 131,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Anek Devanagari": {
+ "x": 145,
+ "y": 320,
+ "w": 176,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Anek Gujarati": {
+ "x": 335,
+ "y": 320,
+ "w": 143,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Anek Gurmukhi": {
+ "x": 492,
+ "y": 320,
+ "w": 164,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Anek Kannada": {
+ "x": 670,
+ "y": 320,
+ "w": 152,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Anek Latin": {
+ "x": 836,
+ "y": 320,
+ "w": 115,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Anek Malayalam": {
+ "x": 965,
+ "y": 320,
+ "w": 171,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Anek Odia": {
+ "x": 0,
+ "y": 360,
+ "w": 108,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Anek Tamil": {
+ "x": 122,
+ "y": 360,
+ "w": 119,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Anek Telugu": {
+ "x": 255,
+ "y": 360,
+ "w": 131,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Angkor": { "x": 400, "y": 360, "w": 111, "ch": 0, "s": ["400"] },
+ "Annapurna SIL": {
+ "x": 525,
+ "y": 360,
+ "w": 151,
+ "ch": 0,
+ "s": ["400", "700"]
+ },
+ "Annie Use Your Telescope": {
+ "x": 690,
+ "y": 360,
+ "w": 215,
+ "ch": 0,
+ "s": ["400"]
+ },
+ "Anonymous Pro": {
+ "x": 919,
+ "y": 360,
+ "w": 179,
+ "ch": 0,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Anta": { "x": 1112, "y": 360, "w": 62, "ch": 0, "s": ["400"] },
+ "Antic": { "x": 0, "y": 400, "w": 60, "ch": 0, "s": ["400"] },
+ "Antic Didone": { "x": 74, "y": 400, "w": 145, "ch": 0, "s": ["400"] },
+ "Antic Slab": { "x": 233, "y": 400, "w": 114, "ch": 0, "s": ["400"] },
+ "Anton": { "x": 361, "y": 400, "w": 63, "ch": 0, "s": ["400"] },
+ "Anton SC": { "x": 438, "y": 400, "w": 93, "ch": 0, "s": ["400"] },
+ "Antonio": {
+ "x": 545,
+ "y": 400,
+ "w": 76,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "Anuphan": {
+ "x": 635,
+ "y": 400,
+ "w": 105,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "Anybody": {
+ "x": 754,
+ "y": 400,
+ "w": 107,
+ "ch": 0,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Aoboshi One": { "x": 875, "y": 400, "w": 158, "ch": 0, "s": ["400"] },
+ "AR One Sans": {
+ "x": 1047,
+ "y": 400,
+ "w": 152,
+ "ch": 0,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Arapey": { "x": 0, "y": 440, "w": 75, "ch": 0, "s": ["400", "400i"] },
+ "Arbutus": { "x": 89, "y": 440, "w": 128, "ch": 0, "s": ["400"] },
+ "Arbutus Slab": { "x": 231, "y": 440, "w": 159, "ch": 0, "s": ["400"] },
+ "Architects Daughter": {
+ "x": 404,
+ "y": 440,
+ "w": 237,
+ "ch": 0,
+ "s": ["400"]
+ },
+ "Archivo": {
+ "x": 655,
+ "y": 440,
+ "w": 90,
+ "ch": 0,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Archivo Black": { "x": 759, "y": 440, "w": 190, "ch": 0, "s": ["400"] },
+ "Archivo Narrow": {
+ "x": 963,
+ "y": 440,
+ "w": 144,
+ "ch": 0,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Are You Serious": { "x": 0, "y": 480, "w": 142, "ch": 0, "s": ["400"] },
+ "Aref Ruqaa": {
+ "x": 156,
+ "y": 480,
+ "w": 130,
+ "ch": 0,
+ "s": ["400", "700"]
+ },
+ "Aref Ruqaa Ink": {
+ "x": 300,
+ "y": 480,
+ "w": 172,
+ "ch": 0,
+ "s": ["400", "700"]
+ },
+ "Arima": {
+ "x": 486,
+ "y": 480,
+ "w": 73,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "Arima Madurai": {
+ "x": 573,
+ "y": 480,
+ "w": 170,
+ "ch": 0,
+ "s": ["100", "200", "300", "400", "500", "700", "800", "900"]
+ },
+ "Arimo": {
+ "x": 757,
+ "y": 480,
+ "w": 71,
+ "ch": 0,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Arizonia": { "x": 842, "y": 480, "w": 95, "ch": 0, "s": ["400"] },
+ "Armata": { "x": 951, "y": 480, "w": 100, "ch": 0, "s": ["400"] },
+ "Arsenal": {
+ "x": 1065,
+ "y": 480,
+ "w": 80,
+ "ch": 0,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Arsenal SC": {
+ "x": 0,
+ "y": 520,
+ "w": 118,
+ "ch": 0,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Artifika": { "x": 132, "y": 520, "w": 105, "ch": 0, "s": ["400"] },
+ "Arvo": {
+ "x": 251,
+ "y": 520,
+ "w": 65,
+ "ch": 0,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Arya": { "x": 330, "y": 520, "w": 53, "ch": 0, "s": ["400", "700"] },
+ "Asap": {
+ "x": 397,
+ "y": 520,
+ "w": 60,
+ "ch": 0,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Asap Condensed": {
+ "x": 471,
+ "y": 520,
+ "w": 153,
+ "ch": 0,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Asar": { "x": 638, "y": 520, "w": 53, "ch": 0, "s": ["400"] },
+ "Asimovian": { "x": 705, "y": 520, "w": 117, "ch": 0, "s": ["400"] },
+ "Asset": { "x": 836, "y": 520, "w": 154, "ch": 0, "s": ["400"] },
+ "Assistant": {
+ "x": 1004,
+ "y": 520,
+ "w": 98,
+ "ch": 0,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Asta Sans": {
+ "x": 0,
+ "y": 560,
+ "w": 115,
+ "ch": 0,
+ "s": ["300", "400", "500", "600", "700", "800"]
+ },
+ "Astloch": { "x": 129, "y": 560, "w": 72, "ch": 0, "s": ["400", "700"] },
+ "Asul": { "x": 215, "y": 560, "w": 54, "ch": 0, "s": ["400", "700"] },
+ "Athiti": {
+ "x": 283,
+ "y": 560,
+ "w": 62,
+ "ch": 0,
+ "s": ["200", "300", "400", "500", "600", "700"]
+ },
+ "Atkinson Hyperlegible": {
+ "x": 359,
+ "y": 560,
+ "w": 239,
+ "ch": 0,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Atkinson Hyperlegible Mono": {
+ "x": 612,
+ "y": 560,
+ "w": 403,
+ "ch": 0,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Atkinson Hyperlegible Next": {
+ "x": 0,
+ "y": 600,
+ "w": 296,
+ "ch": 0,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Atma": {
+ "x": 310,
+ "y": 600,
+ "w": 59,
+ "ch": 0,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Atomic Age": { "x": 383, "y": 600, "w": 144, "ch": 0, "s": ["400"] },
+ "Aubrey": { "x": 541, "y": 600, "w": 67, "ch": 0, "s": ["400"] },
+ "Audiowide": { "x": 622, "y": 600, "w": 145, "ch": 0, "s": ["400"] },
+ "Autour One": { "x": 781, "y": 600, "w": 168, "ch": 0, "s": ["400"] },
+ "Average": { "x": 963, "y": 600, "w": 88, "ch": 0, "s": ["400"] },
+ "Average Sans": { "x": 0, "y": 640, "w": 138, "ch": 0, "s": ["400"] },
+ "Averia Gruesa Libre": {
+ "x": 152,
+ "y": 640,
+ "w": 217,
+ "ch": 0,
+ "s": ["400"]
+ },
+ "Averia Libre": {
+ "x": 383,
+ "y": 640,
+ "w": 138,
+ "ch": 0,
+ "s": ["300", "300i", "400", "400i", "700", "700i"]
+ },
+ "Averia Sans Libre": {
+ "x": 535,
+ "y": 640,
+ "w": 190,
+ "ch": 0,
+ "s": ["300", "300i", "400", "400i", "700", "700i"]
+ },
+ "Averia Serif Libre": {
+ "x": 739,
+ "y": 640,
+ "w": 202,
+ "ch": 0,
+ "s": ["300", "300i", "400", "400i", "700", "700i"]
+ },
+ "Azeret Mono": {
+ "x": 955,
+ "y": 640,
+ "w": 180,
+ "ch": 0,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "B612": {
+ "x": 0,
+ "y": 680,
+ "w": 71,
+ "ch": 0,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "B612 Mono": {
+ "x": 85,
+ "y": 680,
+ "w": 149,
+ "ch": 0,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Babylonica": { "x": 248, "y": 680, "w": 90, "ch": 0, "s": ["400"] },
+ "Bacasime Antique": { "x": 352, "y": 680, "w": 182, "ch": 0, "s": ["400"] },
+ "Bad Script": { "x": 548, "y": 680, "w": 103, "ch": 0, "s": ["400"] },
+ "Badeen Display": { "x": 665, "y": 680, "w": 194, "ch": 0, "s": ["400"] },
+ "Bagel Fat One": { "x": 873, "y": 680, "w": 158, "ch": 0, "s": ["400"] },
+ "Bahiana": { "x": 1045, "y": 680, "w": 58, "ch": 0, "s": ["400"] },
+ "Bahianita": { "x": 1117, "y": 680, "w": 63, "ch": 0, "s": ["400"] },
+ "Bai Jamjuree": {
+ "x": 0,
+ "y": 720,
+ "w": 152,
+ "ch": 0,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Bakbak One": { "x": 166, "y": 720, "w": 141, "ch": 0, "s": ["400"] },
+ "Ballet": { "x": 321, "y": 720, "w": 65, "ch": 0, "s": ["400"] },
+ "Baloo 2": {
+ "x": 400,
+ "y": 720,
+ "w": 84,
+ "ch": 0,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Baloo Bhai 2": {
+ "x": 498,
+ "y": 720,
+ "w": 134,
+ "ch": 0,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Baloo Bhaijaan 2": {
+ "x": 646,
+ "y": 720,
+ "w": 177,
+ "ch": 0,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Baloo Bhaina 2": {
+ "x": 837,
+ "y": 720,
+ "w": 160,
+ "ch": 0,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Baloo Chettan 2": {
+ "x": 1011,
+ "y": 720,
+ "w": 172,
+ "ch": 0,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Baloo Da 2": {
+ "x": 0,
+ "y": 760,
+ "w": 117,
+ "ch": 0,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Baloo Paaji 2": {
+ "x": 131,
+ "y": 760,
+ "w": 138,
+ "ch": 0,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Baloo Tamma 2": {
+ "x": 283,
+ "y": 760,
+ "w": 169,
+ "ch": 0,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Baloo Tammudu 2": {
+ "x": 466,
+ "y": 760,
+ "w": 191,
+ "ch": 0,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Baloo Thambi 2": {
+ "x": 671,
+ "y": 760,
+ "w": 165,
+ "ch": 0,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Balsamiq Sans": {
+ "x": 850,
+ "y": 760,
+ "w": 168,
+ "ch": 0,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Balthazar": { "x": 1032, "y": 760, "w": 96, "ch": 0, "s": ["400"] },
+ "Bangers": { "x": 0, "y": 0, "w": 81, "ch": 1, "s": ["400"] },
+ "Barlow": {
+ "x": 95,
+ "y": 0,
+ "w": 80,
+ "ch": 1,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Barlow Condensed": {
+ "x": 189,
+ "y": 0,
+ "w": 158,
+ "ch": 1,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Barlow Semi Condensed": {
+ "x": 361,
+ "y": 0,
+ "w": 231,
+ "ch": 1,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Barriecito": { "x": 606, "y": 0, "w": 106, "ch": 1, "s": ["400"] },
+ "Barrio": { "x": 726, "y": 0, "w": 80, "ch": 1, "s": ["400"] },
+ "Basic": { "x": 820, "y": 0, "w": 61, "ch": 1, "s": ["400"] },
+ "Baskervville": {
+ "x": 895,
+ "y": 0,
+ "w": 136,
+ "ch": 1,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Baskervville SC": {
+ "x": 0,
+ "y": 40,
+ "w": 187,
+ "ch": 1,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Battambang": {
+ "x": 201,
+ "y": 40,
+ "w": 147,
+ "ch": 1,
+ "s": ["100", "300", "400", "700", "900"]
+ },
+ "Baumans": { "x": 362, "y": 40, "w": 101, "ch": 1, "s": ["400"] },
+ "Bayon": { "x": 477, "y": 40, "w": 61, "ch": 1, "s": ["400"] },
+ "BBH Bartle": { "x": 552, "y": 40, "w": 298, "ch": 1, "s": ["400"] },
+ "BBH Bogle": { "x": 864, "y": 40, "w": 98, "ch": 1, "s": ["400"] },
+ "BBH Hegarty": { "x": 976, "y": 40, "w": 182, "ch": 1, "s": ["400"] },
+ "BBH Sans Bartle": { "x": 0, "y": 80, "w": 427, "ch": 1, "s": ["400"] },
+ "BBH Sans Bogle": { "x": 441, "y": 80, "w": 142, "ch": 1, "s": ["400"] },
+ "BBH Sans Hegarty": { "x": 597, "y": 80, "w": 254, "ch": 1, "s": ["400"] },
+ "Be Vietnam Pro": {
+ "x": 865,
+ "y": 80,
+ "w": 188,
+ "ch": 1,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Beau Rivage": { "x": 1067, "y": 80, "w": 108, "ch": 1, "s": ["400"] },
+ "Bebas Neue": { "x": 0, "y": 120, "w": 96, "ch": 1, "s": ["400"] },
+ "Beiruti": {
+ "x": 110,
+ "y": 120,
+ "w": 66,
+ "ch": 1,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Belanosima": {
+ "x": 190,
+ "y": 120,
+ "w": 124,
+ "ch": 1,
+ "s": ["400", "600", "700"]
+ },
+ "Belgrano": { "x": 328, "y": 120, "w": 115, "ch": 1, "s": ["400"] },
+ "Bellefair": { "x": 457, "y": 120, "w": 81, "ch": 1, "s": ["400"] },
+ "Belleza": { "x": 552, "y": 120, "w": 79, "ch": 1, "s": ["400"] },
+ "Bellota": {
+ "x": 645,
+ "y": 120,
+ "w": 84,
+ "ch": 1,
+ "s": ["300", "300i", "400", "400i", "700", "700i"]
+ },
+ "Bellota Text": {
+ "x": 743,
+ "y": 120,
+ "w": 129,
+ "ch": 1,
+ "s": ["300", "300i", "400", "400i", "700", "700i"]
+ },
+ "BenchNine": {
+ "x": 886,
+ "y": 120,
+ "w": 82,
+ "ch": 1,
+ "s": ["300", "400", "700"]
+ },
+ "Benne": { "x": 982, "y": 120, "w": 66, "ch": 1, "s": ["400"] },
+ "Bentham": { "x": 1062, "y": 120, "w": 98, "ch": 1, "s": ["400"] },
+ "Berkshire Swash": { "x": 0, "y": 160, "w": 178, "ch": 1, "s": ["400"] },
+ "Besley": {
+ "x": 192,
+ "y": 160,
+ "w": 86,
+ "ch": 1,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Beth Ellen": { "x": 292, "y": 160, "w": 138, "ch": 1, "s": ["400"] },
+ "Bevan": { "x": 444, "y": 160, "w": 94, "ch": 1, "s": ["400", "400i"] },
+ "BhuTuka Expanded One": {
+ "x": 552,
+ "y": 160,
+ "w": 412,
+ "ch": 1,
+ "s": ["400"]
+ },
+ "Big Shoulders": {
+ "x": 978,
+ "y": 160,
+ "w": 117,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Big Shoulders Display": {
+ "x": 0,
+ "y": 200,
+ "w": 158,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Big Shoulders Inline": {
+ "x": 172,
+ "y": 200,
+ "w": 167,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Big Shoulders Inline Display": {
+ "x": 353,
+ "y": 200,
+ "w": 203,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Big Shoulders Inline Text": {
+ "x": 570,
+ "y": 200,
+ "w": 201,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Big Shoulders Stencil": {
+ "x": 785,
+ "y": 200,
+ "w": 177,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Big Shoulders Stencil Display": {
+ "x": 976,
+ "y": 200,
+ "w": 211,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Big Shoulders Stencil Text": {
+ "x": 0,
+ "y": 240,
+ "w": 210,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Big Shoulders Text": {
+ "x": 224,
+ "y": 240,
+ "w": 150,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Bigelow Rules": { "x": 388, "y": 240, "w": 86, "ch": 1, "s": ["400"] },
+ "Bigshot One": { "x": 488, "y": 240, "w": 138, "ch": 1, "s": ["400"] },
+ "Bilbo": { "x": 640, "y": 240, "w": 45, "ch": 1, "s": ["400"] },
+ "Bilbo Swash Caps": { "x": 699, "y": 240, "w": 144, "ch": 1, "s": ["400"] },
+ "BioRhyme": {
+ "x": 857,
+ "y": 240,
+ "w": 139,
+ "ch": 1,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "BioRhyme Expanded": {
+ "x": 0,
+ "y": 280,
+ "w": 455,
+ "ch": 1,
+ "s": ["200", "300", "400", "700", "800"]
+ },
+ "Birthstone": { "x": 469, "y": 280, "w": 79, "ch": 1, "s": ["400"] },
+ "Birthstone Bounce": {
+ "x": 562,
+ "y": 280,
+ "w": 167,
+ "ch": 1,
+ "s": ["400", "500"]
+ },
+ "Biryani": {
+ "x": 743,
+ "y": 280,
+ "w": 90,
+ "ch": 1,
+ "s": ["200", "300", "400", "600", "700", "800", "900"]
+ },
+ "Bitcount": {
+ "x": 847,
+ "y": 280,
+ "w": 124,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Bitcount Grid Double": {
+ "x": 0,
+ "y": 320,
+ "w": 296,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Bitcount Grid Double Ink": {
+ "x": 310,
+ "y": 320,
+ "w": 354,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Bitcount Grid Single": {
+ "x": 678,
+ "y": 320,
+ "w": 296,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Bitcount Grid Single Ink": {
+ "x": 0,
+ "y": 360,
+ "w": 354,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Bitcount Ink": {
+ "x": 368,
+ "y": 360,
+ "w": 181,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Bitcount Prop Double": {
+ "x": 563,
+ "y": 360,
+ "w": 265,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Bitcount Prop Double Ink": {
+ "x": 842,
+ "y": 360,
+ "w": 308,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Bitcount Prop Single": {
+ "x": 0,
+ "y": 400,
+ "w": 244,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Bitcount Prop Single Ink": {
+ "x": 258,
+ "y": 400,
+ "w": 282,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Bitcount Single": {
+ "x": 554,
+ "y": 400,
+ "w": 224,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Bitcount Single Ink": {
+ "x": 792,
+ "y": 400,
+ "w": 282,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Bitter": {
+ "x": 1088,
+ "y": 400,
+ "w": 71,
+ "ch": 1,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "BIZ UDGothic": {
+ "x": 0,
+ "y": 440,
+ "w": 152,
+ "ch": 1,
+ "s": ["400", "700"]
+ },
+ "BIZ UDMincho": {
+ "x": 166,
+ "y": 440,
+ "w": 152,
+ "ch": 1,
+ "s": ["400", "700"]
+ },
+ "BIZ UDPGothic": {
+ "x": 332,
+ "y": 440,
+ "w": 209,
+ "ch": 1,
+ "s": ["400", "700"]
+ },
+ "BIZ UDPMincho": {
+ "x": 555,
+ "y": 440,
+ "w": 208,
+ "ch": 1,
+ "s": ["400", "700"]
+ },
+ "Black And White Picture": {
+ "x": 777,
+ "y": 440,
+ "w": 218,
+ "ch": 1,
+ "s": ["400"]
+ },
+ "Black Han Sans": { "x": 0, "y": 480, "w": 212, "ch": 1, "s": ["400"] },
+ "Black Ops One": { "x": 226, "y": 480, "w": 191, "ch": 1, "s": ["400"] },
+ "Blaka": { "x": 431, "y": 480, "w": 57, "ch": 1, "s": ["400"] },
+ "Blaka Hollow": { "x": 502, "y": 480, "w": 119, "ch": 1, "s": ["400"] },
+ "Blaka Ink": { "x": 635, "y": 480, "w": 87, "ch": 1, "s": ["400"] },
+ "Blinker": {
+ "x": 736,
+ "y": 480,
+ "w": 77,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "600", "700", "800", "900"]
+ },
+ "Bodoni Moda": {
+ "x": 827,
+ "y": 480,
+ "w": 155,
+ "ch": 1,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Bodoni Moda SC": {
+ "x": 996,
+ "y": 480,
+ "w": 200,
+ "ch": 1,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Bokor": { "x": 0, "y": 520, "w": 58, "ch": 1, "s": ["400"] },
+ "Boldonse": { "x": 72, "y": 520, "w": 147, "ch": 1, "s": ["400"] },
+ "Bona Nova": {
+ "x": 233,
+ "y": 520,
+ "w": 124,
+ "ch": 1,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Bona Nova SC": {
+ "x": 371,
+ "y": 520,
+ "w": 170,
+ "ch": 1,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Bonbon": { "x": 555, "y": 520, "w": 104, "ch": 1, "s": ["400"] },
+ "Bonheur Royale": { "x": 673, "y": 520, "w": 124, "ch": 1, "s": ["400"] },
+ "Boogaloo": { "x": 811, "y": 520, "w": 81, "ch": 1, "s": ["400"] },
+ "Borel": { "x": 906, "y": 520, "w": 69, "ch": 1, "s": ["400"] },
+ "Bowlby One": { "x": 989, "y": 520, "w": 174, "ch": 1, "s": ["400"] },
+ "Bowlby One SC": { "x": 0, "y": 560, "w": 213, "ch": 1, "s": ["400"] },
+ "Braah One": { "x": 227, "y": 560, "w": 124, "ch": 1, "s": ["400"] },
+ "Brawler": { "x": 365, "y": 560, "w": 95, "ch": 1, "s": ["400", "700"] },
+ "Bree Serif": { "x": 474, "y": 560, "w": 113, "ch": 1, "s": ["400"] },
+ "Bricolage Grotesque": {
+ "x": 601,
+ "y": 560,
+ "w": 238,
+ "ch": 1,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Briem Hand": {
+ "x": 853,
+ "y": 560,
+ "w": 146,
+ "ch": 1,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Bruno Ace": { "x": 1013, "y": 560, "w": 168, "ch": 1, "s": ["400"] },
+ "Bruno Ace SC": { "x": 0, "y": 600, "w": 219, "ch": 1, "s": ["400"] },
+ "Brygada 1918": {
+ "x": 233,
+ "y": 600,
+ "w": 157,
+ "ch": 1,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Bubblegum Sans": { "x": 404, "y": 600, "w": 155, "ch": 1, "s": ["400"] },
+ "Bubbler One": { "x": 573, "y": 600, "w": 117, "ch": 1, "s": ["400"] },
+ "Buenard": {
+ "x": 704,
+ "y": 600,
+ "w": 93,
+ "ch": 1,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Bungee": { "x": 811, "y": 600, "w": 110, "ch": 1, "s": ["400"] },
+ "Bungee Hairline": { "x": 935, "y": 600, "w": 249, "ch": 1, "s": ["400"] },
+ "Bungee Inline": { "x": 0, "y": 640, "w": 213, "ch": 1, "s": ["400"] },
+ "Bungee Outline": { "x": 227, "y": 640, "w": 232, "ch": 1, "s": ["400"] },
+ "Bungee Shade": { "x": 473, "y": 640, "w": 229, "ch": 1, "s": ["400"] },
+ "Bungee Spice": { "x": 716, "y": 640, "w": 193, "ch": 1, "s": ["400"] },
+ "Bungee Tint": { "x": 923, "y": 640, "w": 179, "ch": 1, "s": ["400"] },
+ "Butcherman": { "x": 0, "y": 680, "w": 161, "ch": 1, "s": ["400"] },
+ "Butterfly Kids": { "x": 175, "y": 680, "w": 116, "ch": 1, "s": ["400"] },
+ "Bytesized": { "x": 305, "y": 680, "w": 116, "ch": 1, "s": ["400"] },
+ "Cabin": {
+ "x": 435,
+ "y": 680,
+ "w": 67,
+ "ch": 1,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Cabin Condensed": {
+ "x": 516,
+ "y": 680,
+ "w": 163,
+ "ch": 1,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Cabin Sketch": {
+ "x": 693,
+ "y": 680,
+ "w": 141,
+ "ch": 1,
+ "s": ["400", "700"]
+ },
+ "Cactus Classical Serif": {
+ "x": 848,
+ "y": 680,
+ "w": 235,
+ "ch": 1,
+ "s": ["400"]
+ },
+ "Caesar Dressing": { "x": 0, "y": 720, "w": 164, "ch": 1, "s": ["400"] },
+ "Cagliostro": { "x": 178, "y": 720, "w": 115, "ch": 1, "s": ["400"] },
+ "Cairo": {
+ "x": 307,
+ "y": 720,
+ "w": 60,
+ "ch": 1,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Cairo Play": {
+ "x": 381,
+ "y": 720,
+ "w": 108,
+ "ch": 1,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Cal Sans": { "x": 503, "y": 720, "w": 99, "ch": 1, "s": ["400"] },
+ "Caladea": {
+ "x": 616,
+ "y": 720,
+ "w": 84,
+ "ch": 1,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Calistoga": { "x": 714, "y": 720, "w": 109, "ch": 1, "s": ["400"] },
+ "Calligraffitti": { "x": 837, "y": 720, "w": 124, "ch": 1, "s": ["400"] },
+ "Cambay": {
+ "x": 975,
+ "y": 720,
+ "w": 92,
+ "ch": 1,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Cambo": { "x": 1081, "y": 720, "w": 82, "ch": 1, "s": ["400"] },
+ "Candal": { "x": 0, "y": 760, "w": 102, "ch": 1, "s": ["400"] },
+ "Cantarell": {
+ "x": 116,
+ "y": 760,
+ "w": 109,
+ "ch": 1,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Cantata One": { "x": 239, "y": 760, "w": 163, "ch": 1, "s": ["400"] },
+ "Cantora One": { "x": 416, "y": 760, "w": 134, "ch": 1, "s": ["400"] },
+ "Caprasimo": { "x": 564, "y": 760, "w": 139, "ch": 1, "s": ["400"] },
+ "Capriola": { "x": 717, "y": 760, "w": 110, "ch": 1, "s": ["400"] },
+ "Caramel": { "x": 841, "y": 760, "w": 66, "ch": 1, "s": ["400"] },
+ "Carattere": { "x": 921, "y": 760, "w": 79, "ch": 1, "s": ["400"] },
+ "Cardo": {
+ "x": 1014,
+ "y": 760,
+ "w": 72,
+ "ch": 1,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Carlito": {
+ "x": 1100,
+ "y": 760,
+ "w": 73,
+ "ch": 1,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Carme": { "x": 0, "y": 0, "w": 79, "ch": 2, "s": ["400"] },
+ "Carrois Gothic": { "x": 93, "y": 0, "w": 155, "ch": 2, "s": ["400"] },
+ "Carrois Gothic SC": { "x": 262, "y": 0, "w": 211, "ch": 2, "s": ["400"] },
+ "Carter One": { "x": 487, "y": 0, "w": 136, "ch": 2, "s": ["400"] },
+ "Cascadia Code": {
+ "x": 637,
+ "y": 0,
+ "w": 191,
+ "ch": 2,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Cascadia Mono": {
+ "x": 842,
+ "y": 0,
+ "w": 191,
+ "ch": 2,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Castoro": { "x": 1047, "y": 0, "w": 93, "ch": 2, "s": ["400", "400i"] },
+ "Castoro Titling": { "x": 0, "y": 40, "w": 236, "ch": 2, "s": ["400"] },
+ "Catamaran": {
+ "x": 250,
+ "y": 40,
+ "w": 116,
+ "ch": 2,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Caudex": {
+ "x": 380,
+ "y": 40,
+ "w": 92,
+ "ch": 2,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Cause": {
+ "x": 486,
+ "y": 40,
+ "w": 72,
+ "ch": 2,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Caveat": {
+ "x": 572,
+ "y": 40,
+ "w": 63,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Caveat Brush": { "x": 649, "y": 40, "w": 120, "ch": 2, "s": ["400"] },
+ "Cedarville Cursive": {
+ "x": 783,
+ "y": 40,
+ "w": 203,
+ "ch": 2,
+ "s": ["400"]
+ },
+ "Ceviche One": { "x": 1000, "y": 40, "w": 111, "ch": 2, "s": ["400"] },
+ "Chakra Petch": {
+ "x": 0,
+ "y": 80,
+ "w": 154,
+ "ch": 2,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Changa": {
+ "x": 168,
+ "y": 80,
+ "w": 86,
+ "ch": 2,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Changa One": {
+ "x": 268,
+ "y": 80,
+ "w": 136,
+ "ch": 2,
+ "s": ["400", "400i"]
+ },
+ "Chango": { "x": 418, "y": 80, "w": 128, "ch": 2, "s": ["400"] },
+ "Charis SIL": {
+ "x": 560,
+ "y": 80,
+ "w": 116,
+ "ch": 2,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Charm": { "x": 690, "y": 80, "w": 69, "ch": 2, "s": ["400", "700"] },
+ "Charmonman": { "x": 773, "y": 80, "w": 136, "ch": 2, "s": ["400", "700"] },
+ "Chathura": {
+ "x": 923,
+ "y": 80,
+ "w": 55,
+ "ch": 2,
+ "s": ["100", "300", "400", "700", "800"]
+ },
+ "Chau Philomene One": {
+ "x": 992,
+ "y": 80,
+ "w": 200,
+ "ch": 2,
+ "s": ["400", "400i"]
+ },
+ "Chela One": { "x": 0, "y": 120, "w": 97, "ch": 2, "s": ["400"] },
+ "Chelsea Market": { "x": 111, "y": 120, "w": 200, "ch": 2, "s": ["400"] },
+ "Cherish": { "x": 325, "y": 120, "w": 60, "ch": 2, "s": ["400"] },
+ "Cherry Bomb One": { "x": 399, "y": 120, "w": 200, "ch": 2, "s": ["400"] },
+ "Cherry Cream Soda": {
+ "x": 613,
+ "y": 120,
+ "w": 274,
+ "ch": 2,
+ "s": ["400"]
+ },
+ "Cherry Swash": {
+ "x": 901,
+ "y": 120,
+ "w": 159,
+ "ch": 2,
+ "s": ["400", "700"]
+ },
+ "Chewy": { "x": 1074, "y": 120, "w": 73, "ch": 2, "s": ["400"] },
+ "Chicle": { "x": 0, "y": 160, "w": 56, "ch": 2, "s": ["400"] },
+ "Chilanka": { "x": 70, "y": 160, "w": 99, "ch": 2, "s": ["400"] },
+ "Chiron GoRound TC": {
+ "x": 183,
+ "y": 160,
+ "w": 225,
+ "ch": 2,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Chiron Hei HK": {
+ "x": 422,
+ "y": 160,
+ "w": 163,
+ "ch": 2,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Chivo": {
+ "x": 599,
+ "y": 160,
+ "w": 72,
+ "ch": 2,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Chivo Mono": {
+ "x": 685,
+ "y": 160,
+ "w": 152,
+ "ch": 2,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Chocolate Classical Sans": {
+ "x": 851,
+ "y": 160,
+ "w": 283,
+ "ch": 2,
+ "s": ["400"]
+ },
+ "Chokokutai": { "x": 0, "y": 200, "w": 148, "ch": 2, "s": ["400"] },
+ "Chonburi": { "x": 162, "y": 200, "w": 138, "ch": 2, "s": ["400"] },
+ "Cinzel": {
+ "x": 314,
+ "y": 200,
+ "w": 94,
+ "ch": 2,
+ "s": ["400", "500", "600", "700", "800", "900"]
+ },
+ "Cinzel Decorative": {
+ "x": 422,
+ "y": 200,
+ "w": 262,
+ "ch": 2,
+ "s": ["400", "700", "900"]
+ },
+ "Clicker Script": { "x": 698, "y": 200, "w": 117, "ch": 2, "s": ["400"] },
+ "Climate Crisis": { "x": 829, "y": 200, "w": 247, "ch": 2, "s": ["400"] },
+ "Coda": { "x": 1090, "y": 200, "w": 65, "ch": 2, "s": ["400", "800"] },
+ "Codystar": { "x": 0, "y": 240, "w": 137, "ch": 2, "s": ["300", "400"] },
+ "Coiny": { "x": 151, "y": 240, "w": 79, "ch": 2, "s": ["400"] },
+ "Combo": { "x": 244, "y": 240, "w": 69, "ch": 2, "s": ["400"] },
+ "Comfortaa": {
+ "x": 327,
+ "y": 240,
+ "w": 143,
+ "ch": 2,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Comforter": { "x": 484, "y": 240, "w": 99, "ch": 2, "s": ["400"] },
+ "Comforter Brush": { "x": 597, "y": 240, "w": 147, "ch": 2, "s": ["400"] },
+ "Comic Neue": {
+ "x": 758,
+ "y": 240,
+ "w": 133,
+ "ch": 2,
+ "s": ["300", "300i", "400", "400i", "700", "700i"]
+ },
+ "Comic Relief": {
+ "x": 905,
+ "y": 240,
+ "w": 147,
+ "ch": 2,
+ "s": ["400", "700"]
+ },
+ "Coming Soon": { "x": 0, "y": 280, "w": 152, "ch": 2, "s": ["400"] },
+ "Comme": {
+ "x": 166,
+ "y": 280,
+ "w": 96,
+ "ch": 2,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Commissioner": {
+ "x": 276,
+ "y": 280,
+ "w": 166,
+ "ch": 2,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Concert One": { "x": 456, "y": 280, "w": 136, "ch": 2, "s": ["400"] },
+ "Condiment": { "x": 606, "y": 280, "w": 96, "ch": 2, "s": ["400"] },
+ "Contrail One": { "x": 716, "y": 280, "w": 128, "ch": 2, "s": ["400"] },
+ "Convergence": { "x": 858, "y": 280, "w": 155, "ch": 2, "s": ["400"] },
+ "Cookie": { "x": 1027, "y": 280, "w": 55, "ch": 2, "s": ["400"] },
+ "Copse": { "x": 1096, "y": 280, "w": 73, "ch": 2, "s": ["400"] },
+ "Coral Pixels": { "x": 0, "y": 320, "w": 140, "ch": 2, "s": ["400"] },
+ "Corben": { "x": 154, "y": 320, "w": 91, "ch": 2, "s": ["400", "700"] },
+ "Corinthia": { "x": 259, "y": 320, "w": 69, "ch": 2, "s": ["400", "700"] },
+ "Cormorant": {
+ "x": 342,
+ "y": 320,
+ "w": 113,
+ "ch": 2,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Cormorant Garamond": {
+ "x": 469,
+ "y": 320,
+ "w": 220,
+ "ch": 2,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Cormorant Infant": {
+ "x": 703,
+ "y": 320,
+ "w": 181,
+ "ch": 2,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Cormorant SC": {
+ "x": 898,
+ "y": 320,
+ "w": 167,
+ "ch": 2,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Cormorant Unicase": {
+ "x": 0,
+ "y": 360,
+ "w": 222,
+ "ch": 2,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Cormorant Upright": {
+ "x": 236,
+ "y": 360,
+ "w": 188,
+ "ch": 2,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Cossette Texte": {
+ "x": 438,
+ "y": 360,
+ "w": 164,
+ "ch": 2,
+ "s": ["400", "700"]
+ },
+ "Cossette Titre": {
+ "x": 616,
+ "y": 360,
+ "w": 116,
+ "ch": 2,
+ "s": ["400", "700"]
+ },
+ "Courgette": { "x": 746, "y": 360, "w": 109, "ch": 2, "s": ["400"] },
+ "Courier Prime": {
+ "x": 869,
+ "y": 360,
+ "w": 196,
+ "ch": 2,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Cousine": {
+ "x": 1079,
+ "y": 360,
+ "w": 109,
+ "ch": 2,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Coustard": { "x": 0, "y": 400, "w": 115, "ch": 2, "s": ["400", "900"] },
+ "Covered By Your Grace": {
+ "x": 129,
+ "y": 400,
+ "w": 206,
+ "ch": 2,
+ "s": ["400"]
+ },
+ "Crafty Girls": { "x": 349, "y": 400, "w": 153, "ch": 2, "s": ["400"] },
+ "Creepster": { "x": 516, "y": 400, "w": 101, "ch": 2, "s": ["400"] },
+ "Crete Round": {
+ "x": 631,
+ "y": 400,
+ "w": 142,
+ "ch": 2,
+ "s": ["400", "400i"]
+ },
+ "Crimson Pro": {
+ "x": 787,
+ "y": 400,
+ "w": 127,
+ "ch": 2,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Crimson Text": {
+ "x": 928,
+ "y": 400,
+ "w": 139,
+ "ch": 2,
+ "s": ["400", "400i", "600", "600i", "700", "700i"]
+ },
+ "Croissant One": { "x": 0, "y": 440, "w": 186, "ch": 2, "s": ["400"] },
+ "Crushed": { "x": 200, "y": 440, "w": 79, "ch": 2, "s": ["400"] },
+ "Cuprum": {
+ "x": 293,
+ "y": 440,
+ "w": 77,
+ "ch": 2,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Cute Font": { "x": 384, "y": 440, "w": 78, "ch": 2, "s": ["400"] },
+ "Cutive": { "x": 476, "y": 440, "w": 98, "ch": 2, "s": ["400"] },
+ "Cutive Mono": { "x": 588, "y": 440, "w": 168, "ch": 2, "s": ["400"] },
+ "Dai Banna SIL": {
+ "x": 770,
+ "y": 440,
+ "w": 137,
+ "ch": 2,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Damion": { "x": 921, "y": 440, "w": 78, "ch": 2, "s": ["400"] },
+ "Dancing Script": {
+ "x": 1013,
+ "y": 440,
+ "w": 137,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Danfo": { "x": 0, "y": 480, "w": 86, "ch": 2, "s": ["400"] },
+ "Dangrek": { "x": 100, "y": 480, "w": 91, "ch": 2, "s": ["400"] },
+ "Darker Grotesque": {
+ "x": 205,
+ "y": 480,
+ "w": 163,
+ "ch": 2,
+ "s": ["300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Darumadrop One": { "x": 382, "y": 480, "w": 184, "ch": 2, "s": ["400"] },
+ "David Libre": {
+ "x": 580,
+ "y": 480,
+ "w": 120,
+ "ch": 2,
+ "s": ["400", "500", "700"]
+ },
+ "Dawning of a New Day": {
+ "x": 714,
+ "y": 480,
+ "w": 208,
+ "ch": 2,
+ "s": ["400"]
+ },
+ "Days One": { "x": 936, "y": 480, "w": 133, "ch": 2, "s": ["400"] },
+ "Dekko": { "x": 1083, "y": 480, "w": 62, "ch": 2, "s": ["400"] },
+ "Dela Gothic One": { "x": 0, "y": 520, "w": 234, "ch": 2, "s": ["400"] },
+ "Delicious Handrawn": {
+ "x": 248,
+ "y": 520,
+ "w": 161,
+ "ch": 2,
+ "s": ["400"]
+ },
+ "Delius": { "x": 423, "y": 520, "w": 74, "ch": 2, "s": ["400"] },
+ "Delius Swash Caps": {
+ "x": 511,
+ "y": 520,
+ "w": 214,
+ "ch": 2,
+ "s": ["400"]
+ },
+ "Delius Unicase": {
+ "x": 739,
+ "y": 520,
+ "w": 229,
+ "ch": 2,
+ "s": ["400", "700"]
+ },
+ "Della Respira": { "x": 982, "y": 520, "w": 149, "ch": 2, "s": ["400"] },
+ "Denk One": { "x": 0, "y": 560, "w": 105, "ch": 2, "s": ["400"] },
+ "Devonshire": { "x": 119, "y": 560, "w": 87, "ch": 2, "s": ["400"] },
+ "Dhurjati": { "x": 220, "y": 560, "w": 75, "ch": 2, "s": ["400"] },
+ "Didact Gothic": { "x": 309, "y": 560, "w": 145, "ch": 2, "s": ["400"] },
+ "Diphylleia": { "x": 468, "y": 560, "w": 112, "ch": 2, "s": ["400"] },
+ "Diplomata": { "x": 594, "y": 560, "w": 252, "ch": 2, "s": ["400"] },
+ "Diplomata SC": { "x": 860, "y": 560, "w": 334, "ch": 2, "s": ["400"] },
+ "DM Mono": {
+ "x": 0,
+ "y": 600,
+ "w": 109,
+ "ch": 2,
+ "s": ["300", "300i", "400", "400i", "500", "500i"]
+ },
+ "DM Sans": {
+ "x": 123,
+ "y": 600,
+ "w": 104,
+ "ch": 2,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "DM Serif Display": {
+ "x": 241,
+ "y": 600,
+ "w": 181,
+ "ch": 2,
+ "s": ["400", "400i"]
+ },
+ "DM Serif Text": {
+ "x": 436,
+ "y": 600,
+ "w": 150,
+ "ch": 2,
+ "s": ["400", "400i"]
+ },
+ "Do Hyeon": { "x": 600, "y": 600, "w": 101, "ch": 2, "s": ["400"] },
+ "Dokdo": { "x": 715, "y": 600, "w": 68, "ch": 2, "s": ["400"] },
+ "Domine": {
+ "x": 797,
+ "y": 600,
+ "w": 101,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Donegal One": { "x": 912, "y": 600, "w": 161, "ch": 2, "s": ["400"] },
+ "Dongle": {
+ "x": 1087,
+ "y": 600,
+ "w": 56,
+ "ch": 2,
+ "s": ["300", "400", "700"]
+ },
+ "Doppio One": { "x": 0, "y": 640, "w": 138, "ch": 2, "s": ["400"] },
+ "Dorsa": { "x": 152, "y": 640, "w": 32, "ch": 2, "s": ["400"] },
+ "Dosis": {
+ "x": 198,
+ "y": 640,
+ "w": 56,
+ "ch": 2,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "DotGothic16": { "x": 268, "y": 640, "w": 140, "ch": 2, "s": ["400"] },
+ "Doto": {
+ "x": 422,
+ "y": 640,
+ "w": 66,
+ "ch": 2,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Dr Sugiyama": { "x": 502, "y": 640, "w": 116, "ch": 2, "s": ["400"] },
+ "Duru Sans": { "x": 632, "y": 640, "w": 133, "ch": 2, "s": ["400"] },
+ "Dynalight": { "x": 779, "y": 640, "w": 84, "ch": 2, "s": ["400"] },
+ "DynaPuff": {
+ "x": 877,
+ "y": 640,
+ "w": 121,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Eagle Lake": { "x": 1012, "y": 640, "w": 153, "ch": 2, "s": ["400"] },
+ "East Sea Dokdo": { "x": 0, "y": 680, "w": 118, "ch": 2, "s": ["400"] },
+ "Eater": { "x": 132, "y": 680, "w": 90, "ch": 2, "s": ["400"] },
+ "EB Garamond": {
+ "x": 236,
+ "y": 680,
+ "w": 141,
+ "ch": 2,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Economica": {
+ "x": 391,
+ "y": 680,
+ "w": 88,
+ "ch": 2,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Eczar": {
+ "x": 493,
+ "y": 680,
+ "w": 65,
+ "ch": 2,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Edu AU VIC WA NT Arrows": {
+ "x": 572,
+ "y": 680,
+ "w": 302,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu AU VIC WA NT Dots": {
+ "x": 888,
+ "y": 680,
+ "w": 261,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu AU VIC WA NT Guides": {
+ "x": 0,
+ "y": 720,
+ "w": 285,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu AU VIC WA NT Hand": {
+ "x": 299,
+ "y": 720,
+ "w": 274,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu AU VIC WA NT Pre": {
+ "x": 587,
+ "y": 720,
+ "w": 257,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu NSW ACT Cursive": {
+ "x": 858,
+ "y": 720,
+ "w": 276,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu NSW ACT Foundation": {
+ "x": 0,
+ "y": 760,
+ "w": 239,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu NSW ACT Hand Pre": {
+ "x": 253,
+ "y": 760,
+ "w": 294,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu QLD Beginner": {
+ "x": 561,
+ "y": 760,
+ "w": 177,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu QLD Hand": {
+ "x": 752,
+ "y": 760,
+ "w": 175,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu SA Beginner": {
+ "x": 941,
+ "y": 760,
+ "w": 155,
+ "ch": 2,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu SA Hand": {
+ "x": 0,
+ "y": 0,
+ "w": 159,
+ "ch": 3,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu TAS Beginner": {
+ "x": 173,
+ "y": 0,
+ "w": 175,
+ "ch": 3,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu VIC WA NT Beginner": {
+ "x": 362,
+ "y": 0,
+ "w": 243,
+ "ch": 3,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu VIC WA NT Hand": {
+ "x": 619,
+ "y": 0,
+ "w": 230,
+ "ch": 3,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Edu VIC WA NT Hand Pre": {
+ "x": 863,
+ "y": 0,
+ "w": 282,
+ "ch": 3,
+ "s": ["400", "500", "600", "700"]
+ },
+ "El Messiri": {
+ "x": 0,
+ "y": 40,
+ "w": 109,
+ "ch": 3,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Electrolize": { "x": 123, "y": 40, "w": 123, "ch": 3, "s": ["400"] },
+ "Elms Sans": {
+ "x": 260,
+ "y": 40,
+ "w": 123,
+ "ch": 3,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Elsie": { "x": 397, "y": 40, "w": 61, "ch": 3, "s": ["400", "900"] },
+ "Elsie Swash Caps": {
+ "x": 472,
+ "y": 40,
+ "w": 190,
+ "ch": 3,
+ "s": ["400", "900"]
+ },
+ "Emblema One": { "x": 676, "y": 40, "w": 198, "ch": 3, "s": ["400"] },
+ "Emilys Candy": { "x": 888, "y": 40, "w": 148, "ch": 3, "s": ["400"] },
+ "Encode Sans": {
+ "x": 1050,
+ "y": 40,
+ "w": 141,
+ "ch": 3,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Encode Sans Condensed": {
+ "x": 0,
+ "y": 80,
+ "w": 227,
+ "ch": 3,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Encode Sans Expanded": {
+ "x": 241,
+ "y": 80,
+ "w": 287,
+ "ch": 3,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Encode Sans SC": {
+ "x": 542,
+ "y": 80,
+ "w": 186,
+ "ch": 3,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Encode Sans Semi Condensed": {
+ "x": 742,
+ "y": 80,
+ "w": 299,
+ "ch": 3,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Encode Sans Semi Expanded": {
+ "x": 0,
+ "y": 120,
+ "w": 330,
+ "ch": 3,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Engagement": { "x": 344, "y": 120, "w": 80, "ch": 3, "s": ["400"] },
+ "Englebert": { "x": 438, "y": 120, "w": 97, "ch": 3, "s": ["400"] },
+ "Enriqueta": {
+ "x": 549,
+ "y": 120,
+ "w": 115,
+ "ch": 3,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Ephesis": { "x": 678, "y": 120, "w": 72, "ch": 3, "s": ["400"] },
+ "Epilogue": {
+ "x": 764,
+ "y": 120,
+ "w": 109,
+ "ch": 3,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Epunda Sans": {
+ "x": 887,
+ "y": 120,
+ "w": 133,
+ "ch": 3,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Epunda Slab": {
+ "x": 1034,
+ "y": 120,
+ "w": 132,
+ "ch": 3,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Erica One": { "x": 0, "y": 160, "w": 131, "ch": 3, "s": ["400"] },
+ "Esteban": { "x": 145, "y": 160, "w": 93, "ch": 3, "s": ["400"] },
+ "Estonia": { "x": 252, "y": 160, "w": 47, "ch": 3, "s": ["400"] },
+ "Euphoria Script": { "x": 313, "y": 160, "w": 127, "ch": 3, "s": ["400"] },
+ "Ewert": { "x": 454, "y": 160, "w": 97, "ch": 3, "s": ["400"] },
+ "Exile": { "x": 565, "y": 160, "w": 81, "ch": 3, "s": ["400"] },
+ "Exo": {
+ "x": 660,
+ "y": 160,
+ "w": 48,
+ "ch": 3,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Exo 2": {
+ "x": 722,
+ "y": 160,
+ "w": 67,
+ "ch": 3,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Expletus Sans": {
+ "x": 803,
+ "y": 160,
+ "w": 159,
+ "ch": 3,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Explora": { "x": 976, "y": 160, "w": 87, "ch": 3, "s": ["400"] },
+ "Faculty Glyphic": { "x": 0, "y": 200, "w": 182, "ch": 3, "s": ["400"] },
+ "Fahkwang": {
+ "x": 196,
+ "y": 200,
+ "w": 133,
+ "ch": 3,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Familjen Grotesk": {
+ "x": 343,
+ "y": 200,
+ "w": 178,
+ "ch": 3,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Fanwood Text": {
+ "x": 535,
+ "y": 200,
+ "w": 140,
+ "ch": 3,
+ "s": ["400", "400i"]
+ },
+ "Farro": {
+ "x": 689,
+ "y": 200,
+ "w": 72,
+ "ch": 3,
+ "s": ["300", "400", "500", "700"]
+ },
+ "Farsan": { "x": 775, "y": 200, "w": 61, "ch": 3, "s": ["400"] },
+ "Fascinate": { "x": 850, "y": 200, "w": 135, "ch": 3, "s": ["400"] },
+ "Fascinate Inline": { "x": 0, "y": 240, "w": 220, "ch": 3, "s": ["400"] },
+ "Faster One": { "x": 234, "y": 240, "w": 165, "ch": 3, "s": ["400"] },
+ "Fasthand": { "x": 413, "y": 240, "w": 84, "ch": 3, "s": ["400"] },
+ "Fauna One": { "x": 511, "y": 240, "w": 141, "ch": 3, "s": ["400"] },
+ "Faustina": {
+ "x": 666,
+ "y": 240,
+ "w": 97,
+ "ch": 3,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Federant": { "x": 777, "y": 240, "w": 105, "ch": 3, "s": ["400"] },
+ "Federo": { "x": 896, "y": 240, "w": 78, "ch": 3, "s": ["400"] },
+ "Felipa": { "x": 988, "y": 240, "w": 60, "ch": 3, "s": ["400"] },
+ "Fenix": { "x": 1062, "y": 240, "w": 63, "ch": 3, "s": ["400"] },
+ "Festive": { "x": 0, "y": 280, "w": 67, "ch": 3, "s": ["400"] },
+ "Figtree": {
+ "x": 81,
+ "y": 280,
+ "w": 85,
+ "ch": 3,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Finger Paint": { "x": 180, "y": 280, "w": 158, "ch": 3, "s": ["400"] },
+ "Finlandica": {
+ "x": 352,
+ "y": 280,
+ "w": 114,
+ "ch": 3,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Fira Code": {
+ "x": 480,
+ "y": 280,
+ "w": 138,
+ "ch": 3,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Fira Mono": {
+ "x": 632,
+ "y": 280,
+ "w": 138,
+ "ch": 3,
+ "s": ["400", "500", "700"]
+ },
+ "Fira Sans": {
+ "x": 784,
+ "y": 280,
+ "w": 105,
+ "ch": 3,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Fira Sans Condensed": {
+ "x": 903,
+ "y": 280,
+ "w": 211,
+ "ch": 3,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Fira Sans Extra Condensed": {
+ "x": 0,
+ "y": 320,
+ "w": 244,
+ "ch": 3,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Fjalla One": { "x": 258, "y": 320, "w": 98, "ch": 3, "s": ["400"] },
+ "Fjord One": { "x": 370, "y": 320, "w": 115, "ch": 3, "s": ["400"] },
+ "Flamenco": { "x": 499, "y": 320, "w": 99, "ch": 3, "s": ["300", "400"] },
+ "Flavors": { "x": 612, "y": 320, "w": 82, "ch": 3, "s": ["400"] },
+ "Fleur De Leah": { "x": 708, "y": 320, "w": 133, "ch": 3, "s": ["400"] },
+ "Flow Block": { "x": 855, "y": 320, "w": 126, "ch": 3, "s": ["400"] },
+ "Flow Circular": { "x": 995, "y": 320, "w": 145, "ch": 3, "s": ["400"] },
+ "Flow Rounded": { "x": 0, "y": 360, "w": 160, "ch": 3, "s": ["400"] },
+ "Foldit": {
+ "x": 174,
+ "y": 360,
+ "w": 54,
+ "ch": 3,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Fondamento": {
+ "x": 242,
+ "y": 360,
+ "w": 133,
+ "ch": 3,
+ "s": ["400", "400i"]
+ },
+ "Fontdiner Swanky": { "x": 389, "y": 360, "w": 239, "ch": 3, "s": ["400"] },
+ "Forum": { "x": 642, "y": 360, "w": 70, "ch": 3, "s": ["400"] },
+ "Fragment Mono": {
+ "x": 726,
+ "y": 360,
+ "w": 201,
+ "ch": 3,
+ "s": ["400", "400i"]
+ },
+ "Francois One": { "x": 941, "y": 360, "w": 136, "ch": 3, "s": ["400"] },
+ "Frank Ruhl Libre": {
+ "x": 0,
+ "y": 400,
+ "w": 184,
+ "ch": 3,
+ "s": ["300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Fraunces": {
+ "x": 198,
+ "y": 400,
+ "w": 112,
+ "ch": 3,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Freckle Face": { "x": 324, "y": 400, "w": 141, "ch": 3, "s": ["400"] },
+ "Fredericka the Great": {
+ "x": 479,
+ "y": 400,
+ "w": 243,
+ "ch": 3,
+ "s": ["400"]
+ },
+ "Fredoka": {
+ "x": 736,
+ "y": 400,
+ "w": 96,
+ "ch": 3,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Fredoka One": { "x": 846, "y": 400, "w": 155, "ch": 3, "s": ["400"] },
+ "Freehand": { "x": 1015, "y": 400, "w": 85, "ch": 3, "s": ["400"] },
+ "Freeman": { "x": 0, "y": 440, "w": 91, "ch": 3, "s": ["400"] },
+ "Fresca": { "x": 105, "y": 440, "w": 67, "ch": 3, "s": ["400"] },
+ "Frijole": { "x": 186, "y": 440, "w": 132, "ch": 3, "s": ["400"] },
+ "Fruktur": { "x": 332, "y": 440, "w": 94, "ch": 3, "s": ["400", "400i"] },
+ "Fugaz One": { "x": 440, "y": 440, "w": 127, "ch": 3, "s": ["400"] },
+ "Fuggles": { "x": 581, "y": 440, "w": 58, "ch": 3, "s": ["400"] },
+ "Funnel Display": {
+ "x": 653,
+ "y": 440,
+ "w": 174,
+ "ch": 3,
+ "s": ["300", "400", "500", "600", "700", "800"]
+ },
+ "Funnel Sans": {
+ "x": 841,
+ "y": 440,
+ "w": 140,
+ "ch": 3,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Fustat": {
+ "x": 995,
+ "y": 440,
+ "w": 75,
+ "ch": 3,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Fuzzy Bubbles": {
+ "x": 0,
+ "y": 480,
+ "w": 179,
+ "ch": 3,
+ "s": ["400", "700"]
+ },
+ "Ga Maamli": { "x": 193, "y": 480, "w": 111, "ch": 3, "s": ["400"] },
+ "Gabarito": {
+ "x": 318,
+ "y": 480,
+ "w": 99,
+ "ch": 3,
+ "s": ["400", "500", "600", "700", "800", "900"]
+ },
+ "Gabriela": { "x": 431, "y": 480, "w": 106, "ch": 3, "s": ["400"] },
+ "Gaegu": {
+ "x": 551,
+ "y": 480,
+ "w": 70,
+ "ch": 3,
+ "s": ["300", "400", "700"]
+ },
+ "Gafata": { "x": 635, "y": 480, "w": 72, "ch": 3, "s": ["400"] },
+ "Gajraj One": { "x": 721, "y": 480, "w": 148, "ch": 3, "s": ["400"] },
+ "Galada": { "x": 883, "y": 480, "w": 78, "ch": 3, "s": ["400"] },
+ "Galdeano": { "x": 975, "y": 480, "w": 98, "ch": 3, "s": ["400"] },
+ "Galindo": { "x": 1087, "y": 480, "w": 101, "ch": 3, "s": ["400"] },
+ "Gamja Flower": { "x": 0, "y": 520, "w": 130, "ch": 3, "s": ["400"] },
+ "Gantari": {
+ "x": 144,
+ "y": 520,
+ "w": 89,
+ "ch": 3,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Gasoek One": { "x": 247, "y": 520, "w": 163, "ch": 3, "s": ["400"] },
+ "Gayathri": {
+ "x": 424,
+ "y": 520,
+ "w": 99,
+ "ch": 3,
+ "s": ["100", "400", "700"]
+ },
+ "Geist": {
+ "x": 537,
+ "y": 520,
+ "w": 66,
+ "ch": 3,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Geist Mono": {
+ "x": 617,
+ "y": 520,
+ "w": 152,
+ "ch": 3,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Gelasio": {
+ "x": 783,
+ "y": 520,
+ "w": 87,
+ "ch": 3,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Gemunu Libre": {
+ "x": 884,
+ "y": 520,
+ "w": 126,
+ "ch": 3,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Genos": {
+ "x": 1024,
+ "y": 520,
+ "w": 67,
+ "ch": 3,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Gentium Book Basic": {
+ "x": 0,
+ "y": 560,
+ "w": 209,
+ "ch": 3,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Gentium Book Plus": {
+ "x": 223,
+ "y": 560,
+ "w": 198,
+ "ch": 3,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Gentium Plus": {
+ "x": 435,
+ "y": 560,
+ "w": 140,
+ "ch": 3,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Geo": { "x": 589, "y": 560, "w": 42, "ch": 3, "s": ["400", "400i"] },
+ "Geologica": {
+ "x": 645,
+ "y": 560,
+ "w": 122,
+ "ch": 3,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Geom": {
+ "x": 781,
+ "y": 560,
+ "w": 76,
+ "ch": 3,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Georama": {
+ "x": 871,
+ "y": 560,
+ "w": 102,
+ "ch": 3,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Geostar": { "x": 987, "y": 560, "w": 133, "ch": 3, "s": ["400"] },
+ "Geostar Fill": { "x": 0, "y": 600, "w": 192, "ch": 3, "s": ["400"] },
+ "Germania One": { "x": 206, "y": 600, "w": 137, "ch": 3, "s": ["400"] },
+ "GFS Didot": { "x": 357, "y": 600, "w": 121, "ch": 3, "s": ["400"] },
+ "GFS Neohellenic": {
+ "x": 492,
+ "y": 600,
+ "w": 155,
+ "ch": 3,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Gideon Roman": { "x": 661, "y": 600, "w": 172, "ch": 3, "s": ["400"] },
+ "Gidole": { "x": 847, "y": 600, "w": 73, "ch": 3, "s": ["400"] },
+ "Gidugu": { "x": 934, "y": 600, "w": 58, "ch": 3, "s": ["400"] },
+ "Gilda Display": { "x": 1006, "y": 600, "w": 156, "ch": 3, "s": ["400"] },
+ "Girassol": { "x": 0, "y": 640, "w": 91, "ch": 3, "s": ["400"] },
+ "Give You Glory": { "x": 105, "y": 640, "w": 170, "ch": 3, "s": ["400"] },
+ "Glass Antiqua": { "x": 289, "y": 640, "w": 130, "ch": 3, "s": ["400"] },
+ "Glegoo": { "x": 433, "y": 640, "w": 90, "ch": 3, "s": ["400", "700"] },
+ "Gloock": { "x": 537, "y": 640, "w": 89, "ch": 3, "s": ["400"] },
+ "Gloria Hallelujah": {
+ "x": 640,
+ "y": 640,
+ "w": 190,
+ "ch": 3,
+ "s": ["400"]
+ },
+ "Glory": {
+ "x": 844,
+ "y": 640,
+ "w": 56,
+ "ch": 3,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Gluten": {
+ "x": 914,
+ "y": 640,
+ "w": 90,
+ "ch": 3,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Goblin One": { "x": 0, "y": 680, "w": 202, "ch": 3, "s": ["400"] },
+ "Gochi Hand": { "x": 216, "y": 680, "w": 121, "ch": 3, "s": ["400"] },
+ "Goldman": { "x": 351, "y": 680, "w": 118, "ch": 3, "s": ["400", "700"] },
+ "Golos Text": {
+ "x": 483,
+ "y": 680,
+ "w": 130,
+ "ch": 3,
+ "s": ["400", "500", "600", "700", "800", "900"]
+ },
+ "Google Sans": {
+ "x": 627,
+ "y": 680,
+ "w": 146,
+ "ch": 3,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Google Sans Code": {
+ "x": 787,
+ "y": 680,
+ "w": 239,
+ "ch": 3,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Google Sans Flex": {
+ "x": 0,
+ "y": 720,
+ "w": 193,
+ "ch": 3,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Gorditas": { "x": 207, "y": 720, "w": 111, "ch": 3, "s": ["400", "700"] },
+ "Gothic A1": {
+ "x": 332,
+ "y": 720,
+ "w": 114,
+ "ch": 3,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Gotu": { "x": 460, "y": 720, "w": 67, "ch": 3, "s": ["400"] },
+ "Goudy Bookletter 1911": {
+ "x": 541,
+ "y": 720,
+ "w": 218,
+ "ch": 3,
+ "s": ["400"]
+ },
+ "Gowun Batang": {
+ "x": 773,
+ "y": 720,
+ "w": 162,
+ "ch": 3,
+ "s": ["400", "700"]
+ },
+ "Gowun Dodum": { "x": 949, "y": 720, "w": 161, "ch": 3, "s": ["400"] },
+ "Graduate": { "x": 0, "y": 760, "w": 134, "ch": 3, "s": ["400"] },
+ "Grand Hotel": { "x": 148, "y": 760, "w": 101, "ch": 3, "s": ["400"] },
+ "Grandiflora One": { "x": 263, "y": 760, "w": 174, "ch": 3, "s": ["400"] },
+ "Grandstander": {
+ "x": 451,
+ "y": 760,
+ "w": 173,
+ "ch": 3,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Grape Nuts": { "x": 638, "y": 760, "w": 111, "ch": 3, "s": ["400"] },
+ "Gravitas One": { "x": 763, "y": 760, "w": 230, "ch": 3, "s": ["400"] },
+ "Great Vibes": { "x": 1007, "y": 760, "w": 104, "ch": 3, "s": ["400"] },
+ "Grechen Fuemen": { "x": 0, "y": 0, "w": 179, "ch": 4, "s": ["400"] },
+ "Grenze": {
+ "x": 193,
+ "y": 0,
+ "w": 69,
+ "ch": 4,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Grenze Gotisch": {
+ "x": 276,
+ "y": 0,
+ "w": 135,
+ "ch": 4,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Grey Qo": { "x": 425, "y": 0, "w": 64, "ch": 4, "s": ["400"] },
+ "Griffy": { "x": 503, "y": 0, "w": 71, "ch": 4, "s": ["400"] },
+ "Gruppo": { "x": 588, "y": 0, "w": 95, "ch": 4, "s": ["400"] },
+ "Gudea": {
+ "x": 697,
+ "y": 0,
+ "w": 74,
+ "ch": 4,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Gugi": { "x": 785, "y": 0, "w": 60, "ch": 4, "s": ["400"] },
+ "Gulzar": { "x": 859, "y": 0, "w": 77, "ch": 4, "s": ["400"] },
+ "Gupter": {
+ "x": 950,
+ "y": 0,
+ "w": 72,
+ "ch": 4,
+ "s": ["400", "500", "700"]
+ },
+ "Gurajada": { "x": 1036, "y": 0, "w": 71, "ch": 4, "s": ["400"] },
+ "Gwendolyn": { "x": 0, "y": 40, "w": 89, "ch": 4, "s": ["400", "700"] },
+ "Habibi": { "x": 103, "y": 40, "w": 81, "ch": 4, "s": ["400"] },
+ "Hachi Maru Pop": { "x": 198, "y": 40, "w": 246, "ch": 4, "s": ["400"] },
+ "Hahmlet": {
+ "x": 458,
+ "y": 40,
+ "w": 112,
+ "ch": 4,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Halant": {
+ "x": 584,
+ "y": 40,
+ "w": 75,
+ "ch": 4,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Hammersmith One": { "x": 673, "y": 40, "w": 217, "ch": 4, "s": ["400"] },
+ "Hanalei": { "x": 904, "y": 40, "w": 91, "ch": 4, "s": ["400"] },
+ "Hanalei Fill": { "x": 1009, "y": 40, "w": 136, "ch": 4, "s": ["400"] },
+ "Handjet": {
+ "x": 0,
+ "y": 80,
+ "w": 68,
+ "ch": 4,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Handlee": { "x": 82, "y": 80, "w": 88, "ch": 4, "s": ["400"] },
+ "Hanken Grotesk": {
+ "x": 184,
+ "y": 80,
+ "w": 181,
+ "ch": 4,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Hanuman": {
+ "x": 379,
+ "y": 80,
+ "w": 123,
+ "ch": 4,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Happy Monkey": { "x": 516, "y": 80, "w": 189, "ch": 4, "s": ["400"] },
+ "Harmattan": {
+ "x": 719,
+ "y": 80,
+ "w": 99,
+ "ch": 4,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Headland One": { "x": 832, "y": 80, "w": 185, "ch": 4, "s": ["400"] },
+ "Hedvig Letters Sans": {
+ "x": 0,
+ "y": 120,
+ "w": 224,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "Hedvig Letters Serif": {
+ "x": 238,
+ "y": 120,
+ "w": 226,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "Heebo": {
+ "x": 478,
+ "y": 120,
+ "w": 78,
+ "ch": 4,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Henny Penny": { "x": 570, "y": 120, "w": 148, "ch": 4, "s": ["400"] },
+ "Hepta Slab": {
+ "x": 732,
+ "y": 120,
+ "w": 150,
+ "ch": 4,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Herr Von Muellerhoff": {
+ "x": 896,
+ "y": 120,
+ "w": 145,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "Hi Melody": { "x": 1055, "y": 120, "w": 84, "ch": 4, "s": ["400"] },
+ "Hina Mincho": { "x": 0, "y": 160, "w": 128, "ch": 4, "s": ["400"] },
+ "Hind": {
+ "x": 142,
+ "y": 160,
+ "w": 56,
+ "ch": 4,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Hind Guntur": {
+ "x": 212,
+ "y": 160,
+ "w": 134,
+ "ch": 4,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Hind Madurai": {
+ "x": 360,
+ "y": 160,
+ "w": 146,
+ "ch": 4,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Hind Mysuru": {
+ "x": 520,
+ "y": 160,
+ "w": 138,
+ "ch": 4,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Hind Siliguri": {
+ "x": 672,
+ "y": 160,
+ "w": 130,
+ "ch": 4,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Hind Vadodara": {
+ "x": 816,
+ "y": 160,
+ "w": 161,
+ "ch": 4,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Holtwood One SC": { "x": 0, "y": 200, "w": 292, "ch": 4, "s": ["400"] },
+ "Homemade Apple": { "x": 306, "y": 200, "w": 239, "ch": 4, "s": ["400"] },
+ "Homenaje": { "x": 559, "y": 200, "w": 85, "ch": 4, "s": ["400"] },
+ "Honk": { "x": 658, "y": 200, "w": 59, "ch": 4, "s": ["400"] },
+ "Host Grotesk": {
+ "x": 731,
+ "y": 200,
+ "w": 150,
+ "ch": 4,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Hubballi": { "x": 895, "y": 200, "w": 87, "ch": 4, "s": ["400"] },
+ "Hubot Sans": {
+ "x": 996,
+ "y": 200,
+ "w": 137,
+ "ch": 4,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Huninn": { "x": 0, "y": 240, "w": 89, "ch": 4, "s": ["400"] },
+ "Hurricane": { "x": 103, "y": 240, "w": 76, "ch": 4, "s": ["400"] },
+ "Iansui": { "x": 193, "y": 240, "w": 78, "ch": 4, "s": ["400"] },
+ "Ibarra Real Nova": {
+ "x": 285,
+ "y": 240,
+ "w": 182,
+ "ch": 4,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "IBM Plex Mono": {
+ "x": 481,
+ "y": 240,
+ "w": 196,
+ "ch": 4,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "IBM Plex Sans": {
+ "x": 691,
+ "y": 240,
+ "w": 163,
+ "ch": 4,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "IBM Plex Sans Arabic": {
+ "x": 868,
+ "y": 240,
+ "w": 237,
+ "ch": 4,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "IBM Plex Sans Condensed": {
+ "x": 0,
+ "y": 280,
+ "w": 263,
+ "ch": 4,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "IBM Plex Sans Devanagari": {
+ "x": 277,
+ "y": 280,
+ "w": 289,
+ "ch": 4,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "IBM Plex Sans Hebrew": {
+ "x": 580,
+ "y": 280,
+ "w": 253,
+ "ch": 4,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "IBM Plex Sans JP": {
+ "x": 847,
+ "y": 280,
+ "w": 204,
+ "ch": 4,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "IBM Plex Sans KR": {
+ "x": 0,
+ "y": 320,
+ "w": 199,
+ "ch": 4,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "IBM Plex Sans Thai": {
+ "x": 213,
+ "y": 320,
+ "w": 215,
+ "ch": 4,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "IBM Plex Sans Thai Looped": {
+ "x": 442,
+ "y": 320,
+ "w": 300,
+ "ch": 4,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "IBM Plex Serif": {
+ "x": 756,
+ "y": 320,
+ "w": 167,
+ "ch": 4,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Iceberg": { "x": 937, "y": 320, "w": 84, "ch": 4, "s": ["400"] },
+ "Iceland": { "x": 1035, "y": 320, "w": 75, "ch": 4, "s": ["400"] },
+ "IM Fell Double Pica": {
+ "x": 0,
+ "y": 360,
+ "w": 204,
+ "ch": 4,
+ "s": ["400", "400i"]
+ },
+ "IM Fell Double Pica SC": {
+ "x": 218,
+ "y": 360,
+ "w": 261,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "IM Fell DW Pica": {
+ "x": 493,
+ "y": 360,
+ "w": 175,
+ "ch": 4,
+ "s": ["400", "400i"]
+ },
+ "IM Fell DW Pica SC": {
+ "x": 682,
+ "y": 360,
+ "w": 220,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "IM Fell English": {
+ "x": 916,
+ "y": 360,
+ "w": 168,
+ "ch": 4,
+ "s": ["400", "400i"]
+ },
+ "IM Fell English SC": { "x": 0, "y": 400, "w": 215, "ch": 4, "s": ["400"] },
+ "IM Fell French Canon": {
+ "x": 229,
+ "y": 400,
+ "w": 233,
+ "ch": 4,
+ "s": ["400", "400i"]
+ },
+ "IM Fell French Canon SC": {
+ "x": 476,
+ "y": 400,
+ "w": 279,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "IM Fell Great Primer": {
+ "x": 769,
+ "y": 400,
+ "w": 220,
+ "ch": 4,
+ "s": ["400", "400i"]
+ },
+ "IM Fell Great Primer SC": {
+ "x": 0,
+ "y": 440,
+ "w": 289,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "Imbue": {
+ "x": 303,
+ "y": 440,
+ "w": 52,
+ "ch": 4,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Imperial Script": { "x": 369, "y": 440, "w": 119, "ch": 4, "s": ["400"] },
+ "Imprima": { "x": 502, "y": 440, "w": 98, "ch": 4, "s": ["400"] },
+ "Inclusive Sans": {
+ "x": 614,
+ "y": 440,
+ "w": 172,
+ "ch": 4,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Inconsolata": {
+ "x": 800,
+ "y": 440,
+ "w": 140,
+ "ch": 4,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Inder": { "x": 954, "y": 440, "w": 64, "ch": 4, "s": ["400"] },
+ "Indie Flower": { "x": 1032, "y": 440, "w": 125, "ch": 4, "s": ["400"] },
+ "Ingrid Darling": { "x": 0, "y": 480, "w": 114, "ch": 4, "s": ["400"] },
+ "Inika": { "x": 128, "y": 480, "w": 67, "ch": 4, "s": ["400", "700"] },
+ "Inknut Antiqua": {
+ "x": 209,
+ "y": 480,
+ "w": 211,
+ "ch": 4,
+ "s": ["300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Inria Sans": {
+ "x": 434,
+ "y": 480,
+ "w": 110,
+ "ch": 4,
+ "s": ["300", "300i", "400", "400i", "700", "700i"]
+ },
+ "Inria Serif": {
+ "x": 558,
+ "y": 480,
+ "w": 120,
+ "ch": 4,
+ "s": ["300", "300i", "400", "400i", "700", "700i"]
+ },
+ "Inspiration": { "x": 692, "y": 480, "w": 78, "ch": 4, "s": ["400"] },
+ "Instrument Sans": {
+ "x": 784,
+ "y": 480,
+ "w": 189,
+ "ch": 4,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Instrument Serif": {
+ "x": 987,
+ "y": 480,
+ "w": 143,
+ "ch": 4,
+ "s": ["400", "400i"]
+ },
+ "Intel One Mono": {
+ "x": 0,
+ "y": 520,
+ "w": 215,
+ "ch": 4,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Inter": {
+ "x": 229,
+ "y": 520,
+ "w": 60,
+ "ch": 4,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Inter Tight": {
+ "x": 303,
+ "y": 520,
+ "w": 113,
+ "ch": 4,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Irish Grover": { "x": 430, "y": 520, "w": 139, "ch": 4, "s": ["400"] },
+ "Island Moments": { "x": 583, "y": 520, "w": 125, "ch": 4, "s": ["400"] },
+ "Istok Web": {
+ "x": 722,
+ "y": 520,
+ "w": 116,
+ "ch": 4,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Italiana": { "x": 852, "y": 520, "w": 83, "ch": 4, "s": ["400"] },
+ "Italianno": { "x": 949, "y": 520, "w": 68, "ch": 4, "s": ["400"] },
+ "Itim": { "x": 1031, "y": 520, "w": 52, "ch": 4, "s": ["400"] },
+ "Jacquard 12": { "x": 0, "y": 560, "w": 110, "ch": 4, "s": ["400"] },
+ "Jacquard 12 Charted": {
+ "x": 124,
+ "y": 560,
+ "w": 184,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "Jacquard 24": { "x": 322, "y": 560, "w": 108, "ch": 4, "s": ["400"] },
+ "Jacquard 24 Charted": {
+ "x": 444,
+ "y": 560,
+ "w": 179,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "Jacquarda Bastarda 9": {
+ "x": 637,
+ "y": 560,
+ "w": 261,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "Jacquarda Bastarda 9 Charted": {
+ "x": 0,
+ "y": 600,
+ "w": 357,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "Jacques Francois": { "x": 371, "y": 600, "w": 197, "ch": 4, "s": ["400"] },
+ "Jacques Francois Shadow": {
+ "x": 582,
+ "y": 600,
+ "w": 315,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "Jaini": { "x": 911, "y": 600, "w": 50, "ch": 4, "s": ["400"] },
+ "Jaini Purva": { "x": 975, "y": 600, "w": 102, "ch": 4, "s": ["400"] },
+ "Jaldi": { "x": 1091, "y": 600, "w": 52, "ch": 4, "s": ["400", "700"] },
+ "Jaro": { "x": 0, "y": 640, "w": 50, "ch": 4, "s": ["400"] },
+ "Jersey 10": { "x": 64, "y": 640, "w": 84, "ch": 4, "s": ["400"] },
+ "Jersey 10 Charted": {
+ "x": 162,
+ "y": 640,
+ "w": 158,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "Jersey 15": { "x": 334, "y": 640, "w": 91, "ch": 4, "s": ["400"] },
+ "Jersey 15 Charted": {
+ "x": 439,
+ "y": 640,
+ "w": 168,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "Jersey 20": { "x": 621, "y": 640, "w": 98, "ch": 4, "s": ["400"] },
+ "Jersey 20 Charted": {
+ "x": 733,
+ "y": 640,
+ "w": 176,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "Jersey 25": { "x": 923, "y": 640, "w": 102, "ch": 4, "s": ["400"] },
+ "Jersey 25 Charted": { "x": 0, "y": 680, "w": 180, "ch": 4, "s": ["400"] },
+ "JetBrains Mono": {
+ "x": 194,
+ "y": 680,
+ "w": 210,
+ "ch": 4,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Jim Nightshade": { "x": 418, "y": 680, "w": 119, "ch": 4, "s": ["400"] },
+ "Joan": { "x": 551, "y": 680, "w": 54, "ch": 4, "s": ["400"] },
+ "Jockey One": { "x": 619, "y": 680, "w": 108, "ch": 4, "s": ["400"] },
+ "Jolly Lodger": { "x": 741, "y": 680, "w": 90, "ch": 4, "s": ["400"] },
+ "Jomhuria": { "x": 845, "y": 680, "w": 64, "ch": 4, "s": ["400"] },
+ "Jomolhari": { "x": 923, "y": 680, "w": 116, "ch": 4, "s": ["400"] },
+ "Josefin Sans": {
+ "x": 1053,
+ "y": 680,
+ "w": 138,
+ "ch": 4,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Josefin Slab": {
+ "x": 0,
+ "y": 720,
+ "w": 123,
+ "ch": 4,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Jost": {
+ "x": 137,
+ "y": 720,
+ "w": 43,
+ "ch": 4,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Joti One": { "x": 194, "y": 720, "w": 100, "ch": 4, "s": ["400"] },
+ "Jua": { "x": 308, "y": 720, "w": 48, "ch": 4, "s": ["400"] },
+ "Judson": {
+ "x": 370,
+ "y": 720,
+ "w": 80,
+ "ch": 4,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Julee": { "x": 464, "y": 720, "w": 57, "ch": 4, "s": ["400"] },
+ "Julius Sans One": { "x": 535, "y": 720, "w": 210, "ch": 4, "s": ["400"] },
+ "Junge": { "x": 759, "y": 720, "w": 73, "ch": 4, "s": ["400"] },
+ "Jura": {
+ "x": 846,
+ "y": 720,
+ "w": 59,
+ "ch": 4,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Just Another Hand": {
+ "x": 919,
+ "y": 720,
+ "w": 112,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "Just Me Again Down Here": {
+ "x": 0,
+ "y": 760,
+ "w": 200,
+ "ch": 4,
+ "s": ["400"]
+ },
+ "K2D": {
+ "x": 214,
+ "y": 760,
+ "w": 56,
+ "ch": 4,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Kablammo": { "x": 284, "y": 760, "w": 128, "ch": 4, "s": ["400"] },
+ "Kadwa": { "x": 426, "y": 760, "w": 87, "ch": 4, "s": ["400", "700"] },
+ "Kaisei Decol": {
+ "x": 527,
+ "y": 760,
+ "w": 154,
+ "ch": 4,
+ "s": ["400", "500", "700"]
+ },
+ "Kaisei HarunoUmi": {
+ "x": 695,
+ "y": 760,
+ "w": 223,
+ "ch": 4,
+ "s": ["400", "500", "700"]
+ },
+ "Kaisei Opti": {
+ "x": 932,
+ "y": 760,
+ "w": 137,
+ "ch": 4,
+ "s": ["400", "500", "700"]
+ },
+ "Kaisei Tokumin": {
+ "x": 0,
+ "y": 0,
+ "w": 191,
+ "ch": 5,
+ "s": ["400", "500", "700", "800"]
+ },
+ "Kalam": { "x": 205, "y": 0, "w": 70, "ch": 5, "s": ["300", "400", "700"] },
+ "Kalnia": {
+ "x": 289,
+ "y": 0,
+ "w": 87,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "Kalnia Glaze": {
+ "x": 390,
+ "y": 0,
+ "w": 162,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "Kameron": {
+ "x": 566,
+ "y": 0,
+ "w": 107,
+ "ch": 5,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Kanchenjunga": {
+ "x": 687,
+ "y": 0,
+ "w": 165,
+ "ch": 5,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Kanit": {
+ "x": 866,
+ "y": 0,
+ "w": 65,
+ "ch": 5,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Kantumruy Pro": {
+ "x": 945,
+ "y": 0,
+ "w": 175,
+ "ch": 5,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Kapakana": { "x": 0, "y": 40, "w": 83, "ch": 5, "s": ["300", "400"] },
+ "Karantina": {
+ "x": 97,
+ "y": 40,
+ "w": 66,
+ "ch": 5,
+ "s": ["300", "400", "700"]
+ },
+ "Karla": {
+ "x": 177,
+ "y": 40,
+ "w": 65,
+ "ch": 5,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Karma": {
+ "x": 256,
+ "y": 40,
+ "w": 79,
+ "ch": 5,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Katibeh": { "x": 349, "y": 40, "w": 67, "ch": 5, "s": ["400"] },
+ "Kaushan Script": { "x": 430, "y": 40, "w": 159, "ch": 5, "s": ["400"] },
+ "Kavivanar": { "x": 603, "y": 40, "w": 107, "ch": 5, "s": ["400"] },
+ "Kavoon": { "x": 724, "y": 40, "w": 95, "ch": 5, "s": ["400"] },
+ "Kay Pho Du": {
+ "x": 833,
+ "y": 40,
+ "w": 132,
+ "ch": 5,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Kdam Thmor Pro": { "x": 979, "y": 40, "w": 189, "ch": 5, "s": ["400"] },
+ "Keania One": { "x": 0, "y": 80, "w": 126, "ch": 5, "s": ["400"] },
+ "Kedebideri": {
+ "x": 140,
+ "y": 80,
+ "w": 120,
+ "ch": 5,
+ "s": ["400", "500", "600", "700", "800", "900"]
+ },
+ "Kelly Slab": { "x": 274, "y": 80, "w": 115, "ch": 5, "s": ["400"] },
+ "Kenia": { "x": 403, "y": 80, "w": 57, "ch": 5, "s": ["400"] },
+ "Khand": {
+ "x": 474,
+ "y": 80,
+ "w": 61,
+ "ch": 5,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Khula": {
+ "x": 549,
+ "y": 80,
+ "w": 68,
+ "ch": 5,
+ "s": ["300", "400", "600", "700", "800"]
+ },
+ "Kings": { "x": 631, "y": 80, "w": 63, "ch": 5, "s": ["400"] },
+ "Kirang Haerang": { "x": 708, "y": 80, "w": 145, "ch": 5, "s": ["400"] },
+ "Kite One": { "x": 867, "y": 80, "w": 97, "ch": 5, "s": ["400"] },
+ "Kiwi Maru": {
+ "x": 978,
+ "y": 80,
+ "w": 135,
+ "ch": 5,
+ "s": ["300", "400", "500"]
+ },
+ "Klee One": { "x": 0, "y": 120, "w": 109, "ch": 5, "s": ["400", "600"] },
+ "Knewave": { "x": 123, "y": 120, "w": 103, "ch": 5, "s": ["400"] },
+ "Kodchasan": {
+ "x": 240,
+ "y": 120,
+ "w": 139,
+ "ch": 5,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Kode Mono": {
+ "x": 393,
+ "y": 120,
+ "w": 138,
+ "ch": 5,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Koh Santepheap": {
+ "x": 545,
+ "y": 120,
+ "w": 195,
+ "ch": 5,
+ "s": ["100", "300", "400", "700", "900"]
+ },
+ "KoHo": {
+ "x": 754,
+ "y": 120,
+ "w": 66,
+ "ch": 5,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Kolker Brush": { "x": 834, "y": 120, "w": 84, "ch": 5, "s": ["400"] },
+ "Konkhmer Sleokchher": {
+ "x": 0,
+ "y": 160,
+ "w": 269,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "Kosugi": { "x": 283, "y": 160, "w": 80, "ch": 5, "s": ["400"] },
+ "Kosugi Maru": { "x": 377, "y": 160, "w": 140, "ch": 5, "s": ["400"] },
+ "Kotta One": { "x": 531, "y": 160, "w": 110, "ch": 5, "s": ["400"] },
+ "Koulen": { "x": 655, "y": 160, "w": 72, "ch": 5, "s": ["400"] },
+ "Kranky": { "x": 741, "y": 160, "w": 87, "ch": 5, "s": ["400"] },
+ "Kreon": {
+ "x": 842,
+ "y": 160,
+ "w": 68,
+ "ch": 5,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Kristi": { "x": 924, "y": 160, "w": 46, "ch": 5, "s": ["400"] },
+ "Krona One": { "x": 984, "y": 160, "w": 173, "ch": 5, "s": ["400"] },
+ "Krub": {
+ "x": 0,
+ "y": 200,
+ "w": 62,
+ "ch": 5,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Kufam": {
+ "x": 76,
+ "y": 200,
+ "w": 84,
+ "ch": 5,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Kulim Park": {
+ "x": 174,
+ "y": 200,
+ "w": 116,
+ "ch": 5,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Kumar One": { "x": 304, "y": 200, "w": 167, "ch": 5, "s": ["400"] },
+ "Kumar One Outline": {
+ "x": 485,
+ "y": 200,
+ "w": 277,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "Kumbh Sans": {
+ "x": 776,
+ "y": 200,
+ "w": 147,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Kurale": { "x": 937, "y": 200, "w": 77, "ch": 5, "s": ["400"] },
+ "La Belle Aurore": { "x": 1028, "y": 200, "w": 170, "ch": 5, "s": ["400"] },
+ "Labrada": {
+ "x": 0,
+ "y": 240,
+ "w": 91,
+ "ch": 5,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Lacquer": { "x": 105, "y": 240, "w": 102, "ch": 5, "s": ["400"] },
+ "Laila": {
+ "x": 221,
+ "y": 240,
+ "w": 60,
+ "ch": 5,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Lakki Reddy": { "x": 295, "y": 240, "w": 138, "ch": 5, "s": ["400"] },
+ "Lalezar": { "x": 447, "y": 240, "w": 82, "ch": 5, "s": ["400"] },
+ "Lancelot": { "x": 543, "y": 240, "w": 80, "ch": 5, "s": ["400"] },
+ "Langar": { "x": 637, "y": 240, "w": 83, "ch": 5, "s": ["400"] },
+ "Lateef": {
+ "x": 734,
+ "y": 240,
+ "w": 57,
+ "ch": 5,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Lato": {
+ "x": 805,
+ "y": 240,
+ "w": 55,
+ "ch": 5,
+ "s": [
+ "100",
+ "100i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "700",
+ "700i",
+ "900",
+ "900i"
+ ]
+ },
+ "Lavishly Yours": { "x": 874, "y": 240, "w": 135, "ch": 5, "s": ["400"] },
+ "League Gothic": { "x": 1023, "y": 240, "w": 97, "ch": 5, "s": ["400"] },
+ "League Script": { "x": 0, "y": 280, "w": 156, "ch": 5, "s": ["400"] },
+ "League Spartan": {
+ "x": 170,
+ "y": 280,
+ "w": 166,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Leckerli One": { "x": 350, "y": 280, "w": 144, "ch": 5, "s": ["400"] },
+ "Ledger": { "x": 508, "y": 280, "w": 91, "ch": 5, "s": ["400"] },
+ "Lekton": {
+ "x": 613,
+ "y": 280,
+ "w": 80,
+ "ch": 5,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Lemon": { "x": 707, "y": 280, "w": 100, "ch": 5, "s": ["400"] },
+ "Lemonada": {
+ "x": 821,
+ "y": 280,
+ "w": 153,
+ "ch": 5,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Lexend": {
+ "x": 988,
+ "y": 280,
+ "w": 93,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Lexend Deca": {
+ "x": 0,
+ "y": 320,
+ "w": 159,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Lexend Exa": {
+ "x": 173,
+ "y": 320,
+ "w": 176,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Lexend Giga": {
+ "x": 363,
+ "y": 320,
+ "w": 205,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Lexend Mega": {
+ "x": 582,
+ "y": 320,
+ "w": 223,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Lexend Peta": {
+ "x": 819,
+ "y": 320,
+ "w": 218,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Lexend Tera": {
+ "x": 0,
+ "y": 360,
+ "w": 221,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Lexend Zetta": {
+ "x": 235,
+ "y": 360,
+ "w": 260,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Libertinus Keyboard": {
+ "x": 509,
+ "y": 360,
+ "w": 490,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "Libertinus Math": { "x": 1013, "y": 360, "w": 166, "ch": 5, "s": ["400"] },
+ "Libertinus Mono": { "x": 0, "y": 400, "w": 239, "ch": 5, "s": ["400"] },
+ "Libertinus Sans": {
+ "x": 253,
+ "y": 400,
+ "w": 161,
+ "ch": 5,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Libertinus Serif": {
+ "x": 428,
+ "y": 400,
+ "w": 160,
+ "ch": 5,
+ "s": ["400", "400i", "600", "600i", "700", "700i"]
+ },
+ "Libertinus Serif Display": {
+ "x": 602,
+ "y": 400,
+ "w": 234,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "Libre Barcode 128": {
+ "x": 850,
+ "y": 400,
+ "w": 143,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "Libre Barcode 128 Text": {
+ "x": 1007,
+ "y": 400,
+ "w": 183,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "Libre Barcode 39": { "x": 0, "y": 440, "w": 193, "ch": 5, "s": ["400"] },
+ "Libre Barcode 39 Extended": {
+ "x": 207,
+ "y": 440,
+ "w": 492,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "Libre Barcode 39 Extended Text": {
+ "x": 0,
+ "y": 480,
+ "w": 584,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "Libre Barcode 39 Text": {
+ "x": 598,
+ "y": 480,
+ "w": 250,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "Libre Barcode EAN13 Text": {
+ "x": 862,
+ "y": 480,
+ "w": 87,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "Libre Baskerville": {
+ "x": 963,
+ "y": 480,
+ "w": 216,
+ "ch": 5,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Libre Bodoni": {
+ "x": 0,
+ "y": 520,
+ "w": 153,
+ "ch": 5,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Libre Caslon Display": {
+ "x": 167,
+ "y": 520,
+ "w": 197,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "Libre Caslon Text": {
+ "x": 378,
+ "y": 520,
+ "w": 210,
+ "ch": 5,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Libre Franklin": {
+ "x": 602,
+ "y": 520,
+ "w": 159,
+ "ch": 5,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Licorice": { "x": 775, "y": 520, "w": 60, "ch": 5, "s": ["400"] },
+ "Life Savers": {
+ "x": 849,
+ "y": 520,
+ "w": 119,
+ "ch": 5,
+ "s": ["400", "700", "800"]
+ },
+ "Lilex": {
+ "x": 982,
+ "y": 520,
+ "w": 80,
+ "ch": 5,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Lilita One": { "x": 1076, "y": 520, "w": 106, "ch": 5, "s": ["400"] },
+ "Lily Script One": { "x": 0, "y": 560, "w": 156, "ch": 5, "s": ["400"] },
+ "Limelight": { "x": 170, "y": 560, "w": 132, "ch": 5, "s": ["400"] },
+ "Linden Hill": {
+ "x": 316,
+ "y": 560,
+ "w": 113,
+ "ch": 5,
+ "s": ["400", "400i"]
+ },
+ "LINE Seed JP": {
+ "x": 443,
+ "y": 560,
+ "w": 169,
+ "ch": 5,
+ "s": ["100", "400", "700", "800"]
+ },
+ "Linefont": {
+ "x": 626,
+ "y": 560,
+ "w": 28,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Lisu Bosa": {
+ "x": 668,
+ "y": 560,
+ "w": 106,
+ "ch": 5,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Liter": { "x": 788, "y": 560, "w": 56, "ch": 5, "s": ["400"] },
+ "Literata": {
+ "x": 858,
+ "y": 560,
+ "w": 99,
+ "ch": 5,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Liu Jian Mao Cao": { "x": 971, "y": 560, "w": 164, "ch": 5, "s": ["400"] },
+ "Livvic": {
+ "x": 0,
+ "y": 600,
+ "w": 66,
+ "ch": 5,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "900",
+ "900i"
+ ]
+ },
+ "Lobster": { "x": 80, "y": 600, "w": 72, "ch": 5, "s": ["400"] },
+ "Lobster Two": {
+ "x": 166,
+ "y": 600,
+ "w": 114,
+ "ch": 5,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Londrina Outline": { "x": 294, "y": 600, "w": 156, "ch": 5, "s": ["400"] },
+ "Londrina Shadow": { "x": 464, "y": 600, "w": 165, "ch": 5, "s": ["400"] },
+ "Londrina Sketch": { "x": 643, "y": 600, "w": 156, "ch": 5, "s": ["400"] },
+ "Londrina Solid": {
+ "x": 813,
+ "y": 600,
+ "w": 136,
+ "ch": 5,
+ "s": ["100", "300", "400", "900"]
+ },
+ "Long Cang": { "x": 963, "y": 600, "w": 98, "ch": 5, "s": ["400"] },
+ "Lora": {
+ "x": 1075,
+ "y": 600,
+ "w": 59,
+ "ch": 5,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Love Light": { "x": 0, "y": 640, "w": 88, "ch": 5, "s": ["400"] },
+ "Love Ya Like A Sister": {
+ "x": 102,
+ "y": 640,
+ "w": 235,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "Loved by the King": {
+ "x": 351,
+ "y": 640,
+ "w": 145,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "Lovers Quarrel": { "x": 510, "y": 640, "w": 92, "ch": 5, "s": ["400"] },
+ "Luckiest Guy": { "x": 616, "y": 640, "w": 156, "ch": 5, "s": ["400"] },
+ "Lugrasimo": { "x": 786, "y": 640, "w": 144, "ch": 5, "s": ["400"] },
+ "Lumanosimo": { "x": 944, "y": 640, "w": 185, "ch": 5, "s": ["400"] },
+ "Lunasima": { "x": 0, "y": 680, "w": 119, "ch": 5, "s": ["400", "700"] },
+ "Lusitana": { "x": 133, "y": 680, "w": 96, "ch": 5, "s": ["400", "700"] },
+ "Lustria": { "x": 243, "y": 680, "w": 86, "ch": 5, "s": ["400"] },
+ "Luxurious Roman": { "x": 343, "y": 680, "w": 201, "ch": 5, "s": ["400"] },
+ "Luxurious Script": { "x": 558, "y": 680, "w": 128, "ch": 5, "s": ["400"] },
+ "LXGW Marker Gothic": {
+ "x": 700,
+ "y": 680,
+ "w": 233,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "LXGW WenKai Mono TC": {
+ "x": 947,
+ "y": 680,
+ "w": 236,
+ "ch": 5,
+ "s": ["300", "400", "700"]
+ },
+ "LXGW WenKai TC": {
+ "x": 0,
+ "y": 720,
+ "w": 206,
+ "ch": 5,
+ "s": ["300", "400", "700"]
+ },
+ "M PLUS 1": {
+ "x": 220,
+ "y": 720,
+ "w": 120,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "M PLUS 1 Code": {
+ "x": 354,
+ "y": 720,
+ "w": 164,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "M PLUS 1p": {
+ "x": 532,
+ "y": 720,
+ "w": 129,
+ "ch": 5,
+ "s": ["100", "300", "400", "500", "700", "800", "900"]
+ },
+ "M PLUS 2": {
+ "x": 675,
+ "y": 720,
+ "w": 120,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "M PLUS Code Latin": {
+ "x": 809,
+ "y": 720,
+ "w": 212,
+ "ch": 5,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "M PLUS Rounded 1c": {
+ "x": 0,
+ "y": 760,
+ "w": 225,
+ "ch": 5,
+ "s": ["100", "300", "400", "500", "700", "800", "900"]
+ },
+ "Ma Shan Zheng": { "x": 239, "y": 760, "w": 126, "ch": 5, "s": ["400"] },
+ "Macondo": { "x": 379, "y": 760, "w": 100, "ch": 5, "s": ["400"] },
+ "Macondo Swash Caps": {
+ "x": 493,
+ "y": 760,
+ "w": 228,
+ "ch": 5,
+ "s": ["400"]
+ },
+ "Mada": {
+ "x": 735,
+ "y": 760,
+ "w": 63,
+ "ch": 5,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Madimi One": { "x": 812, "y": 760, "w": 133, "ch": 5, "s": ["400"] },
+ "Magra": { "x": 959, "y": 760, "w": 70, "ch": 5, "s": ["400", "700"] },
+ "Maiden Orange": { "x": 1043, "y": 760, "w": 127, "ch": 5, "s": ["400"] },
+ "Maitree": {
+ "x": 0,
+ "y": 0,
+ "w": 96,
+ "ch": 6,
+ "s": ["200", "300", "400", "500", "600", "700"]
+ },
+ "Major Mono Display": { "x": 110, "y": 0, "w": 328, "ch": 6, "s": ["400"] },
+ "Mako": { "x": 452, "y": 0, "w": 67, "ch": 6, "s": ["400"] },
+ "Mali": {
+ "x": 533,
+ "y": 0,
+ "w": 56,
+ "ch": 6,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Mallanna": { "x": 603, "y": 0, "w": 96, "ch": 6, "s": ["400"] },
+ "Maname": { "x": 713, "y": 0, "w": 100, "ch": 6, "s": ["400"] },
+ "Mandali": { "x": 827, "y": 0, "w": 94, "ch": 6, "s": ["400"] },
+ "Manjari": {
+ "x": 935,
+ "y": 0,
+ "w": 87,
+ "ch": 6,
+ "s": ["100", "400", "700"]
+ },
+ "Manrope": {
+ "x": 1036,
+ "y": 0,
+ "w": 107,
+ "ch": 6,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Mansalva": { "x": 0, "y": 40, "w": 111, "ch": 6, "s": ["400"] },
+ "Manuale": {
+ "x": 125,
+ "y": 40,
+ "w": 101,
+ "ch": 6,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Manufacturing Consent": {
+ "x": 240,
+ "y": 40,
+ "w": 214,
+ "ch": 6,
+ "s": ["400"]
+ },
+ "Marcellus": { "x": 468, "y": 40, "w": 114, "ch": 6, "s": ["400"] },
+ "Marcellus SC": { "x": 596, "y": 40, "w": 157, "ch": 6, "s": ["400"] },
+ "Marck Script": { "x": 767, "y": 40, "w": 137, "ch": 6, "s": ["400"] },
+ "Margarine": { "x": 918, "y": 40, "w": 119, "ch": 6, "s": ["400"] },
+ "Marhey": {
+ "x": 1051,
+ "y": 40,
+ "w": 107,
+ "ch": 6,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Markazi Text": {
+ "x": 0,
+ "y": 80,
+ "w": 113,
+ "ch": 6,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Marko One": { "x": 127, "y": 80, "w": 152, "ch": 6, "s": ["400"] },
+ "Marmelad": { "x": 293, "y": 80, "w": 118, "ch": 6, "s": ["400"] },
+ "Martel": {
+ "x": 425,
+ "y": 80,
+ "w": 88,
+ "ch": 6,
+ "s": ["200", "300", "400", "600", "700", "800", "900"]
+ },
+ "Martel Sans": {
+ "x": 527,
+ "y": 80,
+ "w": 143,
+ "ch": 6,
+ "s": ["200", "300", "400", "600", "700", "800", "900"]
+ },
+ "Martian Mono": {
+ "x": 684,
+ "y": 80,
+ "w": 210,
+ "ch": 6,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Marvel": {
+ "x": 908,
+ "y": 80,
+ "w": 64,
+ "ch": 6,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Matangi": {
+ "x": 986,
+ "y": 80,
+ "w": 99,
+ "ch": 6,
+ "s": ["300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Mate": { "x": 1099, "y": 80, "w": 61, "ch": 6, "s": ["400", "400i"] },
+ "Mate SC": { "x": 0, "y": 120, "w": 99, "ch": 6, "s": ["400"] },
+ "Matemasie": { "x": 113, "y": 120, "w": 145, "ch": 6, "s": ["400"] },
+ "Material Icons": { "x": 272, "y": 120, "w": 344, "ch": 6, "s": ["400"] },
+ "Material Icons Outlined": {
+ "x": 630,
+ "y": 120,
+ "w": 560,
+ "ch": 6,
+ "s": ["400"]
+ },
+ "Material Icons Round": {
+ "x": 0,
+ "y": 160,
+ "w": 488,
+ "ch": 6,
+ "s": ["400"]
+ },
+ "Material Icons Sharp": {
+ "x": 502,
+ "y": 160,
+ "w": 488,
+ "ch": 6,
+ "s": ["400"]
+ },
+ "Material Icons Two Tone": {
+ "x": 0,
+ "y": 200,
+ "w": 560,
+ "ch": 6,
+ "s": ["400"]
+ },
+ "Material Symbols": {
+ "x": 574,
+ "y": 200,
+ "w": 392,
+ "ch": 6,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "Material Symbols Outlined": {
+ "x": 0,
+ "y": 240,
+ "w": 608,
+ "ch": 6,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "Material Symbols Rounded": {
+ "x": 0,
+ "y": 280,
+ "w": 584,
+ "ch": 6,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "Material Symbols Sharp": {
+ "x": 598,
+ "y": 280,
+ "w": 536,
+ "ch": 6,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "Maven Pro": {
+ "x": 0,
+ "y": 320,
+ "w": 123,
+ "ch": 6,
+ "s": ["400", "500", "600", "700", "800", "900"]
+ },
+ "McLaren": { "x": 137, "y": 320, "w": 118, "ch": 6, "s": ["400"] },
+ "Mea Culpa": { "x": 269, "y": 320, "w": 102, "ch": 6, "s": ["400"] },
+ "Meddon": { "x": 385, "y": 320, "w": 129, "ch": 6, "s": ["400"] },
+ "MedievalSharp": { "x": 528, "y": 320, "w": 170, "ch": 6, "s": ["400"] },
+ "Medula One": { "x": 712, "y": 320, "w": 78, "ch": 6, "s": ["400"] },
+ "Meera Inimai": { "x": 804, "y": 320, "w": 138, "ch": 6, "s": ["400"] },
+ "Megrim": { "x": 956, "y": 320, "w": 87, "ch": 6, "s": ["400"] },
+ "Meie Script": { "x": 1057, "y": 320, "w": 133, "ch": 6, "s": ["400"] },
+ "Menbere": {
+ "x": 0,
+ "y": 360,
+ "w": 112,
+ "ch": 6,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "Meow Script": { "x": 126, "y": 360, "w": 119, "ch": 6, "s": ["400"] },
+ "Merienda": {
+ "x": 259,
+ "y": 360,
+ "w": 121,
+ "ch": 6,
+ "s": ["300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Merienda One": { "x": 394, "y": 360, "w": 177, "ch": 6, "s": ["400"] },
+ "Merriweather": {
+ "x": 585,
+ "y": 360,
+ "w": 168,
+ "ch": 6,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Merriweather Sans": {
+ "x": 767,
+ "y": 360,
+ "w": 223,
+ "ch": 6,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Metal": { "x": 1004, "y": 360, "w": 58, "ch": 6, "s": ["400"] },
+ "Metal Mania": { "x": 1076, "y": 360, "w": 121, "ch": 6, "s": ["400"] },
+ "Metamorphous": { "x": 0, "y": 400, "w": 210, "ch": 6, "s": ["400"] },
+ "Metrophobic": { "x": 224, "y": 400, "w": 149, "ch": 6, "s": ["400"] },
+ "Michroma": { "x": 387, "y": 400, "w": 161, "ch": 6, "s": ["400"] },
+ "Micro 5": { "x": 562, "y": 400, "w": 65, "ch": 6, "s": ["400"] },
+ "Micro 5 Charted": { "x": 641, "y": 400, "w": 126, "ch": 6, "s": ["400"] },
+ "Milonga": { "x": 781, "y": 400, "w": 99, "ch": 6, "s": ["400"] },
+ "Miltonian": { "x": 894, "y": 400, "w": 130, "ch": 6, "s": ["400"] },
+ "Miltonian Tattoo": { "x": 0, "y": 440, "w": 214, "ch": 6, "s": ["400"] },
+ "Mina": { "x": 228, "y": 440, "w": 58, "ch": 6, "s": ["400", "700"] },
+ "Mingzat": { "x": 300, "y": 440, "w": 99, "ch": 6, "s": ["400"] },
+ "Miniver": { "x": 413, "y": 440, "w": 89, "ch": 6, "s": ["400"] },
+ "Miriam Libre": {
+ "x": 516,
+ "y": 440,
+ "w": 161,
+ "ch": 6,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Mirza": {
+ "x": 691,
+ "y": 440,
+ "w": 60,
+ "ch": 6,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Miss Fajardose": { "x": 765, "y": 440, "w": 108, "ch": 6, "s": ["400"] },
+ "Mitr": {
+ "x": 887,
+ "y": 440,
+ "w": 55,
+ "ch": 6,
+ "s": ["200", "300", "400", "500", "600", "700"]
+ },
+ "Mochiy Pop One": { "x": 956, "y": 440, "w": 223, "ch": 6, "s": ["400"] },
+ "Mochiy Pop P One": { "x": 0, "y": 480, "w": 248, "ch": 6, "s": ["400"] },
+ "Modak": { "x": 262, "y": 480, "w": 79, "ch": 6, "s": ["400"] },
+ "Modern Antiqua": { "x": 355, "y": 480, "w": 196, "ch": 6, "s": ["400"] },
+ "Moderustic": {
+ "x": 565,
+ "y": 480,
+ "w": 135,
+ "ch": 6,
+ "s": ["300", "400", "500", "600", "700", "800"]
+ },
+ "Mogra": { "x": 714, "y": 480, "w": 80, "ch": 6, "s": ["400"] },
+ "Mohave": {
+ "x": 808,
+ "y": 480,
+ "w": 77,
+ "ch": 6,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Moirai One": { "x": 899, "y": 480, "w": 146, "ch": 6, "s": ["400"] },
+ "Molengo": { "x": 1059, "y": 480, "w": 96, "ch": 6, "s": ["400"] },
+ "Momo Signature": { "x": 0, "y": 520, "w": 227, "ch": 6, "s": ["400"] },
+ "Momo Trust Display": {
+ "x": 241,
+ "y": 520,
+ "w": 249,
+ "ch": 6,
+ "s": ["400"]
+ },
+ "Momo Trust Sans": {
+ "x": 504,
+ "y": 520,
+ "w": 207,
+ "ch": 6,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Mona Sans": {
+ "x": 725,
+ "y": 520,
+ "w": 136,
+ "ch": 6,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Monda": {
+ "x": 875,
+ "y": 520,
+ "w": 85,
+ "ch": 6,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Monofett": { "x": 974, "y": 520, "w": 119, "ch": 6, "s": ["400"] },
+ "Monomakh": { "x": 0, "y": 560, "w": 138, "ch": 6, "s": ["400"] },
+ "Monomaniac One": { "x": 152, "y": 560, "w": 156, "ch": 6, "s": ["400"] },
+ "Monoton": { "x": 322, "y": 560, "w": 143, "ch": 6, "s": ["400"] },
+ "Monsieur La Doulaise": {
+ "x": 479,
+ "y": 560,
+ "w": 215,
+ "ch": 6,
+ "s": ["400"]
+ },
+ "Montaga": { "x": 708, "y": 560, "w": 102, "ch": 6, "s": ["400"] },
+ "Montagu Slab": {
+ "x": 824,
+ "y": 560,
+ "w": 173,
+ "ch": 6,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "MonteCarlo": { "x": 1011, "y": 560, "w": 107, "ch": 6, "s": ["400"] },
+ "Montez": { "x": 1132, "y": 560, "w": 66, "ch": 6, "s": ["400"] },
+ "Montserrat": {
+ "x": 0,
+ "y": 600,
+ "w": 141,
+ "ch": 6,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Montserrat Alternates": {
+ "x": 155,
+ "y": 600,
+ "w": 282,
+ "ch": 6,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Montserrat Subrayada": {
+ "x": 451,
+ "y": 600,
+ "w": 347,
+ "ch": 6,
+ "s": ["400", "700"]
+ },
+ "Montserrat Underline": {
+ "x": 812,
+ "y": 600,
+ "w": 266,
+ "ch": 6,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Moo Lah Lah": { "x": 0, "y": 640, "w": 137, "ch": 6, "s": ["400"] },
+ "Mooli": { "x": 151, "y": 640, "w": 77, "ch": 6, "s": ["400"] },
+ "Moon Dance": { "x": 242, "y": 640, "w": 106, "ch": 6, "s": ["400"] },
+ "Moul": { "x": 362, "y": 640, "w": 82, "ch": 6, "s": ["400"] },
+ "Moulpali": { "x": 458, "y": 640, "w": 90, "ch": 6, "s": ["400"] },
+ "Mountains of Christmas": {
+ "x": 562,
+ "y": 640,
+ "w": 219,
+ "ch": 6,
+ "s": ["400", "700"]
+ },
+ "Mouse Memoirs": { "x": 795, "y": 640, "w": 116, "ch": 6, "s": ["400"] },
+ "Mozilla Headline": {
+ "x": 925,
+ "y": 640,
+ "w": 192,
+ "ch": 6,
+ "s": ["200", "300", "400", "500", "600", "700"]
+ },
+ "Mozilla Text": {
+ "x": 0,
+ "y": 680,
+ "w": 148,
+ "ch": 6,
+ "s": ["200", "300", "400", "500", "600", "700"]
+ },
+ "Mr Bedfort": { "x": 162, "y": 680, "w": 124, "ch": 6, "s": ["400"] },
+ "Mr Dafoe": { "x": 300, "y": 680, "w": 92, "ch": 6, "s": ["400"] },
+ "Mr De Haviland": { "x": 406, "y": 680, "w": 115, "ch": 6, "s": ["400"] },
+ "Mrs Saint Delafield": {
+ "x": 535,
+ "y": 680,
+ "w": 159,
+ "ch": 6,
+ "s": ["400"]
+ },
+ "Mrs Sheppards": { "x": 708, "y": 680, "w": 139, "ch": 6, "s": ["400"] },
+ "Ms Madi": { "x": 861, "y": 680, "w": 89, "ch": 6, "s": ["400"] },
+ "Mukta": {
+ "x": 964,
+ "y": 680,
+ "w": 73,
+ "ch": 6,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Mukta Mahee": {
+ "x": 1051,
+ "y": 680,
+ "w": 146,
+ "ch": 6,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Mukta Malar": {
+ "x": 0,
+ "y": 720,
+ "w": 137,
+ "ch": 6,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Mukta Vaani": {
+ "x": 151,
+ "y": 720,
+ "w": 133,
+ "ch": 6,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Mulish": {
+ "x": 298,
+ "y": 720,
+ "w": 80,
+ "ch": 6,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Murecho": {
+ "x": 392,
+ "y": 720,
+ "w": 102,
+ "ch": 6,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "MuseoModerno": {
+ "x": 508,
+ "y": 720,
+ "w": 199,
+ "ch": 6,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "My Soul": { "x": 721, "y": 720, "w": 107, "ch": 6, "s": ["400"] },
+ "Mynerve": { "x": 842, "y": 720, "w": 104, "ch": 6, "s": ["400"] },
+ "Mystery Quest": { "x": 960, "y": 720, "w": 152, "ch": 6, "s": ["400"] },
+ "Nabla": { "x": 1126, "y": 720, "w": 72, "ch": 6, "s": ["400"] },
+ "Namdhinggo": {
+ "x": 0,
+ "y": 760,
+ "w": 130,
+ "ch": 6,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Nanum Brush Script": {
+ "x": 144,
+ "y": 760,
+ "w": 168,
+ "ch": 6,
+ "s": ["400"]
+ },
+ "Nanum Gothic": {
+ "x": 326,
+ "y": 760,
+ "w": 171,
+ "ch": 6,
+ "s": ["400", "700", "800"]
+ },
+ "Nanum Gothic Coding": {
+ "x": 511,
+ "y": 760,
+ "w": 236,
+ "ch": 6,
+ "s": ["400", "700"]
+ },
+ "Nanum Myeongjo": {
+ "x": 761,
+ "y": 760,
+ "w": 201,
+ "ch": 6,
+ "s": ["400", "700", "800"]
+ },
+ "Nanum Pen Script": { "x": 976, "y": 760, "w": 157, "ch": 6, "s": ["400"] },
+ "Narnoor": {
+ "x": 0,
+ "y": 0,
+ "w": 90,
+ "ch": 7,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Nata Sans": {
+ "x": 104,
+ "y": 0,
+ "w": 123,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "National Park": {
+ "x": 241,
+ "y": 0,
+ "w": 149,
+ "ch": 7,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Neonderthaw": { "x": 404, "y": 0, "w": 139, "ch": 7, "s": ["400"] },
+ "Nerko One": { "x": 557, "y": 0, "w": 107, "ch": 7, "s": ["400"] },
+ "Neucha": { "x": 678, "y": 0, "w": 72, "ch": 7, "s": ["400"] },
+ "Neuton": {
+ "x": 764,
+ "y": 0,
+ "w": 76,
+ "ch": 7,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "New Amsterdam": { "x": 854, "y": 0, "w": 139, "ch": 7, "s": ["400"] },
+ "New Rocker": { "x": 1007, "y": 0, "w": 133, "ch": 7, "s": ["400"] },
+ "New Tegomin": { "x": 0, "y": 40, "w": 159, "ch": 7, "s": ["400"] },
+ "News Cycle": { "x": 173, "y": 40, "w": 116, "ch": 7, "s": ["400", "700"] },
+ "Newsreader": {
+ "x": 303,
+ "y": 40,
+ "w": 129,
+ "ch": 7,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Niconne": { "x": 446, "y": 40, "w": 79, "ch": 7, "s": ["400"] },
+ "Niramit": {
+ "x": 539,
+ "y": 40,
+ "w": 84,
+ "ch": 7,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Nixie One": { "x": 637, "y": 40, "w": 123, "ch": 7, "s": ["400"] },
+ "Nobile": {
+ "x": 774,
+ "y": 40,
+ "w": 82,
+ "ch": 7,
+ "s": ["400", "400i", "500", "500i", "700", "700i"]
+ },
+ "Nokora": {
+ "x": 870,
+ "y": 40,
+ "w": 91,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Norican": { "x": 975, "y": 40, "w": 78, "ch": 7, "s": ["400"] },
+ "Nosifer": { "x": 0, "y": 80, "w": 154, "ch": 7, "s": ["400"] },
+ "Notable": { "x": 168, "y": 80, "w": 144, "ch": 7, "s": ["400"] },
+ "Nothing You Could Do": {
+ "x": 326,
+ "y": 80,
+ "w": 248,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noticia Text": {
+ "x": 588,
+ "y": 80,
+ "w": 142,
+ "ch": 7,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Noto Kufi Arabic": {
+ "x": 744,
+ "y": 80,
+ "w": 196,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Music": { "x": 954, "y": 80, "w": 136, "ch": 7, "s": ["400"] },
+ "Noto Naskh Arabic": {
+ "x": 0,
+ "y": 120,
+ "w": 221,
+ "ch": 7,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Nastaliq Urdu": {
+ "x": 235,
+ "y": 120,
+ "w": 222,
+ "ch": 7,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Rashi Hebrew": {
+ "x": 471,
+ "y": 120,
+ "w": 230,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans": {
+ "x": 715,
+ "y": 120,
+ "w": 124,
+ "ch": 7,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Noto Sans Adlam": {
+ "x": 853,
+ "y": 120,
+ "w": 202,
+ "ch": 7,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Adlam Unjoined": {
+ "x": 0,
+ "y": 160,
+ "w": 310,
+ "ch": 7,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Anatolian Hieroglyphs": {
+ "x": 324,
+ "y": 160,
+ "w": 380,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Arabic": {
+ "x": 718,
+ "y": 160,
+ "w": 201,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Armenian": {
+ "x": 933,
+ "y": 160,
+ "w": 240,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Avestan": { "x": 0, "y": 200, "w": 219, "ch": 7, "s": ["400"] },
+ "Noto Sans Balinese": {
+ "x": 233,
+ "y": 200,
+ "w": 225,
+ "ch": 7,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Bamum": {
+ "x": 472,
+ "y": 200,
+ "w": 219,
+ "ch": 7,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Bassa Vah": {
+ "x": 705,
+ "y": 200,
+ "w": 250,
+ "ch": 7,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Batak": { "x": 969, "y": 200, "w": 194, "ch": 7, "s": ["400"] },
+ "Noto Sans Bengali": {
+ "x": 0,
+ "y": 240,
+ "w": 214,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Bhaiksuki": {
+ "x": 228,
+ "y": 240,
+ "w": 236,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Brahmi": { "x": 478, "y": 240, "w": 212, "ch": 7, "s": ["400"] },
+ "Noto Sans Buginese": {
+ "x": 704,
+ "y": 240,
+ "w": 235,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Buhid": { "x": 953, "y": 240, "w": 196, "ch": 7, "s": ["400"] },
+ "Noto Sans Canadian Aboriginal": {
+ "x": 0,
+ "y": 280,
+ "w": 361,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Carian": { "x": 375, "y": 280, "w": 203, "ch": 7, "s": ["400"] },
+ "Noto Sans Caucasian Albanian": {
+ "x": 592,
+ "y": 280,
+ "w": 353,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Chakma": { "x": 959, "y": 280, "w": 222, "ch": 7, "s": ["400"] },
+ "Noto Sans Cham": {
+ "x": 0,
+ "y": 320,
+ "w": 196,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Cherokee": {
+ "x": 210,
+ "y": 320,
+ "w": 238,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Chorasmian": {
+ "x": 462,
+ "y": 320,
+ "w": 268,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Coptic": { "x": 744, "y": 320, "w": 201, "ch": 7, "s": ["400"] },
+ "Noto Sans Cuneiform": {
+ "x": 0,
+ "y": 360,
+ "w": 250,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Cypriot": {
+ "x": 264,
+ "y": 360,
+ "w": 211,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Cypro Minoan": {
+ "x": 489,
+ "y": 360,
+ "w": 288,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Deseret": {
+ "x": 791,
+ "y": 360,
+ "w": 218,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Devanagari": {
+ "x": 0,
+ "y": 400,
+ "w": 259,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Display": {
+ "x": 273,
+ "y": 400,
+ "w": 200,
+ "ch": 7,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Noto Sans Duployan": {
+ "x": 487,
+ "y": 400,
+ "w": 238,
+ "ch": 7,
+ "s": ["400", "700"]
+ },
+ "Noto Sans Egyptian Hieroglyphs": {
+ "x": 739,
+ "y": 400,
+ "w": 369,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Elbasan": { "x": 0, "y": 440, "w": 217, "ch": 7, "s": ["400"] },
+ "Noto Sans Elymaic": {
+ "x": 231,
+ "y": 440,
+ "w": 222,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Ethiopic": {
+ "x": 467,
+ "y": 440,
+ "w": 220,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Georgian": {
+ "x": 701,
+ "y": 440,
+ "w": 234,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Glagolitic": {
+ "x": 949,
+ "y": 440,
+ "w": 235,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Gothic": { "x": 0, "y": 480, "w": 203, "ch": 7, "s": ["400"] },
+ "Noto Sans Grantha": {
+ "x": 217,
+ "y": 480,
+ "w": 222,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Gujarati": {
+ "x": 453,
+ "y": 480,
+ "w": 220,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Gunjala Gondi": {
+ "x": 687,
+ "y": 480,
+ "w": 291,
+ "ch": 7,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Gurmukhi": {
+ "x": 0,
+ "y": 520,
+ "w": 243,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Hanifi Rohingya": {
+ "x": 257,
+ "y": 520,
+ "w": 309,
+ "ch": 7,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Hanunoo": {
+ "x": 580,
+ "y": 520,
+ "w": 235,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Hatran": { "x": 829, "y": 520, "w": 208, "ch": 7, "s": ["400"] },
+ "Noto Sans Hebrew": {
+ "x": 0,
+ "y": 560,
+ "w": 217,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans HK": {
+ "x": 231,
+ "y": 560,
+ "w": 160,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Imperial Aramaic": {
+ "x": 405,
+ "y": 560,
+ "w": 323,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Indic Siyaq Numbers": {
+ "x": 742,
+ "y": 560,
+ "w": 351,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Inscriptional Pahlavi": {
+ "x": 0,
+ "y": 600,
+ "w": 358,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Inscriptional Parthian": {
+ "x": 372,
+ "y": 600,
+ "w": 373,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Javanese": {
+ "x": 759,
+ "y": 600,
+ "w": 229,
+ "ch": 7,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans JP": {
+ "x": 1002,
+ "y": 600,
+ "w": 155,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Kaithi": { "x": 0, "y": 640, "w": 195, "ch": 7, "s": ["400"] },
+ "Noto Sans Kannada": {
+ "x": 209,
+ "y": 640,
+ "w": 231,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Kawi": {
+ "x": 454,
+ "y": 640,
+ "w": 180,
+ "ch": 7,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Kayah Li": {
+ "x": 648,
+ "y": 640,
+ "w": 224,
+ "ch": 7,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Kharoshthi": {
+ "x": 886,
+ "y": 640,
+ "w": 253,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Khmer": {
+ "x": 0,
+ "y": 680,
+ "w": 205,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Khojki": { "x": 219, "y": 680, "w": 199, "ch": 7, "s": ["400"] },
+ "Noto Sans Khudawadi": {
+ "x": 432,
+ "y": 680,
+ "w": 256,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans KR": {
+ "x": 702,
+ "y": 680,
+ "w": 158,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Lao": {
+ "x": 874,
+ "y": 680,
+ "w": 170,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Lao Looped": {
+ "x": 0,
+ "y": 720,
+ "w": 261,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Lepcha": { "x": 275, "y": 720, "w": 211, "ch": 7, "s": ["400"] },
+ "Noto Sans Limbu": { "x": 500, "y": 720, "w": 201, "ch": 7, "s": ["400"] },
+ "Noto Sans Linear A": {
+ "x": 715,
+ "y": 720,
+ "w": 222,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Linear B": {
+ "x": 951,
+ "y": 720,
+ "w": 222,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Lisu": {
+ "x": 0,
+ "y": 760,
+ "w": 175,
+ "ch": 7,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Lydian": { "x": 189, "y": 760, "w": 204, "ch": 7, "s": ["400"] },
+ "Noto Sans Mahajani": {
+ "x": 407,
+ "y": 760,
+ "w": 234,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Malayalam": {
+ "x": 655,
+ "y": 760,
+ "w": 255,
+ "ch": 7,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Mandaic": {
+ "x": 924,
+ "y": 760,
+ "w": 226,
+ "ch": 7,
+ "s": ["400"]
+ },
+ "Noto Sans Manichaean": { "x": 0, "y": 0, "w": 268, "ch": 8, "s": ["400"] },
+ "Noto Sans Marchen": { "x": 282, "y": 0, "w": 227, "ch": 8, "s": ["400"] },
+ "Noto Sans Masaram Gondi": {
+ "x": 523,
+ "y": 0,
+ "w": 318,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Math": { "x": 855, "y": 0, "w": 189, "ch": 8, "s": ["400"] },
+ "Noto Sans Mayan Numerals": {
+ "x": 0,
+ "y": 40,
+ "w": 322,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Medefaidrin": {
+ "x": 336,
+ "y": 40,
+ "w": 267,
+ "ch": 8,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Meetei Mayek": {
+ "x": 617,
+ "y": 40,
+ "w": 289,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Mende Kikakui": {
+ "x": 0,
+ "y": 80,
+ "w": 296,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Meroitic": {
+ "x": 310,
+ "y": 80,
+ "w": 222,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Miao": { "x": 546, "y": 80, "w": 186, "ch": 8, "s": ["400"] },
+ "Noto Sans Modi": { "x": 746, "y": 80, "w": 187, "ch": 8, "s": ["400"] },
+ "Noto Sans Mongolian": {
+ "x": 947,
+ "y": 80,
+ "w": 251,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Mono": {
+ "x": 0,
+ "y": 120,
+ "w": 210,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Mro": { "x": 224, "y": 120, "w": 176, "ch": 8, "s": ["400"] },
+ "Noto Sans Multani": {
+ "x": 414,
+ "y": 120,
+ "w": 216,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Myanmar": {
+ "x": 644,
+ "y": 120,
+ "w": 238,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Nabataean": {
+ "x": 896,
+ "y": 120,
+ "w": 254,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Nag Mundari": {
+ "x": 0,
+ "y": 160,
+ "w": 274,
+ "ch": 8,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Nandinagari": {
+ "x": 288,
+ "y": 160,
+ "w": 272,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans New Tai Lue": {
+ "x": 574,
+ "y": 160,
+ "w": 265,
+ "ch": 8,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Newa": { "x": 853, "y": 160, "w": 194, "ch": 8, "s": ["400"] },
+ "Noto Sans NKo": { "x": 0, "y": 200, "w": 178, "ch": 8, "s": ["400"] },
+ "Noto Sans NKo Unjoined": {
+ "x": 192,
+ "y": 200,
+ "w": 286,
+ "ch": 8,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Nushu": { "x": 492, "y": 200, "w": 204, "ch": 8, "s": ["400"] },
+ "Noto Sans Ogham": { "x": 710, "y": 200, "w": 214, "ch": 8, "s": ["400"] },
+ "Noto Sans Ol Chiki": {
+ "x": 938,
+ "y": 200,
+ "w": 216,
+ "ch": 8,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Old Hungarian": {
+ "x": 0,
+ "y": 240,
+ "w": 296,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Old Italic": {
+ "x": 310,
+ "y": 240,
+ "w": 230,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Old North Arabian": {
+ "x": 554,
+ "y": 240,
+ "w": 336,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Old Permic": {
+ "x": 904,
+ "y": 240,
+ "w": 253,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Old Persian": {
+ "x": 0,
+ "y": 280,
+ "w": 260,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Old Sogdian": {
+ "x": 274,
+ "y": 280,
+ "w": 260,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Old South Arabian": {
+ "x": 548,
+ "y": 280,
+ "w": 336,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Old Turkic": {
+ "x": 898,
+ "y": 280,
+ "w": 243,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Oriya": {
+ "x": 0,
+ "y": 320,
+ "w": 190,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Osage": { "x": 204, "y": 320, "w": 202, "ch": 8, "s": ["400"] },
+ "Noto Sans Osmanya": {
+ "x": 420,
+ "y": 320,
+ "w": 238,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Pahawh Hmong": {
+ "x": 672,
+ "y": 320,
+ "w": 310,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Palmyrene": {
+ "x": 0,
+ "y": 360,
+ "w": 251,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Pau Cin Hau": {
+ "x": 265,
+ "y": 360,
+ "w": 269,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans PhagsPa": {
+ "x": 548,
+ "y": 360,
+ "w": 227,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Phoenician": {
+ "x": 789,
+ "y": 360,
+ "w": 254,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Psalter Pahlavi": {
+ "x": 0,
+ "y": 400,
+ "w": 295,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Rejang": { "x": 309, "y": 400, "w": 208, "ch": 8, "s": ["400"] },
+ "Noto Sans Runic": { "x": 531, "y": 400, "w": 192, "ch": 8, "s": ["400"] },
+ "Noto Sans Samaritan": {
+ "x": 737,
+ "y": 400,
+ "w": 245,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Saurashtra": {
+ "x": 0,
+ "y": 440,
+ "w": 254,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans SC": {
+ "x": 268,
+ "y": 440,
+ "w": 157,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Sharada": {
+ "x": 439,
+ "y": 440,
+ "w": 222,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Shavian": {
+ "x": 675,
+ "y": 440,
+ "w": 218,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Siddham": {
+ "x": 907,
+ "y": 440,
+ "w": 227,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans SignWriting": {
+ "x": 0,
+ "y": 480,
+ "w": 297,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Sinhala": {
+ "x": 311,
+ "y": 480,
+ "w": 224,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Sogdian": {
+ "x": 549,
+ "y": 480,
+ "w": 216,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Sora Sompeng": {
+ "x": 779,
+ "y": 480,
+ "w": 295,
+ "ch": 8,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Soyombo": { "x": 0, "y": 520, "w": 236, "ch": 8, "s": ["400"] },
+ "Noto Sans Sundanese": {
+ "x": 250,
+ "y": 520,
+ "w": 254,
+ "ch": 8,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Sunuwar": {
+ "x": 518,
+ "y": 520,
+ "w": 237,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Syloti Nagri": {
+ "x": 769,
+ "y": 520,
+ "w": 260,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Symbols": {
+ "x": 0,
+ "y": 560,
+ "w": 225,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Symbols 2": {
+ "x": 239,
+ "y": 560,
+ "w": 257,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Syriac": {
+ "x": 510,
+ "y": 560,
+ "w": 196,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Syriac Eastern": {
+ "x": 720,
+ "y": 560,
+ "w": 288,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Syriac Western": {
+ "x": 0,
+ "y": 600,
+ "w": 296,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Tagalog": {
+ "x": 310,
+ "y": 600,
+ "w": 218,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Tagbanwa": {
+ "x": 542,
+ "y": 600,
+ "w": 247,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Tai Le": { "x": 803, "y": 600, "w": 193, "ch": 8, "s": ["400"] },
+ "Noto Sans Tai Tham": {
+ "x": 0,
+ "y": 640,
+ "w": 243,
+ "ch": 8,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Tai Viet": {
+ "x": 257,
+ "y": 640,
+ "w": 210,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Takri": { "x": 481, "y": 640, "w": 184, "ch": 8, "s": ["400"] },
+ "Noto Sans Tamil": {
+ "x": 679,
+ "y": 640,
+ "w": 190,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Tamil Supplement": {
+ "x": 0,
+ "y": 680,
+ "w": 334,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Tangsa": {
+ "x": 348,
+ "y": 680,
+ "w": 209,
+ "ch": 8,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans TC": {
+ "x": 571,
+ "y": 680,
+ "w": 156,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Telugu": {
+ "x": 741,
+ "y": 680,
+ "w": 207,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Thaana": {
+ "x": 962,
+ "y": 680,
+ "w": 213,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Thai": {
+ "x": 0,
+ "y": 720,
+ "w": 178,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Thai Looped": {
+ "x": 192,
+ "y": 720,
+ "w": 269,
+ "ch": 8,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Sans Tifinagh": {
+ "x": 475,
+ "y": 720,
+ "w": 222,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Tirhuta": {
+ "x": 711,
+ "y": 720,
+ "w": 211,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Ugaritic": {
+ "x": 936,
+ "y": 720,
+ "w": 218,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Vai": { "x": 0, "y": 760, "w": 164, "ch": 8, "s": ["400"] },
+ "Noto Sans Vithkuqi": {
+ "x": 178,
+ "y": 760,
+ "w": 223,
+ "ch": 8,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Sans Wancho": { "x": 415, "y": 760, "w": 228, "ch": 8, "s": ["400"] },
+ "Noto Sans Warang Citi": {
+ "x": 657,
+ "y": 760,
+ "w": 264,
+ "ch": 8,
+ "s": ["400"]
+ },
+ "Noto Sans Yi": { "x": 935, "y": 760, "w": 149, "ch": 8, "s": ["400"] },
+ "Noto Sans Zanabazar Square": {
+ "x": 0,
+ "y": 0,
+ "w": 334,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Noto Serif": {
+ "x": 348,
+ "y": 0,
+ "w": 123,
+ "ch": 9,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Noto Serif Ahom": { "x": 485, "y": 0, "w": 196, "ch": 9, "s": ["400"] },
+ "Noto Serif Armenian": {
+ "x": 695,
+ "y": 0,
+ "w": 245,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Balinese": {
+ "x": 954,
+ "y": 0,
+ "w": 225,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Noto Serif Bengali": {
+ "x": 0,
+ "y": 40,
+ "w": 215,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Devanagari": {
+ "x": 229,
+ "y": 40,
+ "w": 261,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Display": {
+ "x": 504,
+ "y": 40,
+ "w": 212,
+ "ch": 9,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Noto Serif Dives Akuru": {
+ "x": 730,
+ "y": 40,
+ "w": 274,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Noto Serif Dogra": { "x": 0, "y": 80, "w": 197, "ch": 9, "s": ["400"] },
+ "Noto Serif Ethiopic": {
+ "x": 211,
+ "y": 80,
+ "w": 224,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Georgian": {
+ "x": 449,
+ "y": 80,
+ "w": 234,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Grantha": {
+ "x": 697,
+ "y": 80,
+ "w": 223,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Noto Serif Gujarati": {
+ "x": 934,
+ "y": 80,
+ "w": 225,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Gurmukhi": {
+ "x": 0,
+ "y": 120,
+ "w": 248,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Hebrew": {
+ "x": 262,
+ "y": 120,
+ "w": 221,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Hentaigana": {
+ "x": 497,
+ "y": 120,
+ "w": 269,
+ "ch": 9,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif HK": {
+ "x": 780,
+ "y": 120,
+ "w": 170,
+ "ch": 9,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif JP": {
+ "x": 964,
+ "y": 120,
+ "w": 157,
+ "ch": 9,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Kannada": {
+ "x": 0,
+ "y": 160,
+ "w": 231,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Khitan Small Script": {
+ "x": 245,
+ "y": 160,
+ "w": 383,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Noto Serif Khmer": {
+ "x": 642,
+ "y": 160,
+ "w": 208,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Khojki": {
+ "x": 864,
+ "y": 160,
+ "w": 204,
+ "ch": 9,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Serif KR": {
+ "x": 0,
+ "y": 200,
+ "w": 167,
+ "ch": 9,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Lao": {
+ "x": 181,
+ "y": 200,
+ "w": 171,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Makasar": {
+ "x": 366,
+ "y": 200,
+ "w": 226,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Noto Serif Malayalam": {
+ "x": 606,
+ "y": 200,
+ "w": 259,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif NP Hmong": {
+ "x": 879,
+ "y": 200,
+ "w": 252,
+ "ch": 9,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Serif Old Uyghur": {
+ "x": 0,
+ "y": 240,
+ "w": 257,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Noto Serif Oriya": {
+ "x": 271,
+ "y": 240,
+ "w": 200,
+ "ch": 9,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Serif Ottoman Siyaq": {
+ "x": 485,
+ "y": 240,
+ "w": 298,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Noto Serif SC": {
+ "x": 797,
+ "y": 240,
+ "w": 162,
+ "ch": 9,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Sinhala": {
+ "x": 973,
+ "y": 240,
+ "w": 227,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Tamil": {
+ "x": 0,
+ "y": 280,
+ "w": 194,
+ "ch": 9,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Noto Serif Tangut": {
+ "x": 208,
+ "y": 280,
+ "w": 220,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Noto Serif TC": {
+ "x": 442,
+ "y": 280,
+ "w": 164,
+ "ch": 9,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Telugu": {
+ "x": 620,
+ "y": 280,
+ "w": 207,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Thai": {
+ "x": 841,
+ "y": 280,
+ "w": 180,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Tibetan": {
+ "x": 0,
+ "y": 320,
+ "w": 217,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Noto Serif Todhri": {
+ "x": 231,
+ "y": 320,
+ "w": 201,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Noto Serif Toto": {
+ "x": 446,
+ "y": 320,
+ "w": 179,
+ "ch": 9,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Serif Vithkuqi": {
+ "x": 639,
+ "y": 320,
+ "w": 228,
+ "ch": 9,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Serif Yezidi": {
+ "x": 881,
+ "y": 320,
+ "w": 198,
+ "ch": 9,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Noto Traditional Nushu": {
+ "x": 0,
+ "y": 360,
+ "w": 289,
+ "ch": 9,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Noto Znamenny Musical Notation": {
+ "x": 303,
+ "y": 360,
+ "w": 388,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Nova Cut": { "x": 705, "y": 360, "w": 108, "ch": 9, "s": ["400"] },
+ "Nova Flat": { "x": 827, "y": 360, "w": 112, "ch": 9, "s": ["400"] },
+ "Nova Mono": { "x": 953, "y": 360, "w": 130, "ch": 9, "s": ["400"] },
+ "Nova Oval": { "x": 0, "y": 400, "w": 123, "ch": 9, "s": ["400"] },
+ "Nova Round": { "x": 137, "y": 400, "w": 140, "ch": 9, "s": ["400"] },
+ "Nova Script": { "x": 291, "y": 400, "w": 140, "ch": 9, "s": ["400"] },
+ "Nova Slim": { "x": 445, "y": 400, "w": 121, "ch": 9, "s": ["400"] },
+ "Nova Square": { "x": 580, "y": 400, "w": 148, "ch": 9, "s": ["400"] },
+ "NTR": { "x": 742, "y": 400, "w": 49, "ch": 9, "s": ["400"] },
+ "Numans": { "x": 805, "y": 400, "w": 113, "ch": 9, "s": ["400"] },
+ "Nunito": {
+ "x": 932,
+ "y": 400,
+ "w": 81,
+ "ch": 9,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Nunito Sans": {
+ "x": 1027,
+ "y": 400,
+ "w": 139,
+ "ch": 9,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Nuosu SIL": { "x": 0, "y": 440, "w": 117, "ch": 9, "s": ["400"] },
+ "Odibee Sans": { "x": 131, "y": 440, "w": 95, "ch": 9, "s": ["400"] },
+ "Odor Mean Chey": { "x": 240, "y": 440, "w": 184, "ch": 9, "s": ["400"] },
+ "Offside": { "x": 438, "y": 440, "w": 96, "ch": 9, "s": ["400"] },
+ "Oi": { "x": 548, "y": 440, "w": 47, "ch": 9, "s": ["400"] },
+ "Ojuju": {
+ "x": 609,
+ "y": 440,
+ "w": 62,
+ "ch": 9,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Old Standard TT": {
+ "x": 685,
+ "y": 440,
+ "w": 187,
+ "ch": 9,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Oldenburg": { "x": 886, "y": 440, "w": 136, "ch": 9, "s": ["400"] },
+ "Ole": { "x": 1036, "y": 440, "w": 33, "ch": 9, "s": ["400"] },
+ "Oleo Script": {
+ "x": 1083,
+ "y": 440,
+ "w": 109,
+ "ch": 9,
+ "s": ["400", "700"]
+ },
+ "Oleo Script Swash Caps": {
+ "x": 0,
+ "y": 480,
+ "w": 227,
+ "ch": 9,
+ "s": ["400", "700"]
+ },
+ "Onest": {
+ "x": 241,
+ "y": 480,
+ "w": 76,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Oooh Baby": { "x": 331, "y": 480, "w": 109, "ch": 9, "s": ["400"] },
+ "Open Sans": {
+ "x": 454,
+ "y": 480,
+ "w": 129,
+ "ch": 9,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Oranienbaum": { "x": 597, "y": 480, "w": 137, "ch": 9, "s": ["400"] },
+ "Orbit": { "x": 748, "y": 480, "w": 80, "ch": 9, "s": ["400"] },
+ "Orbitron": {
+ "x": 842,
+ "y": 480,
+ "w": 117,
+ "ch": 9,
+ "s": ["400", "500", "600", "700", "800", "900"]
+ },
+ "Oregano": { "x": 973, "y": 480, "w": 78, "ch": 9, "s": ["400", "400i"] },
+ "Orelega One": { "x": 0, "y": 520, "w": 138, "ch": 9, "s": ["400"] },
+ "Orienta": { "x": 152, "y": 520, "w": 94, "ch": 9, "s": ["400"] },
+ "Original Surfer": { "x": 260, "y": 520, "w": 175, "ch": 9, "s": ["400"] },
+ "Oswald": {
+ "x": 449,
+ "y": 520,
+ "w": 70,
+ "ch": 9,
+ "s": ["200", "300", "400", "500", "600", "700"]
+ },
+ "Outfit": {
+ "x": 533,
+ "y": 520,
+ "w": 71,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Over the Rainbow": { "x": 618, "y": 520, "w": 189, "ch": 9, "s": ["400"] },
+ "Overlock": {
+ "x": 821,
+ "y": 520,
+ "w": 95,
+ "ch": 9,
+ "s": ["400", "400i", "700", "700i", "900", "900i"]
+ },
+ "Overlock SC": { "x": 930, "y": 520, "w": 139, "ch": 9, "s": ["400"] },
+ "Overpass": {
+ "x": 1083,
+ "y": 520,
+ "w": 109,
+ "ch": 9,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Overpass Mono": {
+ "x": 0,
+ "y": 560,
+ "w": 201,
+ "ch": 9,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Ovo": { "x": 215, "y": 560, "w": 52, "ch": 9, "s": ["400"] },
+ "Oxanium": {
+ "x": 281,
+ "y": 560,
+ "w": 106,
+ "ch": 9,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Oxygen": {
+ "x": 401,
+ "y": 560,
+ "w": 92,
+ "ch": 9,
+ "s": ["300", "400", "700"]
+ },
+ "Oxygen Mono": { "x": 507, "y": 560, "w": 167, "ch": 9, "s": ["400"] },
+ "Pacifico": { "x": 688, "y": 560, "w": 87, "ch": 9, "s": ["400"] },
+ "Padauk": { "x": 789, "y": 560, "w": 79, "ch": 9, "s": ["400", "700"] },
+ "Padyakke Expanded One": {
+ "x": 0,
+ "y": 600,
+ "w": 415,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Palanquin": {
+ "x": 429,
+ "y": 600,
+ "w": 111,
+ "ch": 9,
+ "s": ["100", "200", "300", "400", "500", "600", "700"]
+ },
+ "Palanquin Dark": {
+ "x": 554,
+ "y": 600,
+ "w": 172,
+ "ch": 9,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Palette Mosaic": { "x": 740, "y": 600, "w": 212, "ch": 9, "s": ["400"] },
+ "Pangolin": { "x": 966, "y": 600, "w": 93, "ch": 9, "s": ["400"] },
+ "Paprika": { "x": 1073, "y": 600, "w": 105, "ch": 9, "s": ["400"] },
+ "Parastoo": {
+ "x": 0,
+ "y": 640,
+ "w": 82,
+ "ch": 9,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Parisienne": { "x": 96, "y": 640, "w": 103, "ch": 9, "s": ["400"] },
+ "Parkinsans": {
+ "x": 213,
+ "y": 640,
+ "w": 139,
+ "ch": 9,
+ "s": ["300", "400", "500", "600", "700", "800"]
+ },
+ "Passero One": { "x": 366, "y": 640, "w": 125, "ch": 9, "s": ["400"] },
+ "Passion One": {
+ "x": 505,
+ "y": 640,
+ "w": 114,
+ "ch": 9,
+ "s": ["400", "700", "900"]
+ },
+ "Passions Conflict": {
+ "x": 633,
+ "y": 640,
+ "w": 109,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Pathway Extreme": {
+ "x": 756,
+ "y": 640,
+ "w": 214,
+ "ch": 9,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Pathway Gothic One": {
+ "x": 984,
+ "y": 640,
+ "w": 154,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Patrick Hand": { "x": 0, "y": 680, "w": 118, "ch": 9, "s": ["400"] },
+ "Patrick Hand SC": { "x": 132, "y": 680, "w": 154, "ch": 9, "s": ["400"] },
+ "Pattaya": { "x": 300, "y": 680, "w": 85, "ch": 9, "s": ["400"] },
+ "Patua One": { "x": 399, "y": 680, "w": 117, "ch": 9, "s": ["400"] },
+ "Pavanam": { "x": 530, "y": 680, "w": 97, "ch": 9, "s": ["400"] },
+ "Paytone One": { "x": 641, "y": 680, "w": 164, "ch": 9, "s": ["400"] },
+ "Peddana": { "x": 819, "y": 680, "w": 67, "ch": 9, "s": ["400"] },
+ "Peralta": { "x": 900, "y": 680, "w": 108, "ch": 9, "s": ["400"] },
+ "Permanent Marker": { "x": 0, "y": 720, "w": 234, "ch": 9, "s": ["400"] },
+ "Petemoss": { "x": 248, "y": 720, "w": 60, "ch": 9, "s": ["400"] },
+ "Petit Formal Script": {
+ "x": 322,
+ "y": 720,
+ "w": 255,
+ "ch": 9,
+ "s": ["400"]
+ },
+ "Petrona": {
+ "x": 591,
+ "y": 720,
+ "w": 89,
+ "ch": 9,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Philosopher": {
+ "x": 694,
+ "y": 720,
+ "w": 129,
+ "ch": 9,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Phudu": {
+ "x": 837,
+ "y": 720,
+ "w": 76,
+ "ch": 9,
+ "s": ["300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Piazzolla": {
+ "x": 927,
+ "y": 720,
+ "w": 107,
+ "ch": 9,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Piedra": { "x": 1048, "y": 720, "w": 68, "ch": 9, "s": ["400"] },
+ "Pinyon Script": { "x": 0, "y": 760, "w": 128, "ch": 9, "s": ["400"] },
+ "Pirata One": { "x": 142, "y": 760, "w": 92, "ch": 9, "s": ["400"] },
+ "Pixelify Sans": {
+ "x": 248,
+ "y": 760,
+ "w": 153,
+ "ch": 9,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Plaster": { "x": 415, "y": 760, "w": 126, "ch": 9, "s": ["400"] },
+ "Platypi": {
+ "x": 555,
+ "y": 760,
+ "w": 90,
+ "ch": 9,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Play": { "x": 659, "y": 760, "w": 52, "ch": 9, "s": ["400", "700"] },
+ "Playball": { "x": 725, "y": 760, "w": 83, "ch": 9, "s": ["400"] },
+ "Playfair": {
+ "x": 822,
+ "y": 760,
+ "w": 90,
+ "ch": 9,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Playfair Display": {
+ "x": 926,
+ "y": 760,
+ "w": 176,
+ "ch": 9,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Playfair Display SC": {
+ "x": 0,
+ "y": 0,
+ "w": 247,
+ "ch": 10,
+ "s": ["400", "400i", "700", "700i", "900", "900i"]
+ },
+ "Playpen Sans": {
+ "x": 261,
+ "y": 0,
+ "w": 161,
+ "ch": 10,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Playpen Sans Arabic": {
+ "x": 436,
+ "y": 0,
+ "w": 247,
+ "ch": 10,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Playpen Sans Deva": {
+ "x": 697,
+ "y": 0,
+ "w": 227,
+ "ch": 10,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Playpen Sans Hebrew": {
+ "x": 938,
+ "y": 0,
+ "w": 255,
+ "ch": 10,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Playpen Sans Thai": {
+ "x": 0,
+ "y": 40,
+ "w": 220,
+ "ch": 10,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Playwrite AR": {
+ "x": 234,
+ "y": 40,
+ "w": 199,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite AR Guides": {
+ "x": 447,
+ "y": 40,
+ "w": 294,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite AT": {
+ "x": 755,
+ "y": 40,
+ "w": 180,
+ "ch": 10,
+ "s": ["100", "100i", "200", "200i", "300", "300i", "400", "400i"]
+ },
+ "Playwrite AT Guides": {
+ "x": 0,
+ "y": 80,
+ "w": 273,
+ "ch": 10,
+ "s": ["400", "400i"]
+ },
+ "Playwrite AU NSW": {
+ "x": 287,
+ "y": 80,
+ "w": 257,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite AU NSW Guides": {
+ "x": 558,
+ "y": 80,
+ "w": 350,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite AU QLD": {
+ "x": 922,
+ "y": 80,
+ "w": 249,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite AU QLD Guides": {
+ "x": 0,
+ "y": 120,
+ "w": 344,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite AU SA": {
+ "x": 358,
+ "y": 120,
+ "w": 217,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite AU SA Guides": {
+ "x": 589,
+ "y": 120,
+ "w": 312,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite AU TAS": {
+ "x": 915,
+ "y": 120,
+ "w": 235,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite AU TAS Guides": {
+ "x": 0,
+ "y": 160,
+ "w": 329,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite AU VIC": {
+ "x": 343,
+ "y": 160,
+ "w": 234,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite AU VIC Guides": {
+ "x": 591,
+ "y": 160,
+ "w": 327,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite BE VLG": {
+ "x": 932,
+ "y": 160,
+ "w": 266,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite BE VLG Guides": {
+ "x": 0,
+ "y": 200,
+ "w": 359,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite BE WAL": {
+ "x": 373,
+ "y": 200,
+ "w": 319,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite BE WAL Guides": {
+ "x": 706,
+ "y": 200,
+ "w": 420,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite BR": {
+ "x": 0,
+ "y": 240,
+ "w": 201,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite BR Guides": {
+ "x": 215,
+ "y": 240,
+ "w": 298,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite CA": {
+ "x": 527,
+ "y": 240,
+ "w": 191,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite CA Guides": {
+ "x": 732,
+ "y": 240,
+ "w": 288,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite CL": {
+ "x": 0,
+ "y": 280,
+ "w": 194,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite CL Guides": {
+ "x": 208,
+ "y": 280,
+ "w": 290,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite CO": {
+ "x": 512,
+ "y": 280,
+ "w": 189,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite CO Guides": {
+ "x": 715,
+ "y": 280,
+ "w": 287,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite CU": {
+ "x": 0,
+ "y": 320,
+ "w": 192,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite CU Guides": {
+ "x": 206,
+ "y": 320,
+ "w": 289,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite CZ": {
+ "x": 509,
+ "y": 320,
+ "w": 192,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite CZ Guides": {
+ "x": 715,
+ "y": 320,
+ "w": 287,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite DE Grund": {
+ "x": 0,
+ "y": 360,
+ "w": 251,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite DE Grund Guides": {
+ "x": 265,
+ "y": 360,
+ "w": 342,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite DE LA": {
+ "x": 621,
+ "y": 360,
+ "w": 232,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite DE LA Guides": {
+ "x": 867,
+ "y": 360,
+ "w": 325,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite DE SAS": {
+ "x": 0,
+ "y": 400,
+ "w": 241,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite DE SAS Guides": {
+ "x": 255,
+ "y": 400,
+ "w": 333,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite DE VA": {
+ "x": 602,
+ "y": 400,
+ "w": 225,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite DE VA Guides": {
+ "x": 841,
+ "y": 400,
+ "w": 320,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite DK Loopet": {
+ "x": 0,
+ "y": 440,
+ "w": 254,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite DK Loopet Guides": {
+ "x": 268,
+ "y": 440,
+ "w": 345,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite DK Uloopet": {
+ "x": 627,
+ "y": 440,
+ "w": 262,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite DK Uloopet Guides": {
+ "x": 0,
+ "y": 480,
+ "w": 353,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite ES": {
+ "x": 367,
+ "y": 480,
+ "w": 176,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite ES Deco": {
+ "x": 557,
+ "y": 480,
+ "w": 256,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite ES Deco Guides": {
+ "x": 827,
+ "y": 480,
+ "w": 350,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite ES Guides": {
+ "x": 0,
+ "y": 520,
+ "w": 268,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite FR Moderne": {
+ "x": 282,
+ "y": 520,
+ "w": 287,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite FR Moderne Guides": {
+ "x": 583,
+ "y": 520,
+ "w": 383,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite FR Trad": {
+ "x": 0,
+ "y": 560,
+ "w": 281,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite FR Trad Guides": {
+ "x": 295,
+ "y": 560,
+ "w": 384,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite GB J": {
+ "x": 693,
+ "y": 560,
+ "w": 191,
+ "ch": 10,
+ "s": ["100", "100i", "200", "200i", "300", "300i", "400", "400i"]
+ },
+ "Playwrite GB J Guides": {
+ "x": 898,
+ "y": 560,
+ "w": 284,
+ "ch": 10,
+ "s": ["400", "400i"]
+ },
+ "Playwrite GB S": {
+ "x": 0,
+ "y": 600,
+ "w": 190,
+ "ch": 10,
+ "s": ["100", "100i", "200", "200i", "300", "300i", "400", "400i"]
+ },
+ "Playwrite GB S Guides": {
+ "x": 204,
+ "y": 600,
+ "w": 282,
+ "ch": 10,
+ "s": ["400", "400i"]
+ },
+ "Playwrite HR": {
+ "x": 500,
+ "y": 600,
+ "w": 191,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite HR Guides": {
+ "x": 705,
+ "y": 600,
+ "w": 286,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite HR Lijeva": {
+ "x": 0,
+ "y": 640,
+ "w": 276,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite HR Lijeva Guides": {
+ "x": 290,
+ "y": 640,
+ "w": 370,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite HU": {
+ "x": 674,
+ "y": 640,
+ "w": 190,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite HU Guides": {
+ "x": 878,
+ "y": 640,
+ "w": 285,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite ID": {
+ "x": 0,
+ "y": 680,
+ "w": 194,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite ID Guides": {
+ "x": 208,
+ "y": 680,
+ "w": 296,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite IE": {
+ "x": 518,
+ "y": 680,
+ "w": 175,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite IE Guides": {
+ "x": 707,
+ "y": 680,
+ "w": 271,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite IN": {
+ "x": 992,
+ "y": 680,
+ "w": 189,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite IN Guides": {
+ "x": 0,
+ "y": 720,
+ "w": 285,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite IS": {
+ "x": 299,
+ "y": 720,
+ "w": 156,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite IS Guides": {
+ "x": 469,
+ "y": 720,
+ "w": 249,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite IT Moderna": {
+ "x": 732,
+ "y": 720,
+ "w": 273,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite IT Moderna Guides": {
+ "x": 0,
+ "y": 760,
+ "w": 365,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite IT Trad": {
+ "x": 379,
+ "y": 760,
+ "w": 243,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite IT Trad Guides": {
+ "x": 636,
+ "y": 760,
+ "w": 336,
+ "ch": 10,
+ "s": ["400"]
+ },
+ "Playwrite MX": {
+ "x": 986,
+ "y": 760,
+ "w": 200,
+ "ch": 10,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite MX Guides": { "x": 0, "y": 0, "w": 297, "ch": 11, "s": ["400"] },
+ "Playwrite NG Modern": {
+ "x": 311,
+ "y": 0,
+ "w": 279,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite NG Modern Guides": {
+ "x": 604,
+ "y": 0,
+ "w": 373,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Playwrite NL": {
+ "x": 991,
+ "y": 0,
+ "w": 207,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite NL Guides": {
+ "x": 0,
+ "y": 40,
+ "w": 306,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Playwrite NO": {
+ "x": 320,
+ "y": 40,
+ "w": 189,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite NO Guides": {
+ "x": 523,
+ "y": 40,
+ "w": 285,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Playwrite NZ": {
+ "x": 822,
+ "y": 40,
+ "w": 164,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite NZ Basic": {
+ "x": 0,
+ "y": 80,
+ "w": 231,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite NZ Basic Guides": {
+ "x": 245,
+ "y": 80,
+ "w": 319,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Playwrite NZ Guides": {
+ "x": 578,
+ "y": 80,
+ "w": 256,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Playwrite PE": {
+ "x": 848,
+ "y": 80,
+ "w": 185,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite PE Guides": {
+ "x": 0,
+ "y": 120,
+ "w": 282,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Playwrite PL": {
+ "x": 296,
+ "y": 120,
+ "w": 178,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite PL Guides": {
+ "x": 488,
+ "y": 120,
+ "w": 272,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Playwrite PT": {
+ "x": 774,
+ "y": 120,
+ "w": 188,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite PT Guides": {
+ "x": 0,
+ "y": 160,
+ "w": 284,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Playwrite RO": {
+ "x": 298,
+ "y": 160,
+ "w": 197,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite RO Guides": {
+ "x": 509,
+ "y": 160,
+ "w": 286,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Playwrite SK": {
+ "x": 809,
+ "y": 160,
+ "w": 195,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite SK Guides": {
+ "x": 0,
+ "y": 200,
+ "w": 289,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Playwrite TZ": {
+ "x": 303,
+ "y": 200,
+ "w": 182,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite TZ Guides": {
+ "x": 499,
+ "y": 200,
+ "w": 271,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Playwrite US Modern": {
+ "x": 784,
+ "y": 200,
+ "w": 277,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite US Modern Guides": {
+ "x": 0,
+ "y": 240,
+ "w": 369,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Playwrite US Trad": {
+ "x": 383,
+ "y": 240,
+ "w": 258,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite US Trad Guides": {
+ "x": 655,
+ "y": 240,
+ "w": 355,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Playwrite VN": {
+ "x": 0,
+ "y": 280,
+ "w": 212,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite VN Guides": {
+ "x": 226,
+ "y": 280,
+ "w": 311,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Playwrite ZA": {
+ "x": 551,
+ "y": 280,
+ "w": 182,
+ "ch": 11,
+ "s": ["100", "200", "300", "400"]
+ },
+ "Playwrite ZA Guides": {
+ "x": 747,
+ "y": 280,
+ "w": 276,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Plus Jakarta Sans": {
+ "x": 0,
+ "y": 320,
+ "w": 199,
+ "ch": 11,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Pochaevsk": { "x": 213, "y": 320, "w": 111, "ch": 11, "s": ["400"] },
+ "Podkova": {
+ "x": 338,
+ "y": 320,
+ "w": 97,
+ "ch": 11,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Poetsen One": { "x": 449, "y": 320, "w": 148, "ch": 11, "s": ["400"] },
+ "Poiret One": { "x": 611, "y": 320, "w": 114, "ch": 11, "s": ["400"] },
+ "Poller One": { "x": 739, "y": 320, "w": 164, "ch": 11, "s": ["400"] },
+ "Poltawski Nowy": {
+ "x": 917,
+ "y": 320,
+ "w": 182,
+ "ch": 11,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Poly": { "x": 1113, "y": 320, "w": 54, "ch": 11, "s": ["400", "400i"] },
+ "Pompiere": { "x": 0, "y": 360, "w": 75, "ch": 11, "s": ["400"] },
+ "Ponnala": { "x": 89, "y": 360, "w": 70, "ch": 11, "s": ["400"] },
+ "Ponomar": { "x": 173, "y": 360, "w": 97, "ch": 11, "s": ["400"] },
+ "Pontano Sans": {
+ "x": 284,
+ "y": 360,
+ "w": 149,
+ "ch": 11,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Poor Story": { "x": 447, "y": 360, "w": 99, "ch": 11, "s": ["400"] },
+ "Poppins": {
+ "x": 560,
+ "y": 360,
+ "w": 104,
+ "ch": 11,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Port Lligat Sans": {
+ "x": 678,
+ "y": 360,
+ "w": 157,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Port Lligat Slab": {
+ "x": 849,
+ "y": 360,
+ "w": 153,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Potta One": { "x": 1016, "y": 360, "w": 143, "ch": 11, "s": ["400"] },
+ "Pragati Narrow": {
+ "x": 0,
+ "y": 400,
+ "w": 129,
+ "ch": 11,
+ "s": ["400", "700"]
+ },
+ "Praise": { "x": 143, "y": 400, "w": 59, "ch": 11, "s": ["400"] },
+ "Prata": { "x": 216, "y": 400, "w": 71, "ch": 11, "s": ["400"] },
+ "Preahvihear": { "x": 301, "y": 400, "w": 161, "ch": 11, "s": ["400"] },
+ "Press Start 2P": { "x": 476, "y": 400, "w": 344, "ch": 11, "s": ["400"] },
+ "Pridi": {
+ "x": 834,
+ "y": 400,
+ "w": 60,
+ "ch": 11,
+ "s": ["200", "300", "400", "500", "600", "700"]
+ },
+ "Princess Sofia": { "x": 908, "y": 400, "w": 131, "ch": 11, "s": ["400"] },
+ "Prociono": { "x": 1053, "y": 400, "w": 101, "ch": 11, "s": ["400"] },
+ "Prompt": {
+ "x": 0,
+ "y": 440,
+ "w": 92,
+ "ch": 11,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Prosto One": { "x": 106, "y": 440, "w": 159, "ch": 11, "s": ["400"] },
+ "Protest Guerrilla": {
+ "x": 279,
+ "y": 440,
+ "w": 177,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Protest Revolution": {
+ "x": 470,
+ "y": 440,
+ "w": 200,
+ "ch": 11,
+ "s": ["400"]
+ },
+ "Protest Riot": { "x": 684, "y": 440, "w": 131, "ch": 11, "s": ["400"] },
+ "Protest Strike": { "x": 829, "y": 440, "w": 151, "ch": 11, "s": ["400"] },
+ "Proza Libre": {
+ "x": 994,
+ "y": 440,
+ "w": 137,
+ "ch": 11,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "PT Mono": { "x": 0, "y": 480, "w": 109, "ch": 11, "s": ["400"] },
+ "PT Sans": {
+ "x": 123,
+ "y": 480,
+ "w": 88,
+ "ch": 11,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "PT Sans Caption": {
+ "x": 225,
+ "y": 480,
+ "w": 197,
+ "ch": 11,
+ "s": ["400", "700"]
+ },
+ "PT Sans Narrow": {
+ "x": 436,
+ "y": 480,
+ "w": 138,
+ "ch": 11,
+ "s": ["400", "700"]
+ },
+ "PT Serif": {
+ "x": 588,
+ "y": 480,
+ "w": 93,
+ "ch": 11,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "PT Serif Caption": {
+ "x": 695,
+ "y": 480,
+ "w": 205,
+ "ch": 11,
+ "s": ["400", "400i"]
+ },
+ "Public Sans": {
+ "x": 914,
+ "y": 480,
+ "w": 139,
+ "ch": 11,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Puppies Play": { "x": 1067, "y": 480, "w": 85, "ch": 11, "s": ["400"] },
+ "Puritan": {
+ "x": 0,
+ "y": 520,
+ "w": 78,
+ "ch": 11,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Purple Purse": { "x": 92, "y": 520, "w": 150, "ch": 11, "s": ["400"] },
+ "Pushster": { "x": 256, "y": 520, "w": 89, "ch": 11, "s": ["400"] },
+ "Qahiri": { "x": 359, "y": 520, "w": 51, "ch": 11, "s": ["400"] },
+ "Quando": { "x": 424, "y": 520, "w": 107, "ch": 11, "s": ["400"] },
+ "Quantico": {
+ "x": 545,
+ "y": 520,
+ "w": 109,
+ "ch": 11,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Quattrocento": {
+ "x": 668,
+ "y": 520,
+ "w": 153,
+ "ch": 11,
+ "s": ["400", "700"]
+ },
+ "Quattrocento Sans": {
+ "x": 835,
+ "y": 520,
+ "w": 201,
+ "ch": 11,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Questrial": { "x": 1050, "y": 520, "w": 105, "ch": 11, "s": ["400"] },
+ "Quicksand": {
+ "x": 0,
+ "y": 560,
+ "w": 125,
+ "ch": 11,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Quintessential": { "x": 139, "y": 560, "w": 144, "ch": 11, "s": ["400"] },
+ "Qwigley": { "x": 297, "y": 560, "w": 59, "ch": 11, "s": ["400"] },
+ "Qwitcher Grypen": {
+ "x": 370,
+ "y": 560,
+ "w": 111,
+ "ch": 11,
+ "s": ["400", "700"]
+ },
+ "Racing Sans One": { "x": 495, "y": 560, "w": 190, "ch": 11, "s": ["400"] },
+ "Radio Canada": {
+ "x": 699,
+ "y": 560,
+ "w": 161,
+ "ch": 11,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Radio Canada Big": {
+ "x": 874,
+ "y": 560,
+ "w": 196,
+ "ch": 11,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Radley": { "x": 1084, "y": 560, "w": 81, "ch": 11, "s": ["400", "400i"] },
+ "Rajdhani": {
+ "x": 0,
+ "y": 600,
+ "w": 92,
+ "ch": 11,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Rakkas": { "x": 106, "y": 600, "w": 79, "ch": 11, "s": ["400"] },
+ "Raleway": {
+ "x": 199,
+ "y": 600,
+ "w": 102,
+ "ch": 11,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Raleway Dots": { "x": 315, "y": 600, "w": 156, "ch": 11, "s": ["400"] },
+ "Ramabhadra": { "x": 485, "y": 600, "w": 154, "ch": 11, "s": ["400"] },
+ "Ramaraja": { "x": 653, "y": 600, "w": 96, "ch": 11, "s": ["400"] },
+ "Rambla": {
+ "x": 763,
+ "y": 600,
+ "w": 83,
+ "ch": 11,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Rammetto One": { "x": 860, "y": 600, "w": 234, "ch": 11, "s": ["400"] },
+ "Rampart One": { "x": 0, "y": 640, "w": 170, "ch": 11, "s": ["400"] },
+ "Ranchers": { "x": 184, "y": 640, "w": 97, "ch": 11, "s": ["400"] },
+ "Rancho": { "x": 295, "y": 640, "w": 62, "ch": 11, "s": ["400"] },
+ "Ranga": { "x": 371, "y": 640, "w": 52, "ch": 11, "s": ["400", "700"] },
+ "Rasa": {
+ "x": 437,
+ "y": 640,
+ "w": 53,
+ "ch": 11,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Rationale": { "x": 504, "y": 640, "w": 90, "ch": 11, "s": ["400"] },
+ "Ravi Prakash": { "x": 608, "y": 640, "w": 132, "ch": 11, "s": ["400"] },
+ "Readex Pro": {
+ "x": 754,
+ "y": 640,
+ "w": 140,
+ "ch": 11,
+ "s": ["200", "300", "400", "500", "600", "700"]
+ },
+ "Recursive": {
+ "x": 908,
+ "y": 640,
+ "w": 123,
+ "ch": 11,
+ "s": ["300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Red Hat Display": {
+ "x": 0,
+ "y": 680,
+ "w": 175,
+ "ch": 11,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Red Hat Mono": {
+ "x": 189,
+ "y": 680,
+ "w": 181,
+ "ch": 11,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Red Hat Text": {
+ "x": 384,
+ "y": 680,
+ "w": 146,
+ "ch": 11,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Red Rose": {
+ "x": 544,
+ "y": 680,
+ "w": 117,
+ "ch": 11,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Redacted": { "x": 675, "y": 680, "w": 80, "ch": 11, "s": ["400"] },
+ "Redacted Script": {
+ "x": 769,
+ "y": 680,
+ "w": 201,
+ "ch": 11,
+ "s": ["300", "400", "700"]
+ },
+ "Reddit Mono": {
+ "x": 984,
+ "y": 680,
+ "w": 157,
+ "ch": 11,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Reddit Sans": {
+ "x": 0,
+ "y": 720,
+ "w": 134,
+ "ch": 11,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Reddit Sans Condensed": {
+ "x": 148,
+ "y": 720,
+ "w": 232,
+ "ch": 11,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Redressed": { "x": 394, "y": 720, "w": 96, "ch": 11, "s": ["400"] },
+ "Reem Kufi": {
+ "x": 504,
+ "y": 720,
+ "w": 118,
+ "ch": 11,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Reem Kufi Fun": {
+ "x": 636,
+ "y": 720,
+ "w": 161,
+ "ch": 11,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Reem Kufi Ink": { "x": 811, "y": 720, "w": 154, "ch": 11, "s": ["400"] },
+ "Reenie Beanie": { "x": 979, "y": 720, "w": 122, "ch": 11, "s": ["400"] },
+ "Reggae One": { "x": 0, "y": 760, "w": 161, "ch": 11, "s": ["400"] },
+ "REM": {
+ "x": 175,
+ "y": 760,
+ "w": 63,
+ "ch": 11,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Rethink Sans": {
+ "x": 252,
+ "y": 760,
+ "w": 150,
+ "ch": 11,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Revalia": { "x": 416, "y": 760, "w": 121, "ch": 11, "s": ["400"] },
+ "Rhodium Libre": { "x": 551, "y": 760, "w": 187, "ch": 11, "s": ["400"] },
+ "Ribeye": { "x": 752, "y": 760, "w": 92, "ch": 11, "s": ["400"] },
+ "Ribeye Marrow": { "x": 858, "y": 760, "w": 201, "ch": 11, "s": ["400"] },
+ "Righteous": { "x": 1073, "y": 760, "w": 121, "ch": 11, "s": ["400"] },
+ "Risque": { "x": 0, "y": 0, "w": 77, "ch": 12, "s": ["400"] },
+ "Road Rage": { "x": 91, "y": 0, "w": 72, "ch": 12, "s": ["400"] },
+ "Roboto": {
+ "x": 177,
+ "y": 0,
+ "w": 85,
+ "ch": 12,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Roboto Condensed": {
+ "x": 276,
+ "y": 0,
+ "w": 188,
+ "ch": 12,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Roboto Flex": { "x": 478, "y": 0, "w": 134, "ch": 12, "s": ["400"] },
+ "Roboto Mono": {
+ "x": 626,
+ "y": 0,
+ "w": 167,
+ "ch": 12,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Roboto Serif": {
+ "x": 807,
+ "y": 0,
+ "w": 162,
+ "ch": 12,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Roboto Slab": {
+ "x": 983,
+ "y": 0,
+ "w": 141,
+ "ch": 12,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Rochester": { "x": 0, "y": 40, "w": 85, "ch": 12, "s": ["400"] },
+ "Rock 3D": { "x": 99, "y": 40, "w": 137, "ch": 12, "s": ["400"] },
+ "Rock Salt": { "x": 250, "y": 40, "w": 153, "ch": 12, "s": ["400"] },
+ "RocknRoll One": { "x": 417, "y": 40, "w": 190, "ch": 12, "s": ["400"] },
+ "Rokkitt": {
+ "x": 621,
+ "y": 40,
+ "w": 79,
+ "ch": 12,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Romanesco": { "x": 714, "y": 40, "w": 73, "ch": 12, "s": ["400"] },
+ "Ropa Sans": {
+ "x": 801,
+ "y": 40,
+ "w": 102,
+ "ch": 12,
+ "s": ["400", "400i"]
+ },
+ "Rosario": {
+ "x": 917,
+ "y": 40,
+ "w": 84,
+ "ch": 12,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Rosarivo": {
+ "x": 1015,
+ "y": 40,
+ "w": 106,
+ "ch": 12,
+ "s": ["400", "400i"]
+ },
+ "Rouge Script": { "x": 0, "y": 80, "w": 104, "ch": 12, "s": ["400"] },
+ "Rowdies": {
+ "x": 118,
+ "y": 80,
+ "w": 106,
+ "ch": 12,
+ "s": ["300", "400", "700"]
+ },
+ "Rozha One": { "x": 238, "y": 80, "w": 126, "ch": 12, "s": ["400"] },
+ "Rubik": {
+ "x": 378,
+ "y": 80,
+ "w": 71,
+ "ch": 12,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Rubik 80s Fade": { "x": 463, "y": 80, "w": 199, "ch": 12, "s": ["400"] },
+ "Rubik Beastly": { "x": 676, "y": 80, "w": 183, "ch": 12, "s": ["400"] },
+ "Rubik Broken Fax": { "x": 873, "y": 80, "w": 227, "ch": 12, "s": ["400"] },
+ "Rubik Bubbles": { "x": 0, "y": 120, "w": 188, "ch": 12, "s": ["400"] },
+ "Rubik Burned": { "x": 202, "y": 120, "w": 179, "ch": 12, "s": ["400"] },
+ "Rubik Dirt": { "x": 395, "y": 120, "w": 137, "ch": 12, "s": ["400"] },
+ "Rubik Distressed": {
+ "x": 546,
+ "y": 120,
+ "w": 225,
+ "ch": 12,
+ "s": ["400"]
+ },
+ "Rubik Doodle Shadow": {
+ "x": 785,
+ "y": 120,
+ "w": 277,
+ "ch": 12,
+ "s": ["400"]
+ },
+ "Rubik Doodle Triangles": {
+ "x": 0,
+ "y": 160,
+ "w": 296,
+ "ch": 12,
+ "s": ["400"]
+ },
+ "Rubik Gemstones": { "x": 310, "y": 160, "w": 229, "ch": 12, "s": ["400"] },
+ "Rubik Glitch": { "x": 553, "y": 160, "w": 165, "ch": 12, "s": ["400"] },
+ "Rubik Glitch Pop": {
+ "x": 732,
+ "y": 160,
+ "w": 217,
+ "ch": 12,
+ "s": ["400"]
+ },
+ "Rubik Iso": { "x": 963, "y": 160, "w": 124, "ch": 12, "s": ["400"] },
+ "Rubik Lines": { "x": 0, "y": 200, "w": 155, "ch": 12, "s": ["400"] },
+ "Rubik Maps": { "x": 169, "y": 200, "w": 151, "ch": 12, "s": ["400"] },
+ "Rubik Marker Hatch": {
+ "x": 334,
+ "y": 200,
+ "w": 257,
+ "ch": 12,
+ "s": ["400"]
+ },
+ "Rubik Maze": { "x": 605, "y": 200, "w": 150, "ch": 12, "s": ["400"] },
+ "Rubik Microbe": { "x": 769, "y": 200, "w": 188, "ch": 12, "s": ["400"] },
+ "Rubik Mono One": { "x": 0, "y": 240, "w": 294, "ch": 12, "s": ["400"] },
+ "Rubik Moonrocks": { "x": 308, "y": 240, "w": 225, "ch": 12, "s": ["400"] },
+ "Rubik Pixels": { "x": 547, "y": 240, "w": 162, "ch": 12, "s": ["400"] },
+ "Rubik Puddles": { "x": 723, "y": 240, "w": 187, "ch": 12, "s": ["400"] },
+ "Rubik Scribble": { "x": 924, "y": 240, "w": 192, "ch": 12, "s": ["400"] },
+ "Rubik Spray Paint": { "x": 0, "y": 280, "w": 233, "ch": 12, "s": ["400"] },
+ "Rubik Storm": { "x": 247, "y": 280, "w": 165, "ch": 12, "s": ["400"] },
+ "Rubik Vinyl": { "x": 426, "y": 280, "w": 150, "ch": 12, "s": ["400"] },
+ "Rubik Wet Paint": { "x": 590, "y": 280, "w": 207, "ch": 12, "s": ["400"] },
+ "Ruda": {
+ "x": 811,
+ "y": 280,
+ "w": 66,
+ "ch": 12,
+ "s": ["400", "500", "600", "700", "800", "900"]
+ },
+ "Rufina": { "x": 891, "y": 280, "w": 82, "ch": 12, "s": ["400", "700"] },
+ "Ruge Boogie": { "x": 987, "y": 280, "w": 99, "ch": 12, "s": ["400"] },
+ "Ruluko": { "x": 1100, "y": 280, "w": 78, "ch": 12, "s": ["400"] },
+ "Rum Raisin": { "x": 0, "y": 320, "w": 100, "ch": 12, "s": ["400"] },
+ "Ruslan Display": { "x": 114, "y": 320, "w": 220, "ch": 12, "s": ["400"] },
+ "Russo One": { "x": 348, "y": 320, "w": 138, "ch": 12, "s": ["400"] },
+ "Ruthie": { "x": 500, "y": 320, "w": 64, "ch": 12, "s": ["400"] },
+ "Ruwudu": {
+ "x": 578,
+ "y": 320,
+ "w": 94,
+ "ch": 12,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Rye": { "x": 686, "y": 320, "w": 55, "ch": 12, "s": ["400"] },
+ "Sacramento": { "x": 755, "y": 320, "w": 104, "ch": 12, "s": ["400"] },
+ "Sahitya": { "x": 873, "y": 320, "w": 81, "ch": 12, "s": ["400", "700"] },
+ "Sail": { "x": 968, "y": 320, "w": 50, "ch": 12, "s": ["400"] },
+ "Saira": {
+ "x": 1032,
+ "y": 320,
+ "w": 66,
+ "ch": 12,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Saira Condensed": {
+ "x": 0,
+ "y": 360,
+ "w": 149,
+ "ch": 12,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Saira Extra Condensed": {
+ "x": 163,
+ "y": 360,
+ "w": 167,
+ "ch": 12,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Saira Semi Condensed": {
+ "x": 344,
+ "y": 360,
+ "w": 227,
+ "ch": 12,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Saira Stencil One": {
+ "x": 585,
+ "y": 360,
+ "w": 201,
+ "ch": 12,
+ "s": ["400"]
+ },
+ "Salsa": { "x": 800, "y": 360, "w": 66, "ch": 12, "s": ["400"] },
+ "Sanchez": { "x": 880, "y": 360, "w": 105, "ch": 12, "s": ["400", "400i"] },
+ "Sancreek": { "x": 999, "y": 360, "w": 111, "ch": 12, "s": ["400"] },
+ "Sankofa Display": { "x": 0, "y": 400, "w": 163, "ch": 12, "s": ["400"] },
+ "Sansation": {
+ "x": 177,
+ "y": 400,
+ "w": 119,
+ "ch": 12,
+ "s": ["300", "300i", "400", "400i", "700", "700i"]
+ },
+ "Sansita": {
+ "x": 310,
+ "y": 400,
+ "w": 80,
+ "ch": 12,
+ "s": ["400", "400i", "700", "700i", "800", "800i", "900", "900i"]
+ },
+ "Sansita Swashed": {
+ "x": 404,
+ "y": 400,
+ "w": 177,
+ "ch": 12,
+ "s": ["300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Sarabun": {
+ "x": 595,
+ "y": 400,
+ "w": 94,
+ "ch": 12,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Sarala": { "x": 703, "y": 400, "w": 74, "ch": 12, "s": ["400", "700"] },
+ "Sarina": { "x": 791, "y": 400, "w": 112, "ch": 12, "s": ["400"] },
+ "Sarpanch": {
+ "x": 917,
+ "y": 400,
+ "w": 122,
+ "ch": 12,
+ "s": ["400", "500", "600", "700", "800", "900"]
+ },
+ "Sassy Frass": { "x": 1053, "y": 400, "w": 78, "ch": 12, "s": ["400"] },
+ "Satisfy": { "x": 0, "y": 440, "w": 70, "ch": 12, "s": ["400"] },
+ "Savate": {
+ "x": 84,
+ "y": 440,
+ "w": 81,
+ "ch": 12,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Sawarabi Gothic": { "x": 179, "y": 440, "w": 186, "ch": 12, "s": ["400"] },
+ "Sawarabi Mincho": { "x": 379, "y": 440, "w": 204, "ch": 12, "s": ["400"] },
+ "Scada": {
+ "x": 597,
+ "y": 440,
+ "w": 69,
+ "ch": 12,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Scheherazade New": {
+ "x": 680,
+ "y": 440,
+ "w": 175,
+ "ch": 12,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Schibsted Grotesk": {
+ "x": 869,
+ "y": 440,
+ "w": 212,
+ "ch": 12,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Schoolbell": { "x": 1095, "y": 440, "w": 102, "ch": 12, "s": ["400"] },
+ "Science Gothic": {
+ "x": 0,
+ "y": 480,
+ "w": 212,
+ "ch": 12,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Scope One": { "x": 226, "y": 480, "w": 120, "ch": 12, "s": ["400"] },
+ "Seaweed Script": { "x": 360, "y": 480, "w": 137, "ch": 12, "s": ["400"] },
+ "Secular One": { "x": 511, "y": 480, "w": 144, "ch": 12, "s": ["400"] },
+ "Sedan": { "x": 669, "y": 480, "w": 72, "ch": 12, "s": ["400", "400i"] },
+ "Sedan SC": { "x": 755, "y": 480, "w": 111, "ch": 12, "s": ["400"] },
+ "Sedgwick Ave": { "x": 880, "y": 480, "w": 140, "ch": 12, "s": ["400"] },
+ "Sedgwick Ave Display": {
+ "x": 0,
+ "y": 520,
+ "w": 224,
+ "ch": 12,
+ "s": ["400"]
+ },
+ "Sekuya": { "x": 238, "y": 520, "w": 138, "ch": 12, "s": ["400"] },
+ "Sen": {
+ "x": 390,
+ "y": 520,
+ "w": 48,
+ "ch": 12,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Send Flowers": { "x": 452, "y": 520, "w": 129, "ch": 12, "s": ["400"] },
+ "Sevillana": { "x": 595, "y": 520, "w": 96, "ch": 12, "s": ["400"] },
+ "Seymour One": { "x": 705, "y": 520, "w": 241, "ch": 12, "s": ["400"] },
+ "Shadows Into Light": {
+ "x": 960,
+ "y": 520,
+ "w": 172,
+ "ch": 12,
+ "s": ["400"]
+ },
+ "Shadows Into Light Two": {
+ "x": 0,
+ "y": 560,
+ "w": 234,
+ "ch": 12,
+ "s": ["400"]
+ },
+ "Shafarik": { "x": 248, "y": 560, "w": 89, "ch": 12, "s": ["400"] },
+ "Shalimar": { "x": 351, "y": 560, "w": 59, "ch": 12, "s": ["400"] },
+ "Shantell Sans": {
+ "x": 424,
+ "y": 560,
+ "w": 166,
+ "ch": 12,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Shanti": { "x": 604, "y": 560, "w": 75, "ch": 12, "s": ["400"] },
+ "Share": {
+ "x": 693,
+ "y": 560,
+ "w": 62,
+ "ch": 12,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Share Tech": { "x": 769, "y": 560, "w": 109, "ch": 12, "s": ["400"] },
+ "Share Tech Mono": { "x": 892, "y": 560, "w": 203, "ch": 12, "s": ["400"] },
+ "Shippori Antique": { "x": 0, "y": 600, "w": 206, "ch": 12, "s": ["400"] },
+ "Shippori Antique B1": {
+ "x": 220,
+ "y": 600,
+ "w": 241,
+ "ch": 12,
+ "s": ["400"]
+ },
+ "Shippori Mincho": {
+ "x": 475,
+ "y": 600,
+ "w": 202,
+ "ch": 12,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Shippori Mincho B1": {
+ "x": 691,
+ "y": 600,
+ "w": 237,
+ "ch": 12,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Shizuru": { "x": 942, "y": 600, "w": 82, "ch": 12, "s": ["400"] },
+ "Shojumaru": { "x": 0, "y": 640, "w": 184, "ch": 12, "s": ["400"] },
+ "Short Stack": { "x": 198, "y": 640, "w": 168, "ch": 12, "s": ["400"] },
+ "Shrikhand": { "x": 380, "y": 640, "w": 147, "ch": 12, "s": ["400"] },
+ "Sigmar": { "x": 541, "y": 640, "w": 99, "ch": 12, "s": ["400"] },
+ "Sigmar One": { "x": 654, "y": 640, "w": 172, "ch": 12, "s": ["400"] },
+ "Signika": {
+ "x": 840,
+ "y": 640,
+ "w": 85,
+ "ch": 12,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Signika Negative": {
+ "x": 939,
+ "y": 640,
+ "w": 180,
+ "ch": 12,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Silkscreen": { "x": 0, "y": 680, "w": 173, "ch": 12, "s": ["400", "700"] },
+ "Simonetta": {
+ "x": 187,
+ "y": 680,
+ "w": 108,
+ "ch": 12,
+ "s": ["400", "400i", "900", "900i"]
+ },
+ "Sintony": { "x": 309, "y": 680, "w": 97, "ch": 12, "s": ["400", "700"] },
+ "Sirin Stencil": { "x": 420, "y": 680, "w": 122, "ch": 12, "s": ["400"] },
+ "Sirivennela": { "x": 556, "y": 680, "w": 98, "ch": 12, "s": ["400"] },
+ "Six Caps": { "x": 668, "y": 680, "w": 47, "ch": 12, "s": ["400"] },
+ "Sixtyfour": { "x": 729, "y": 680, "w": 224, "ch": 12, "s": ["400"] },
+ "Sixtyfour Convergence": {
+ "x": 0,
+ "y": 720,
+ "w": 512,
+ "ch": 12,
+ "s": ["400"]
+ },
+ "Skranji": { "x": 526, "y": 720, "w": 78, "ch": 12, "s": ["400", "700"] },
+ "Slabo 13px": { "x": 618, "y": 720, "w": 127, "ch": 12, "s": ["400"] },
+ "Slabo 27px": { "x": 759, "y": 720, "w": 111, "ch": 12, "s": ["400"] },
+ "Slackey": { "x": 884, "y": 720, "w": 124, "ch": 12, "s": ["400"] },
+ "Slackside One": { "x": 1022, "y": 720, "w": 137, "ch": 12, "s": ["400"] },
+ "Smokum": { "x": 0, "y": 760, "w": 76, "ch": 12, "s": ["400"] },
+ "Smooch": { "x": 90, "y": 760, "w": 73, "ch": 12, "s": ["400"] },
+ "Smooch Sans": {
+ "x": 177,
+ "y": 760,
+ "w": 110,
+ "ch": 12,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Smythe": { "x": 301, "y": 760, "w": 64, "ch": 12, "s": ["400"] },
+ "SN Pro": {
+ "x": 379,
+ "y": 760,
+ "w": 84,
+ "ch": 12,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Sniglet": { "x": 477, "y": 760, "w": 82, "ch": 12, "s": ["400", "800"] },
+ "Snippet": { "x": 573, "y": 760, "w": 89, "ch": 12, "s": ["400"] },
+ "Snowburst One": { "x": 676, "y": 760, "w": 197, "ch": 12, "s": ["400"] },
+ "Sofadi One": { "x": 887, "y": 760, "w": 137, "ch": 12, "s": ["400"] },
+ "Sofia": { "x": 1038, "y": 760, "w": 61, "ch": 12, "s": ["400"] },
+ "Sofia Sans": {
+ "x": 0,
+ "y": 0,
+ "w": 115,
+ "ch": 13,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Sofia Sans Condensed": {
+ "x": 129,
+ "y": 0,
+ "w": 184,
+ "ch": 13,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Sofia Sans Extra Condensed": {
+ "x": 327,
+ "y": 0,
+ "w": 188,
+ "ch": 13,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Sofia Sans Semi Condensed": {
+ "x": 529,
+ "y": 0,
+ "w": 280,
+ "ch": 13,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Solitreo": { "x": 823, "y": 0, "w": 83, "ch": 13, "s": ["400"] },
+ "Solway": {
+ "x": 920,
+ "y": 0,
+ "w": 90,
+ "ch": 13,
+ "s": ["300", "400", "500", "700", "800"]
+ },
+ "Sometype Mono": {
+ "x": 0,
+ "y": 40,
+ "w": 189,
+ "ch": 13,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Sono": {
+ "x": 203,
+ "y": 40,
+ "w": 68,
+ "ch": 13,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Sonsie One": { "x": 285, "y": 40, "w": 194, "ch": 13, "s": ["400"] },
+ "Sora": {
+ "x": 493,
+ "y": 40,
+ "w": 65,
+ "ch": 13,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Sorts Mill Goudy": {
+ "x": 572,
+ "y": 40,
+ "w": 187,
+ "ch": 13,
+ "s": ["400", "400i"]
+ },
+ "Sour Gummy": {
+ "x": 773,
+ "y": 40,
+ "w": 149,
+ "ch": 13,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Source Code Pro": {
+ "x": 936,
+ "y": 40,
+ "w": 224,
+ "ch": 13,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Source Sans 3": {
+ "x": 0,
+ "y": 80,
+ "w": 147,
+ "ch": 13,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Source Sans Pro": {
+ "x": 161,
+ "y": 80,
+ "w": 170,
+ "ch": 13,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "900",
+ "900i"
+ ]
+ },
+ "Source Serif 4": {
+ "x": 345,
+ "y": 80,
+ "w": 164,
+ "ch": 13,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Source Serif Pro": {
+ "x": 523,
+ "y": 80,
+ "w": 180,
+ "ch": 13,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "900",
+ "900i"
+ ]
+ },
+ "Space Grotesk": {
+ "x": 717,
+ "y": 80,
+ "w": 176,
+ "ch": 13,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Space Mono": {
+ "x": 907,
+ "y": 80,
+ "w": 155,
+ "ch": 13,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Special Elite": { "x": 0, "y": 120, "w": 177, "ch": 13, "s": ["400"] },
+ "Special Gothic": {
+ "x": 191,
+ "y": 120,
+ "w": 156,
+ "ch": 13,
+ "s": ["400", "500", "600", "700"]
+ },
+ "Special Gothic Condensed One": {
+ "x": 361,
+ "y": 120,
+ "w": 247,
+ "ch": 13,
+ "s": ["400"]
+ },
+ "Special Gothic Expanded One": {
+ "x": 622,
+ "y": 120,
+ "w": 424,
+ "ch": 13,
+ "s": ["400"]
+ },
+ "Spectral": {
+ "x": 1060,
+ "y": 120,
+ "w": 95,
+ "ch": 13,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Spectral SC": {
+ "x": 0,
+ "y": 160,
+ "w": 159,
+ "ch": 13,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Spicy Rice": { "x": 173, "y": 160, "w": 111, "ch": 13, "s": ["400"] },
+ "Spinnaker": { "x": 298, "y": 160, "w": 128, "ch": 13, "s": ["400"] },
+ "Spirax": { "x": 440, "y": 160, "w": 73, "ch": 13, "s": ["400"] },
+ "Splash": { "x": 527, "y": 160, "w": 88, "ch": 13, "s": ["400"] },
+ "Spline Sans": {
+ "x": 629,
+ "y": 160,
+ "w": 133,
+ "ch": 13,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Spline Sans Mono": {
+ "x": 776,
+ "y": 160,
+ "w": 239,
+ "ch": 13,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Squada One": { "x": 1029, "y": 160, "w": 111, "ch": 13, "s": ["400"] },
+ "Square Peg": { "x": 0, "y": 200, "w": 73, "ch": 13, "s": ["400"] },
+ "Sree Krushnadevaraya": {
+ "x": 87,
+ "y": 200,
+ "w": 222,
+ "ch": 13,
+ "s": ["400"]
+ },
+ "Sriracha": { "x": 323, "y": 200, "w": 99, "ch": 13, "s": ["400"] },
+ "Srisakdi": { "x": 436, "y": 200, "w": 90, "ch": 13, "s": ["400", "700"] },
+ "Staatliches": { "x": 540, "y": 200, "w": 114, "ch": 13, "s": ["400"] },
+ "Stack Sans Headline": {
+ "x": 668,
+ "y": 200,
+ "w": 235,
+ "ch": 13,
+ "s": ["200", "300", "400", "500", "600", "700"]
+ },
+ "Stack Sans Notch": {
+ "x": 917,
+ "y": 200,
+ "w": 205,
+ "ch": 13,
+ "s": ["200", "300", "400", "500", "600", "700"]
+ },
+ "Stack Sans Text": {
+ "x": 0,
+ "y": 240,
+ "w": 195,
+ "ch": 13,
+ "s": ["200", "300", "400", "500", "600", "700"]
+ },
+ "Stalemate": { "x": 209, "y": 240, "w": 62, "ch": 13, "s": ["400"] },
+ "Stalinist One": { "x": 285, "y": 240, "w": 280, "ch": 13, "s": ["400"] },
+ "Stardos Stencil": {
+ "x": 579,
+ "y": 240,
+ "w": 168,
+ "ch": 13,
+ "s": ["400", "700"]
+ },
+ "Stick": { "x": 761, "y": 240, "w": 65, "ch": 13, "s": ["400"] },
+ "Stick No Bills": {
+ "x": 840,
+ "y": 240,
+ "w": 123,
+ "ch": 13,
+ "s": ["200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Stint Ultra Condensed": {
+ "x": 977,
+ "y": 240,
+ "w": 139,
+ "ch": 13,
+ "s": ["400"]
+ },
+ "Stint Ultra Expanded": {
+ "x": 0,
+ "y": 280,
+ "w": 310,
+ "ch": 13,
+ "s": ["400"]
+ },
+ "STIX Two Text": {
+ "x": 324,
+ "y": 280,
+ "w": 156,
+ "ch": 13,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Stoke": { "x": 494, "y": 280, "w": 84, "ch": 13, "s": ["300", "400"] },
+ "Story Script": { "x": 592, "y": 280, "w": 113, "ch": 13, "s": ["400"] },
+ "Strait": { "x": 719, "y": 280, "w": 62, "ch": 13, "s": ["400"] },
+ "Style Script": { "x": 795, "y": 280, "w": 97, "ch": 13, "s": ["400"] },
+ "Sue Ellen Francisco": {
+ "x": 906,
+ "y": 280,
+ "w": 137,
+ "ch": 13,
+ "s": ["400"]
+ },
+ "Suez One": { "x": 1057, "y": 280, "w": 113, "ch": 13, "s": ["400"] },
+ "Sulphur Point": {
+ "x": 0,
+ "y": 320,
+ "w": 144,
+ "ch": 13,
+ "s": ["300", "400", "700"]
+ },
+ "Sumana": { "x": 158, "y": 320, "w": 92, "ch": 13, "s": ["400", "700"] },
+ "Sunshiney": { "x": 264, "y": 320, "w": 94, "ch": 13, "s": ["400"] },
+ "Supermercado One": {
+ "x": 372,
+ "y": 320,
+ "w": 196,
+ "ch": 13,
+ "s": ["400"]
+ },
+ "Sura": { "x": 582, "y": 320, "w": 58, "ch": 13, "s": ["400", "700"] },
+ "Suranna": { "x": 654, "y": 320, "w": 86, "ch": 13, "s": ["400"] },
+ "Suravaram": { "x": 754, "y": 320, "w": 104, "ch": 13, "s": ["400"] },
+ "SUSE": {
+ "x": 872,
+ "y": 320,
+ "w": 65,
+ "ch": 13,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "SUSE Mono": {
+ "x": 951,
+ "y": 320,
+ "w": 138,
+ "ch": 13,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Suwannaphum": {
+ "x": 0,
+ "y": 360,
+ "w": 184,
+ "ch": 13,
+ "s": ["100", "300", "400", "700", "900"]
+ },
+ "Swanky and Moo Moo": {
+ "x": 198,
+ "y": 360,
+ "w": 203,
+ "ch": 13,
+ "s": ["400"]
+ },
+ "Syncopate": {
+ "x": 415,
+ "y": 360,
+ "w": 195,
+ "ch": 13,
+ "s": ["400", "700"]
+ },
+ "Syne": {
+ "x": 624,
+ "y": 360,
+ "w": 60,
+ "ch": 13,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Syne Mono": { "x": 698, "y": 360, "w": 127, "ch": 13, "s": ["400"] },
+ "Syne Tactile": { "x": 839, "y": 360, "w": 119, "ch": 13, "s": ["400"] },
+ "Tac One": { "x": 972, "y": 360, "w": 73, "ch": 13, "s": ["400"] },
+ "Tagesschrift": { "x": 0, "y": 400, "w": 142, "ch": 13, "s": ["400"] },
+ "Tai Heritage Pro": {
+ "x": 156,
+ "y": 400,
+ "w": 171,
+ "ch": 13,
+ "s": ["400", "700"]
+ },
+ "Tajawal": {
+ "x": 341,
+ "y": 400,
+ "w": 82,
+ "ch": 13,
+ "s": ["200", "300", "400", "500", "700", "800", "900"]
+ },
+ "Tangerine": { "x": 437, "y": 400, "w": 67, "ch": 13, "s": ["400", "700"] },
+ "Tapestry": { "x": 518, "y": 400, "w": 100, "ch": 13, "s": ["400"] },
+ "Taprom": { "x": 632, "y": 400, "w": 70, "ch": 13, "s": ["400"] },
+ "TASA Explorer": {
+ "x": 716,
+ "y": 400,
+ "w": 161,
+ "ch": 13,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "TASA Orbiter": {
+ "x": 891,
+ "y": 400,
+ "w": 153,
+ "ch": 13,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Tauri": { "x": 1058, "y": 400, "w": 66, "ch": 13, "s": ["400"] },
+ "Taviraj": {
+ "x": 0,
+ "y": 440,
+ "w": 84,
+ "ch": 13,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Teachers": {
+ "x": 98,
+ "y": 440,
+ "w": 100,
+ "ch": 13,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Teko": {
+ "x": 212,
+ "y": 440,
+ "w": 42,
+ "ch": 13,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Tektur": {
+ "x": 268,
+ "y": 440,
+ "w": 86,
+ "ch": 13,
+ "s": ["400", "500", "600", "700", "800", "900"]
+ },
+ "Telex": { "x": 368, "y": 440, "w": 64, "ch": 13, "s": ["400"] },
+ "Tenali Ramakrishna": {
+ "x": 446,
+ "y": 440,
+ "w": 170,
+ "ch": 13,
+ "s": ["400"]
+ },
+ "Tenor Sans": { "x": 630, "y": 440, "w": 134, "ch": 13, "s": ["400"] },
+ "Text Me One": { "x": 778, "y": 440, "w": 140, "ch": 13, "s": ["400"] },
+ "Texturina": {
+ "x": 932,
+ "y": 440,
+ "w": 113,
+ "ch": 13,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Thasadith": {
+ "x": 1059,
+ "y": 440,
+ "w": 101,
+ "ch": 13,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "The Girl Next Door": {
+ "x": 0,
+ "y": 480,
+ "w": 215,
+ "ch": 13,
+ "s": ["400"]
+ },
+ "The Nautigal": {
+ "x": 229,
+ "y": 480,
+ "w": 102,
+ "ch": 13,
+ "s": ["400", "700"]
+ },
+ "Tienne": {
+ "x": 345,
+ "y": 480,
+ "w": 92,
+ "ch": 13,
+ "s": ["400", "700", "900"]
+ },
+ "TikTok Sans": {
+ "x": 451,
+ "y": 480,
+ "w": 141,
+ "ch": 13,
+ "s": ["300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Tillana": {
+ "x": 606,
+ "y": 480,
+ "w": 75,
+ "ch": 13,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Tilt Neon": { "x": 695, "y": 480, "w": 101, "ch": 13, "s": ["400"] },
+ "Tilt Prism": { "x": 810, "y": 480, "w": 121, "ch": 13, "s": ["400"] },
+ "Tilt Warp": { "x": 945, "y": 480, "w": 109, "ch": 13, "s": ["400"] },
+ "Timmana": { "x": 1068, "y": 480, "w": 90, "ch": 13, "s": ["400"] },
+ "Tinos": {
+ "x": 0,
+ "y": 520,
+ "w": 62,
+ "ch": 13,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Tiny5": { "x": 76, "y": 520, "w": 62, "ch": 13, "s": ["400"] },
+ "Tiro Bangla": {
+ "x": 152,
+ "y": 520,
+ "w": 134,
+ "ch": 13,
+ "s": ["400", "400i"]
+ },
+ "Tiro Devanagari Hindi": {
+ "x": 300,
+ "y": 520,
+ "w": 251,
+ "ch": 13,
+ "s": ["400", "400i"]
+ },
+ "Tiro Devanagari Marathi": {
+ "x": 565,
+ "y": 520,
+ "w": 274,
+ "ch": 13,
+ "s": ["400", "400i"]
+ },
+ "Tiro Devanagari Sanskrit": {
+ "x": 853,
+ "y": 520,
+ "w": 276,
+ "ch": 13,
+ "s": ["400", "400i"]
+ },
+ "Tiro Gurmukhi": {
+ "x": 0,
+ "y": 560,
+ "w": 173,
+ "ch": 13,
+ "s": ["400", "400i"]
+ },
+ "Tiro Kannada": {
+ "x": 187,
+ "y": 560,
+ "w": 156,
+ "ch": 13,
+ "s": ["400", "400i"]
+ },
+ "Tiro Tamil": {
+ "x": 357,
+ "y": 560,
+ "w": 122,
+ "ch": 13,
+ "s": ["400", "400i"]
+ },
+ "Tiro Telugu": {
+ "x": 493,
+ "y": 560,
+ "w": 134,
+ "ch": 13,
+ "s": ["400", "400i"]
+ },
+ "Tirra": {
+ "x": 641,
+ "y": 560,
+ "w": 57,
+ "ch": 13,
+ "s": ["400", "500", "600", "700", "800", "900"]
+ },
+ "Titan One": { "x": 712, "y": 560, "w": 131, "ch": 13, "s": ["400"] },
+ "Titillium Web": {
+ "x": 857,
+ "y": 560,
+ "w": 140,
+ "ch": 13,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "900",
+ "900i"
+ ]
+ },
+ "Tomorrow": {
+ "x": 1011,
+ "y": 560,
+ "w": 126,
+ "ch": 13,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Tourney": {
+ "x": 0,
+ "y": 600,
+ "w": 107,
+ "ch": 13,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Trade Winds": { "x": 121, "y": 600, "w": 160, "ch": 13, "s": ["400"] },
+ "Train One": { "x": 295, "y": 600, "w": 132, "ch": 13, "s": ["400"] },
+ "Triodion": { "x": 441, "y": 600, "w": 94, "ch": 13, "s": ["400"] },
+ "Trirong": {
+ "x": 549,
+ "y": 600,
+ "w": 92,
+ "ch": 13,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Trispace": {
+ "x": 655,
+ "y": 600,
+ "w": 128,
+ "ch": 13,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800"]
+ },
+ "Trocchi": { "x": 797, "y": 600, "w": 100, "ch": 13, "s": ["400"] },
+ "Trochut": {
+ "x": 911,
+ "y": 600,
+ "w": 75,
+ "ch": 13,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Truculenta": {
+ "x": 1000,
+ "y": 600,
+ "w": 100,
+ "ch": 13,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Trykker": { "x": 0, "y": 640, "w": 98, "ch": 13, "s": ["400"] },
+ "Tsukimi Rounded": {
+ "x": 112,
+ "y": 640,
+ "w": 209,
+ "ch": 13,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Tuffy": {
+ "x": 335,
+ "y": 640,
+ "w": 63,
+ "ch": 13,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Tulpen One": { "x": 412, "y": 640, "w": 59, "ch": 13, "s": ["400"] },
+ "Turret Road": {
+ "x": 485,
+ "y": 640,
+ "w": 142,
+ "ch": 13,
+ "s": ["200", "300", "400", "500", "700", "800"]
+ },
+ "Twinkle Star": { "x": 641, "y": 640, "w": 137, "ch": 13, "s": ["400"] },
+ "Ubuntu": {
+ "x": 792,
+ "y": 640,
+ "w": 90,
+ "ch": 13,
+ "s": ["300", "300i", "400", "400i", "500", "500i", "700", "700i"]
+ },
+ "Ubuntu Condensed": {
+ "x": 896,
+ "y": 640,
+ "w": 177,
+ "ch": 13,
+ "s": ["400"]
+ },
+ "Ubuntu Mono": {
+ "x": 0,
+ "y": 680,
+ "w": 156,
+ "ch": 13,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Ubuntu Sans": {
+ "x": 170,
+ "y": 680,
+ "w": 144,
+ "ch": 13,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Ubuntu Sans Mono": {
+ "x": 328,
+ "y": 680,
+ "w": 224,
+ "ch": 13,
+ "s": ["400", "400i", "500", "500i", "600", "600i", "700", "700i"]
+ },
+ "Uchen": { "x": 566, "y": 680, "w": 75, "ch": 13, "s": ["400"] },
+ "Ultra": { "x": 655, "y": 680, "w": 85, "ch": 13, "s": ["400"] },
+ "Unbounded": {
+ "x": 754,
+ "y": 680,
+ "w": 174,
+ "ch": 13,
+ "s": ["200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Uncial Antiqua": { "x": 942, "y": 680, "w": 211, "ch": 13, "s": ["400"] },
+ "Underdog": { "x": 0, "y": 720, "w": 113, "ch": 13, "s": ["400"] },
+ "Unica One": { "x": 127, "y": 720, "w": 106, "ch": 13, "s": ["400"] },
+ "UnifrakturMaguntia": {
+ "x": 247,
+ "y": 720,
+ "w": 204,
+ "ch": 13,
+ "s": ["400"]
+ },
+ "Unkempt": { "x": 465, "y": 720, "w": 99, "ch": 13, "s": ["400", "700"] },
+ "Unlock": { "x": 578, "y": 720, "w": 93, "ch": 13, "s": ["400"] },
+ "Unna": {
+ "x": 685,
+ "y": 720,
+ "w": 60,
+ "ch": 13,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "UoqMunThenKhung": { "x": 759, "y": 720, "w": 245, "ch": 13, "s": ["400"] },
+ "Updock": { "x": 1018, "y": 720, "w": 58, "ch": 13, "s": ["400"] },
+ "Urbanist": {
+ "x": 1090,
+ "y": 720,
+ "w": 97,
+ "ch": 13,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Vampiro One": { "x": 0, "y": 760, "w": 165, "ch": 13, "s": ["400"] },
+ "Varela": { "x": 179, "y": 760, "w": 81, "ch": 13, "s": ["400"] },
+ "Varela Round": { "x": 274, "y": 760, "w": 161, "ch": 13, "s": ["400"] },
+ "Varta": {
+ "x": 449,
+ "y": 760,
+ "w": 61,
+ "ch": 13,
+ "s": ["300", "400", "500", "600", "700"]
+ },
+ "Vast Shadow": { "x": 524, "y": 760, "w": 227, "ch": 13, "s": ["400"] },
+ "Vazirmatn": {
+ "x": 765,
+ "y": 760,
+ "w": 117,
+ "ch": 13,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Vend Sans": {
+ "x": 896,
+ "y": 760,
+ "w": 121,
+ "ch": 13,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Vesper Libre": {
+ "x": 1031,
+ "y": 760,
+ "w": 136,
+ "ch": 13,
+ "s": ["400", "500", "700", "900"]
+ },
+ "Viaoda Libre": { "x": 0, "y": 0, "w": 126, "ch": 14, "s": ["400"] },
+ "Vibes": { "x": 140, "y": 0, "w": 53, "ch": 14, "s": ["400"] },
+ "Vibur": { "x": 207, "y": 0, "w": 61, "ch": 14, "s": ["400"] },
+ "Victor Mono": {
+ "x": 282,
+ "y": 0,
+ "w": 167,
+ "ch": 14,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Vidaloka": { "x": 463, "y": 0, "w": 99, "ch": 14, "s": ["400"] },
+ "Viga": { "x": 576, "y": 0, "w": 55, "ch": 14, "s": ["400"] },
+ "Vina Sans": { "x": 645, "y": 0, "w": 86, "ch": 14, "s": ["400"] },
+ "Voces": { "x": 745, "y": 0, "w": 72, "ch": 14, "s": ["400"] },
+ "Volkhov": {
+ "x": 831,
+ "y": 0,
+ "w": 104,
+ "ch": 14,
+ "s": ["400", "400i", "700", "700i"]
+ },
+ "Vollkorn": {
+ "x": 949,
+ "y": 0,
+ "w": 99,
+ "ch": 14,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Vollkorn SC": {
+ "x": 0,
+ "y": 40,
+ "w": 166,
+ "ch": 14,
+ "s": ["400", "600", "700", "900"]
+ },
+ "Voltaire": { "x": 180, "y": 40, "w": 73, "ch": 14, "s": ["400"] },
+ "VT323": { "x": 267, "y": 40, "w": 56, "ch": 14, "s": ["400"] },
+ "Vujahday Script": { "x": 337, "y": 40, "w": 151, "ch": 14, "s": ["400"] },
+ "Waiting for the Sunrise": {
+ "x": 502,
+ "y": 40,
+ "w": 230,
+ "ch": 14,
+ "s": ["400"]
+ },
+ "Wallpoet": { "x": 746, "y": 40, "w": 127, "ch": 14, "s": ["400"] },
+ "Walter Turncoat": { "x": 887, "y": 40, "w": 206, "ch": 14, "s": ["400"] },
+ "Warnes": { "x": 0, "y": 80, "w": 120, "ch": 14, "s": ["400"] },
+ "Water Brush": { "x": 134, "y": 80, "w": 116, "ch": 14, "s": ["400"] },
+ "Waterfall": { "x": 264, "y": 80, "w": 77, "ch": 14, "s": ["400"] },
+ "Wavefont": {
+ "x": 355,
+ "y": 80,
+ "w": 28,
+ "ch": 14,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "WDXL Lubrifont JP N": {
+ "x": 397,
+ "y": 80,
+ "w": 185,
+ "ch": 14,
+ "s": ["400"]
+ },
+ "WDXL Lubrifont SC": {
+ "x": 596,
+ "y": 80,
+ "w": 170,
+ "ch": 14,
+ "s": ["400"]
+ },
+ "WDXL Lubrifont TC": {
+ "x": 780,
+ "y": 80,
+ "w": 168,
+ "ch": 14,
+ "s": ["400"]
+ },
+ "Wellfleet": { "x": 962, "y": 80, "w": 117, "ch": 14, "s": ["400"] },
+ "Wendy One": { "x": 0, "y": 120, "w": 142, "ch": 14, "s": ["400"] },
+ "Whisper": { "x": 156, "y": 120, "w": 76, "ch": 14, "s": ["400"] },
+ "WindSong": { "x": 246, "y": 120, "w": 133, "ch": 14, "s": ["400", "500"] },
+ "Winky Rough": {
+ "x": 393,
+ "y": 120,
+ "w": 139,
+ "ch": 14,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Winky Sans": {
+ "x": 546,
+ "y": 120,
+ "w": 120,
+ "ch": 14,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Wire One": { "x": 680, "y": 120, "w": 57, "ch": 14, "s": ["400"] },
+ "Wittgenstein": {
+ "x": 751,
+ "y": 120,
+ "w": 151,
+ "ch": 14,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Wix Madefor Display": {
+ "x": 916,
+ "y": 120,
+ "w": 238,
+ "ch": 14,
+ "s": ["400", "500", "600", "700", "800"]
+ },
+ "Wix Madefor Text": {
+ "x": 0,
+ "y": 160,
+ "w": 202,
+ "ch": 14,
+ "s": [
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i"
+ ]
+ },
+ "Work Sans": {
+ "x": 216,
+ "y": 160,
+ "w": 132,
+ "ch": 14,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Workbench": { "x": 362, "y": 160, "w": 116, "ch": 14, "s": ["400"] },
+ "Xanh Mono": {
+ "x": 492,
+ "y": 160,
+ "w": 116,
+ "ch": 14,
+ "s": ["400", "400i"]
+ },
+ "Yaldevi": {
+ "x": 622,
+ "y": 160,
+ "w": 83,
+ "ch": 14,
+ "s": ["200", "300", "400", "500", "600", "700"]
+ },
+ "Yanone Kaffeesatz": {
+ "x": 719,
+ "y": 160,
+ "w": 142,
+ "ch": 14,
+ "s": ["200", "300", "400", "500", "600", "700"]
+ },
+ "Yantramanav": {
+ "x": 875,
+ "y": 160,
+ "w": 138,
+ "ch": 14,
+ "s": ["100", "300", "400", "500", "700", "900"]
+ },
+ "Yarndings 12": { "x": 0, "y": 200, "w": 226, "ch": 14, "s": ["400"] },
+ "Yarndings 12 Charted": {
+ "x": 240,
+ "y": 200,
+ "w": 365,
+ "ch": 14,
+ "s": ["400"]
+ },
+ "Yarndings 20": { "x": 619, "y": 200, "w": 219, "ch": 14, "s": ["400"] },
+ "Yarndings 20 Charted": {
+ "x": 0,
+ "y": 240,
+ "w": 357,
+ "ch": 14,
+ "s": ["400"]
+ },
+ "Yatra One": { "x": 371, "y": 240, "w": 127, "ch": 14, "s": ["400"] },
+ "Yellowtail": { "x": 512, "y": 240, "w": 93, "ch": 14, "s": ["400"] },
+ "Yeon Sung": { "x": 619, "y": 240, "w": 107, "ch": 14, "s": ["400"] },
+ "Yeseva One": { "x": 740, "y": 240, "w": 144, "ch": 14, "s": ["400"] },
+ "Yesteryear": { "x": 898, "y": 240, "w": 94, "ch": 14, "s": ["400"] },
+ "Yomogi": { "x": 1006, "y": 240, "w": 80, "ch": 14, "s": ["400"] },
+ "Young Serif": { "x": 0, "y": 280, "w": 149, "ch": 14, "s": ["400"] },
+ "Yrsa": {
+ "x": 163,
+ "y": 280,
+ "w": 49,
+ "ch": 14,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Ysabeau": {
+ "x": 226,
+ "y": 280,
+ "w": 88,
+ "ch": 14,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Ysabeau Infant": {
+ "x": 328,
+ "y": 280,
+ "w": 161,
+ "ch": 14,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Ysabeau Office": {
+ "x": 503,
+ "y": 280,
+ "w": 156,
+ "ch": 14,
+ "s": [
+ "100",
+ "100i",
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Ysabeau SC": {
+ "x": 673,
+ "y": 280,
+ "w": 130,
+ "ch": 14,
+ "s": ["100", "200", "300", "400", "500", "600", "700", "800", "900"]
+ },
+ "Yuji Boku": { "x": 817, "y": 280, "w": 135, "ch": 14, "s": ["400"] },
+ "Yuji Hentaigana Akari": {
+ "x": 0,
+ "y": 320,
+ "w": 335,
+ "ch": 14,
+ "s": ["400"]
+ },
+ "Yuji Hentaigana Akebono": {
+ "x": 349,
+ "y": 320,
+ "w": 374,
+ "ch": 14,
+ "s": ["400"]
+ },
+ "Yuji Mai": { "x": 737, "y": 320, "w": 117, "ch": 14, "s": ["400"] },
+ "Yuji Syuku": { "x": 868, "y": 320, "w": 144, "ch": 14, "s": ["400"] },
+ "Yusei Magic": { "x": 1026, "y": 320, "w": 138, "ch": 14, "s": ["400"] },
+ "Zain": {
+ "x": 0,
+ "y": 360,
+ "w": 54,
+ "ch": 14,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Zalando Sans": {
+ "x": 68,
+ "y": 360,
+ "w": 161,
+ "ch": 14,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Zalando Sans Expanded": {
+ "x": 243,
+ "y": 360,
+ "w": 335,
+ "ch": 14,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "Zalando Sans SemiExpanded": {
+ "x": 592,
+ "y": 360,
+ "w": 361,
+ "ch": 14,
+ "s": [
+ "200",
+ "200i",
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i",
+ "800",
+ "800i",
+ "900",
+ "900i"
+ ]
+ },
+ "ZCOOL KuaiLe": { "x": 967, "y": 360, "w": 180, "ch": 14, "s": ["400"] },
+ "ZCOOL QingKe HuangYou": {
+ "x": 0,
+ "y": 400,
+ "w": 222,
+ "ch": 14,
+ "s": ["400"]
+ },
+ "ZCOOL XiaoWei": { "x": 236, "y": 400, "w": 173, "ch": 14, "s": ["400"] },
+ "Zen Antique": { "x": 423, "y": 400, "w": 149, "ch": 14, "s": ["400"] },
+ "Zen Antique Soft": {
+ "x": 586,
+ "y": 400,
+ "w": 204,
+ "ch": 14,
+ "s": ["400"]
+ },
+ "Zen Dots": { "x": 804, "y": 400, "w": 147, "ch": 14, "s": ["400"] },
+ "Zen Kaku Gothic Antique": {
+ "x": 0,
+ "y": 440,
+ "w": 267,
+ "ch": 14,
+ "s": ["300", "400", "500", "700", "900"]
+ },
+ "Zen Kaku Gothic New": {
+ "x": 281,
+ "y": 440,
+ "w": 231,
+ "ch": 14,
+ "s": ["300", "400", "500", "700", "900"]
+ },
+ "Zen Kurenaido": { "x": 526, "y": 440, "w": 145, "ch": 14, "s": ["400"] },
+ "Zen Loop": { "x": 685, "y": 440, "w": 65, "ch": 14, "s": ["400", "400i"] },
+ "Zen Maru Gothic": {
+ "x": 764,
+ "y": 440,
+ "w": 181,
+ "ch": 14,
+ "s": ["300", "400", "500", "700", "900"]
+ },
+ "Zen Old Mincho": {
+ "x": 959,
+ "y": 440,
+ "w": 173,
+ "ch": 14,
+ "s": ["400", "500", "600", "700", "900"]
+ },
+ "Zen Tokyo Zoo": { "x": 0, "y": 480, "w": 154, "ch": 14, "s": ["400"] },
+ "Zeyada": { "x": 168, "y": 480, "w": 70, "ch": 14, "s": ["400"] },
+ "Zhi Mang Xing": { "x": 252, "y": 480, "w": 121, "ch": 14, "s": ["400"] },
+ "Zilla Slab": {
+ "x": 387,
+ "y": 480,
+ "w": 102,
+ "ch": 14,
+ "s": [
+ "300",
+ "300i",
+ "400",
+ "400i",
+ "500",
+ "500i",
+ "600",
+ "600i",
+ "700",
+ "700i"
+ ]
+ },
+ "Zilla Slab Highlight": {
+ "x": 503,
+ "y": 480,
+ "w": 207,
+ "ch": 14,
+ "s": ["400", "700"]
+ }
+ }
+}
diff --git a/apps/web/public/fonts/font-chunk-0.avif b/apps/web/public/fonts/font-chunk-0.avif
new file mode 100644
index 00000000..719b4235
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-0.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-1.avif b/apps/web/public/fonts/font-chunk-1.avif
new file mode 100644
index 00000000..c276430b
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-1.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-10.avif b/apps/web/public/fonts/font-chunk-10.avif
new file mode 100644
index 00000000..9761e954
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-10.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-11.avif b/apps/web/public/fonts/font-chunk-11.avif
new file mode 100644
index 00000000..d4f788e0
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-11.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-12.avif b/apps/web/public/fonts/font-chunk-12.avif
new file mode 100644
index 00000000..91e643ae
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-12.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-13.avif b/apps/web/public/fonts/font-chunk-13.avif
new file mode 100644
index 00000000..6a507e8b
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-13.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-14.avif b/apps/web/public/fonts/font-chunk-14.avif
new file mode 100644
index 00000000..f67ab51b
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-14.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-2.avif b/apps/web/public/fonts/font-chunk-2.avif
new file mode 100644
index 00000000..bc3ca018
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-2.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-3.avif b/apps/web/public/fonts/font-chunk-3.avif
new file mode 100644
index 00000000..a5b9dd60
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-3.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-4.avif b/apps/web/public/fonts/font-chunk-4.avif
new file mode 100644
index 00000000..505fcb25
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-4.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-5.avif b/apps/web/public/fonts/font-chunk-5.avif
new file mode 100644
index 00000000..7d8c0216
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-5.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-6.avif b/apps/web/public/fonts/font-chunk-6.avif
new file mode 100644
index 00000000..d318967d
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-6.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-7.avif b/apps/web/public/fonts/font-chunk-7.avif
new file mode 100644
index 00000000..0dcb81d7
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-7.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-8.avif b/apps/web/public/fonts/font-chunk-8.avif
new file mode 100644
index 00000000..63b0e35a
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-8.avif
Binary files differ
diff --git a/apps/web/public/fonts/font-chunk-9.avif b/apps/web/public/fonts/font-chunk-9.avif
new file mode 100644
index 00000000..e7936bf0
--- /dev/null
+++ b/apps/web/public/fonts/font-chunk-9.avif
Binary files differ
diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json
index d4a41dc0..2f00e1e9 100644
--- a/apps/web/public/manifest.json
+++ b/apps/web/public/manifest.json
@@ -1,44 +1,44 @@
{
- "name": "OpenCut",
- "description": "A simple but powerful video editor that gets the job done. In your browser.",
- "display": "standalone",
- "start_url": "/",
- "icons": [
- {
- "src": "/icons/android-icon-36x36.png",
- "sizes": "36x36",
- "type": "image\/png",
- "density": "0.75"
- },
- {
- "src": "/icons/android-icon-48x48.png",
- "sizes": "48x48",
- "type": "image\/png",
- "density": "1.0"
- },
- {
- "src": "/icons/android-icon-72x72.png",
- "sizes": "72x72",
- "type": "image\/png",
- "density": "1.5"
- },
- {
- "src": "/icons/android-icon-96x96.png",
- "sizes": "96x96",
- "type": "image\/png",
- "density": "2.0"
- },
- {
- "src": "/icons/android-icon-144x144.png",
- "sizes": "144x144",
- "type": "image\/png",
- "density": "3.0"
- },
- {
- "src": "/icons/android-icon-192x192.png",
- "sizes": "192x192",
- "type": "image\/png",
- "density": "4.0"
- }
- ]
+ "name": "OpenCut",
+ "description": "A simple but powerful video editor that gets the job done. In your browser.",
+ "display": "standalone",
+ "start_url": "/",
+ "icons": [
+ {
+ "src": "/icons/android-icon-36x36.png",
+ "sizes": "36x36",
+ "type": "image\/png",
+ "density": "0.75"
+ },
+ {
+ "src": "/icons/android-icon-48x48.png",
+ "sizes": "48x48",
+ "type": "image\/png",
+ "density": "1.0"
+ },
+ {
+ "src": "/icons/android-icon-72x72.png",
+ "sizes": "72x72",
+ "type": "image\/png",
+ "density": "1.5"
+ },
+ {
+ "src": "/icons/android-icon-96x96.png",
+ "sizes": "96x96",
+ "type": "image\/png",
+ "density": "2.0"
+ },
+ {
+ "src": "/icons/android-icon-144x144.png",
+ "sizes": "144x144",
+ "type": "image\/png",
+ "density": "3.0"
+ },
+ {
+ "src": "/icons/android-icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image\/png",
+ "density": "4.0"
+ }
+ ]
}
diff --git a/apps/web/public/shapes/circle.svg b/apps/web/public/shapes/circle.svg
new file mode 100644
index 00000000..adb837cf
--- /dev/null
+++ b/apps/web/public/shapes/circle.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
+ <circle cx="50" cy="50" r="44" fill="currentColor" />
+</svg>
diff --git a/apps/web/public/shapes/diamond.svg b/apps/web/public/shapes/diamond.svg
new file mode 100644
index 00000000..4a039b5c
--- /dev/null
+++ b/apps/web/public/shapes/diamond.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
+ <polygon points="50,6 94,50 50,94 6,50" fill="currentColor" />
+</svg>
diff --git a/apps/web/public/shapes/hexagon.svg b/apps/web/public/shapes/hexagon.svg
new file mode 100644
index 00000000..e1eb087e
--- /dev/null
+++ b/apps/web/public/shapes/hexagon.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
+ <polygon points="28,8 72,8 94,50 72,92 28,92 6,50" fill="currentColor" />
+</svg>
diff --git a/apps/web/public/shapes/square.svg b/apps/web/public/shapes/square.svg
new file mode 100644
index 00000000..ed9b790f
--- /dev/null
+++ b/apps/web/public/shapes/square.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
+ <rect x="10" y="10" width="80" height="80" fill="currentColor" />
+</svg>
diff --git a/apps/web/public/shapes/star.svg b/apps/web/public/shapes/star.svg
new file mode 100644
index 00000000..c49e0c53
--- /dev/null
+++ b/apps/web/public/shapes/star.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
+ <polygon points="50,6 61,37 94,37 67,56 77,88 50,68 23,88 33,56 6,37 39,37" fill="currentColor" />
+</svg>
diff --git a/apps/web/public/shapes/triangle.svg b/apps/web/public/shapes/triangle.svg
new file mode 100644
index 00000000..a1bd6b0c
--- /dev/null
+++ b/apps/web/public/shapes/triangle.svg
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
+ <polygon points="50,8 94,92 6,92" fill="currentColor" />
+</svg>
diff --git a/apps/web/scripts/generate-font-sprites.ts b/apps/web/scripts/generate-font-sprites.ts
new file mode 100644
index 00000000..144d68ec
--- /dev/null
+++ b/apps/web/scripts/generate-font-sprites.ts
@@ -0,0 +1,281 @@
+/**
+ * Generates font sprite atlas for the font picker.
+ *
+ * Downloads Google Fonts from Fontsource, renders each font name as a sprite,
+ * packs them into chunk images, and outputs a JSON atlas + AVIF images.
+ *
+ * Run: npx tsx scripts/generate-font-sprites.ts
+ * Deps: @napi-rs/canvas sharp (install as devDependencies)
+ */
+
+import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
+import sharp from "sharp";
+import { mkdir, writeFile, readFile } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { join, dirname } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+const FONT_SIZE = 24;
+const ROW_HEIGHT = 40;
+const CANVAS_WIDTH = 1200;
+const MAX_CHUNK_HEIGHT = 800;
+const PADDING_X = 14;
+const WIDTH_BUFFER = 8;
+const CONCURRENT_DOWNLOADS = 30;
+
+const OUTPUT_DIR = join(__dirname, "..", "public", "fonts");
+const CACHE_DIR = join(__dirname, "..", ".font-cache");
+const FONTSOURCE_API = "https://api.fontsource.org/v1/fonts";
+
+interface FontsourceFont {
+ id: string;
+ family: string;
+ subsets: string[];
+ weights: number[];
+ styles: string[];
+ category: string;
+ type: string;
+}
+
+interface MeasuredFont {
+ family: string;
+ width: number;
+ styles: string[];
+}
+
+interface PackedFont {
+ family: string;
+ x: number;
+ y: number;
+ w: number;
+}
+
+interface AtlasEntry {
+ x: number;
+ y: number;
+ w: number;
+ ch: number;
+ s: string[];
+}
+
+async function fetchFontList(): Promise<FontsourceFont[]> {
+ console.log("Fetching font list from Fontsource...");
+ const response = await fetch(FONTSOURCE_API);
+ if (!response.ok) throw new Error(`Fontsource API error: ${response.status}`);
+
+ const data: FontsourceFont[] = await response.json();
+ const filtered = data.filter(
+ (font) =>
+ font.type === "google" &&
+ font.subsets.includes("latin") &&
+ font.weights.includes(400),
+ );
+
+ console.log(
+ ` ${filtered.length} Google fonts with Latin subset + 400 weight`,
+ );
+ return filtered;
+}
+
+async function downloadFont({ id }: { id: string }): Promise<Buffer | null> {
+ const cachePath = join(CACHE_DIR, `${id}.woff2`);
+
+ if (existsSync(cachePath)) {
+ return readFile(cachePath);
+ }
+
+ // Fontsource CDN serves woff2 for all Google fonts
+ const url = `https://cdn.jsdelivr.net/fontsource/fonts/${id}@latest/latin-400-normal.woff2`;
+ try {
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const buffer = Buffer.from(await response.arrayBuffer());
+ await writeFile(cachePath, buffer);
+ return buffer;
+ } catch {
+ return null;
+ }
+}
+
+async function downloadAllFonts({
+ fonts,
+ concurrency,
+}: {
+ fonts: FontsourceFont[];
+ concurrency: number;
+}): Promise<Map<string, Buffer>> {
+ console.log(
+ `Downloading ${fonts.length} font files (${concurrency} concurrent)...`,
+ );
+ const results = new Map<string, Buffer>();
+ let nextIndex = 0;
+ let completed = 0;
+
+ async function worker() {
+ while (nextIndex < fonts.length) {
+ const index = nextIndex++;
+ const font = fonts[index];
+ const buffer = await downloadFont({ id: font.id });
+ if (buffer) results.set(font.id, buffer);
+ completed++;
+ if (completed % 100 === 0 || completed === fonts.length) {
+ process.stdout.write(`\r ${completed}/${fonts.length}`);
+ }
+ }
+ }
+
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
+ console.log(`\n Downloaded ${results.size}/${fonts.length} fonts`);
+ return results;
+}
+
+function measureFonts({
+ fonts,
+ fontBuffers,
+}: {
+ fonts: FontsourceFont[];
+ fontBuffers: Map<string, Buffer>;
+}): MeasuredFont[] {
+ console.log("Registering fonts and measuring text...");
+ const measured: MeasuredFont[] = [];
+ const canvas = createCanvas(CANVAS_WIDTH, ROW_HEIGHT);
+ const ctx = canvas.getContext("2d");
+
+ for (const font of fonts) {
+ const buffer = fontBuffers.get(font.id);
+ if (!buffer) continue;
+
+ try {
+ const ok = GlobalFonts.register(buffer, font.family);
+ if (!ok) continue;
+
+ ctx.font = `${FONT_SIZE}px "${font.family}"`;
+ const metrics = ctx.measureText(font.family);
+ const width = Math.ceil(metrics.width) + WIDTH_BUFFER;
+
+ const styles: string[] = [];
+ for (const weight of font.weights) {
+ if (font.styles.includes("normal")) styles.push(String(weight));
+ if (font.styles.includes("italic")) styles.push(`${weight}i`);
+ }
+
+ measured.push({ family: font.family, width, styles });
+ } catch {
+ // skip fonts that fail to register
+ }
+ }
+
+ measured.sort((a, b) => a.family.localeCompare(b.family));
+ console.log(` ${measured.length} fonts measured`);
+ return measured;
+}
+
+function packIntoChunks({ measured }: { measured: MeasuredFont[] }): {
+ atlas: Record<string, AtlasEntry>;
+ chunks: PackedFont[][];
+} {
+ console.log("Packing into sprite chunks...");
+ const atlas: Record<string, AtlasEntry> = {};
+ const chunks: PackedFont[][] = [[]];
+ let chunkIndex = 0;
+ let cursorX = 0;
+ let cursorY = 0;
+
+ for (const font of measured) {
+ // New row if doesn't fit horizontally
+ if (cursorX + font.width > CANVAS_WIDTH) {
+ cursorX = 0;
+ cursorY += ROW_HEIGHT;
+ }
+
+ // New chunk if doesn't fit vertically
+ if (cursorY + ROW_HEIGHT > MAX_CHUNK_HEIGHT) {
+ chunkIndex++;
+ chunks.push([]);
+ cursorX = 0;
+ cursorY = 0;
+ }
+
+ // Same data goes to BOTH the atlas and the chunk render list
+ const x = cursorX;
+ const y = cursorY;
+
+ atlas[font.family] = {
+ x,
+ y,
+ w: font.width,
+ ch: chunkIndex,
+ s: font.styles,
+ };
+ chunks[chunkIndex].push({ family: font.family, x, y, w: font.width });
+
+ cursorX += font.width + PADDING_X;
+ }
+
+ console.log(` ${chunks.length} chunks`);
+ return { atlas, chunks };
+}
+
+async function renderChunks({
+ chunks,
+}: {
+ chunks: PackedFont[][];
+}): Promise<void> {
+ console.log("Rendering sprite chunks...");
+
+ for (let i = 0; i < chunks.length; i++) {
+ const chunk = chunks[i];
+ const chunkHeight = Math.max(...chunk.map((f) => f.y)) + ROW_HEIGHT;
+
+ const canvas = createCanvas(CANVAS_WIDTH, chunkHeight);
+ const ctx = canvas.getContext("2d");
+
+ for (const font of chunk) {
+ ctx.font = `${FONT_SIZE}px "${font.family}"`;
+ ctx.fillStyle = "#000000";
+ ctx.textBaseline = "middle";
+ ctx.fillText(font.family, font.x, font.y + ROW_HEIGHT / 2);
+ }
+
+ const pngBuffer = canvas.toBuffer("image/png");
+ await sharp(pngBuffer)
+ .avif({ quality: 80 })
+ .toFile(join(OUTPUT_DIR, `font-chunk-${i}.avif`));
+
+ console.log(
+ ` Chunk ${i}: ${chunk.length} fonts, ${CANVAS_WIDTH}×${chunkHeight}`,
+ );
+ }
+}
+
+async function main() {
+ await mkdir(OUTPUT_DIR, { recursive: true });
+ await mkdir(CACHE_DIR, { recursive: true });
+
+ const fonts = await fetchFontList();
+ const fontBuffers = await downloadAllFonts({
+ fonts,
+ concurrency: CONCURRENT_DOWNLOADS,
+ });
+ const measured = measureFonts({ fonts, fontBuffers });
+ const { atlas, chunks } = packIntoChunks({ measured });
+ await renderChunks({ chunks });
+
+ // Write atlas JSON (compact for smaller file size)
+ await writeFile(
+ join(OUTPUT_DIR, "font-atlas.json"),
+ JSON.stringify({ fonts: atlas }),
+ );
+
+ const totalFonts = Object.keys(atlas).length;
+ console.log(
+ `\nDone! ${totalFonts} fonts in ${chunks.length} chunks → ${OUTPUT_DIR}`,
+ );
+}
+
+main().catch((error) => {
+ console.error("Failed:", error);
+ process.exit(1);
+});
diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts
index 3070d152..339a54da 100644
--- a/apps/web/src/app/api/sounds/search/route.ts
+++ b/apps/web/src/app/api/sounds/search/route.ts
@@ -1,280 +1,280 @@
-import { webEnv } from "@opencut/env/web";
-import { type NextRequest, NextResponse } from "next/server";
-import { z } from "zod";
-import { checkRateLimit } from "@/lib/rate-limit";
-
-const searchParamsSchema = z.object({
- q: z.string().max(500, "Query too long").optional(),
- type: z.enum(["songs", "effects"]).optional(),
- page: z.coerce.number().int().min(1).max(1000).default(1),
- page_size: z.coerce.number().int().min(1).max(150).default(20),
- sort: z
- .enum(["downloads", "rating", "created", "score"])
- .default("downloads"),
- min_rating: z.coerce.number().min(0).max(5).default(3),
- commercial_only: z.coerce.boolean().default(true),
-});
-
-const freesoundResultSchema = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string(),
- url: z.string().url(),
- previews: z
- .object({
- "preview-hq-mp3": z.string().url(),
- "preview-lq-mp3": z.string().url(),
- "preview-hq-ogg": z.string().url(),
- "preview-lq-ogg": z.string().url(),
- })
- .optional(),
- download: z.string().url().optional(),
- duration: z.number(),
- filesize: z.number(),
- type: z.string(),
- channels: z.number(),
- bitrate: z.number(),
- bitdepth: z.number(),
- samplerate: z.number(),
- username: z.string(),
- tags: z.array(z.string()),
- license: z.string(),
- created: z.string(),
- num_downloads: z.number().optional(),
- avg_rating: z.number().optional(),
- num_ratings: z.number().optional(),
-});
-
-const freesoundResponseSchema = z.object({
- count: z.number(),
- next: z.string().url().nullable(),
- previous: z.string().url().nullable(),
- results: z.array(freesoundResultSchema),
-});
-
-const transformedResultSchema = z.object({
- id: z.number(),
- name: z.string(),
- description: z.string(),
- url: z.string(),
- previewUrl: z.string().optional(),
- downloadUrl: z.string().optional(),
- duration: z.number(),
- filesize: z.number(),
- type: z.string(),
- channels: z.number(),
- bitrate: z.number(),
- bitdepth: z.number(),
- samplerate: z.number(),
- username: z.string(),
- tags: z.array(z.string()),
- license: z.string(),
- created: z.string(),
- downloads: z.number().optional(),
- rating: z.number().optional(),
- ratingCount: z.number().optional(),
-});
-
-const apiResponseSchema = z.object({
- count: z.number(),
- next: z.string().nullable(),
- previous: z.string().nullable(),
- results: z.array(transformedResultSchema),
- query: z.string().optional(),
- type: z.string(),
- page: z.number(),
- pageSize: z.number(),
- sort: z.string(),
- minRating: z.number().optional(),
-});
-
-function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
- if (!query) return `${sort}_desc`;
- return sort === "score" ? "score" : `${sort}_desc`;
-}
-
-function applyEffectsFilters({
- params,
- min_rating,
- commercial_only,
-}: {
- params: URLSearchParams;
- min_rating: number;
- commercial_only: boolean;
-}) {
- params.append("filter", "duration:[* TO 30.0]");
- params.append("filter", `avg_rating:[${min_rating} TO *]`);
-
- if (commercial_only) {
- params.append(
- "filter",
- 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
- );
- }
-
- params.append(
- "filter",
- "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
- );
-}
-
-function transformFreesoundResult(
- result: z.infer<typeof freesoundResultSchema>,
-) {
- return {
- id: result.id,
- name: result.name,
- description: result.description,
- url: result.url,
- previewUrl:
- result.previews?.["preview-hq-mp3"] ||
- result.previews?.["preview-lq-mp3"],
- downloadUrl: result.download,
- duration: result.duration,
- filesize: result.filesize,
- type: result.type,
- channels: result.channels,
- bitrate: result.bitrate,
- bitdepth: result.bitdepth,
- samplerate: result.samplerate,
- username: result.username,
- tags: result.tags,
- license: result.license,
- created: result.created,
- downloads: result.num_downloads || 0,
- rating: result.avg_rating || 0,
- ratingCount: result.num_ratings || 0,
- };
-}
-
-export async function GET(request: NextRequest) {
- try {
- const { limited } = await checkRateLimit({ request });
- if (limited) {
- return NextResponse.json({ error: "Too many requests" }, { status: 429 });
- }
-
- const { searchParams } = new URL(request.url);
-
- const validationResult = searchParamsSchema.safeParse({
- q: searchParams.get("q") || undefined,
- type: searchParams.get("type") || undefined,
- page: searchParams.get("page") || undefined,
- page_size: searchParams.get("page_size") || undefined,
- sort: searchParams.get("sort") || undefined,
- min_rating: searchParams.get("min_rating") || undefined,
- });
-
- if (!validationResult.success) {
- return NextResponse.json(
- {
- error: "Invalid parameters",
- details: validationResult.error.flatten().fieldErrors,
- },
- { status: 400 },
- );
- }
-
- const {
- q: query,
- type,
- page,
- page_size: pageSize,
- sort,
- min_rating,
- commercial_only,
- } = validationResult.data;
-
- if (type === "songs") {
- return NextResponse.json(
- {
- error: "Songs are not available yet",
- message:
- "Song search functionality is coming soon. Try searching for sound effects instead.",
- },
- { status: 501 },
- );
- }
-
- const baseUrl = "https://freesound.org/apiv2/search/text/";
-
- const sortParam = buildSortParameter({ query, sort });
-
- const params = new URLSearchParams({
- query: query || "",
- token: webEnv.FREESOUND_API_KEY,
- page: page.toString(),
- page_size: pageSize.toString(),
- sort: sortParam,
- fields:
- "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
- });
-
- const isEffectsSearch = type === "effects" || !type;
- if (isEffectsSearch) {
- applyEffectsFilters({ params, min_rating, commercial_only });
- }
-
- const response = await fetch(`${baseUrl}?${params.toString()}`);
-
- if (!response.ok) {
- const errorText = await response.text();
- console.error("Freesound API error:", response.status, errorText);
- return NextResponse.json(
- { error: "Failed to search sounds" },
- { status: response.status },
- );
- }
-
- const rawData = await response.json();
-
- const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
- if (!freesoundValidation.success) {
- console.error(
- "Invalid Freesound API response:",
- freesoundValidation.error,
- );
- return NextResponse.json(
- { error: "Invalid response from Freesound API" },
- { status: 502 },
- );
- }
-
- const data = freesoundValidation.data;
-
- const transformedResults = data.results.map(transformFreesoundResult);
-
- const responseData = {
- count: data.count,
- next: data.next,
- previous: data.previous,
- results: transformedResults,
- query: query || "",
- type: type || "effects",
- page,
- pageSize,
- sort,
- minRating: min_rating,
- };
-
- const responseValidation = apiResponseSchema.safeParse(responseData);
- if (!responseValidation.success) {
- console.error(
- "Invalid API response structure:",
- responseValidation.error,
- );
- return NextResponse.json(
- { error: "Internal response formatting error" },
- { status: 500 },
- );
- }
-
- return NextResponse.json(responseValidation.data);
- } catch (error) {
- console.error("Error searching sounds:", error);
- return NextResponse.json(
- { error: "Internal server error" },
- { status: 500 },
- );
- }
-}
+import { webEnv } from "@opencut/env/web";
+import { type NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { checkRateLimit } from "@/lib/rate-limit";
+
+const searchParamsSchema = z.object({
+ q: z.string().max(500, "Query too long").optional(),
+ type: z.enum(["songs", "effects"]).optional(),
+ page: z.coerce.number().int().min(1).max(1000).default(1),
+ page_size: z.coerce.number().int().min(1).max(150).default(20),
+ sort: z
+ .enum(["downloads", "rating", "created", "score"])
+ .default("downloads"),
+ min_rating: z.coerce.number().min(0).max(5).default(3),
+ commercial_only: z.coerce.boolean().default(true),
+});
+
+const freesoundResultSchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ description: z.string(),
+ url: z.string().url(),
+ previews: z
+ .object({
+ "preview-hq-mp3": z.string().url(),
+ "preview-lq-mp3": z.string().url(),
+ "preview-hq-ogg": z.string().url(),
+ "preview-lq-ogg": z.string().url(),
+ })
+ .optional(),
+ download: z.string().url().optional(),
+ duration: z.number(),
+ filesize: z.number(),
+ type: z.string(),
+ channels: z.number(),
+ bitrate: z.number(),
+ bitdepth: z.number(),
+ samplerate: z.number(),
+ username: z.string(),
+ tags: z.array(z.string()),
+ license: z.string(),
+ created: z.string(),
+ num_downloads: z.number().optional(),
+ avg_rating: z.number().optional(),
+ num_ratings: z.number().optional(),
+});
+
+const freesoundResponseSchema = z.object({
+ count: z.number(),
+ next: z.string().url().nullable(),
+ previous: z.string().url().nullable(),
+ results: z.array(freesoundResultSchema),
+});
+
+const transformedResultSchema = z.object({
+ id: z.number(),
+ name: z.string(),
+ description: z.string(),
+ url: z.string(),
+ previewUrl: z.string().optional(),
+ downloadUrl: z.string().optional(),
+ duration: z.number(),
+ filesize: z.number(),
+ type: z.string(),
+ channels: z.number(),
+ bitrate: z.number(),
+ bitdepth: z.number(),
+ samplerate: z.number(),
+ username: z.string(),
+ tags: z.array(z.string()),
+ license: z.string(),
+ created: z.string(),
+ downloads: z.number().optional(),
+ rating: z.number().optional(),
+ ratingCount: z.number().optional(),
+});
+
+const apiResponseSchema = z.object({
+ count: z.number(),
+ next: z.string().nullable(),
+ previous: z.string().nullable(),
+ results: z.array(transformedResultSchema),
+ query: z.string().optional(),
+ type: z.string(),
+ page: z.number(),
+ pageSize: z.number(),
+ sort: z.string(),
+ minRating: z.number().optional(),
+});
+
+function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
+ if (!query) return `${sort}_desc`;
+ return sort === "score" ? "score" : `${sort}_desc`;
+}
+
+function applyEffectsFilters({
+ params,
+ min_rating,
+ commercial_only,
+}: {
+ params: URLSearchParams;
+ min_rating: number;
+ commercial_only: boolean;
+}) {
+ params.append("filter", "duration:[* TO 30.0]");
+ params.append("filter", `avg_rating:[${min_rating} TO *]`);
+
+ if (commercial_only) {
+ params.append(
+ "filter",
+ 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
+ );
+ }
+
+ params.append(
+ "filter",
+ "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
+ );
+}
+
+function transformFreesoundResult(
+ result: z.infer<typeof freesoundResultSchema>,
+) {
+ return {
+ id: result.id,
+ name: result.name,
+ description: result.description,
+ url: result.url,
+ previewUrl:
+ result.previews?.["preview-hq-mp3"] ||
+ result.previews?.["preview-lq-mp3"],
+ downloadUrl: result.download,
+ duration: result.duration,
+ filesize: result.filesize,
+ type: result.type,
+ channels: result.channels,
+ bitrate: result.bitrate,
+ bitdepth: result.bitdepth,
+ samplerate: result.samplerate,
+ username: result.username,
+ tags: result.tags,
+ license: result.license,
+ created: result.created,
+ downloads: result.num_downloads || 0,
+ rating: result.avg_rating || 0,
+ ratingCount: result.num_ratings || 0,
+ };
+}
+
+export async function GET(request: NextRequest) {
+ try {
+ const { limited } = await checkRateLimit({ request });
+ if (limited) {
+ return NextResponse.json({ error: "Too many requests" }, { status: 429 });
+ }
+
+ const { searchParams } = new URL(request.url);
+
+ const validationResult = searchParamsSchema.safeParse({
+ q: searchParams.get("q") || undefined,
+ type: searchParams.get("type") || undefined,
+ page: searchParams.get("page") || undefined,
+ page_size: searchParams.get("page_size") || undefined,
+ sort: searchParams.get("sort") || undefined,
+ min_rating: searchParams.get("min_rating") || undefined,
+ });
+
+ if (!validationResult.success) {
+ return NextResponse.json(
+ {
+ error: "Invalid parameters",
+ details: validationResult.error.flatten().fieldErrors,
+ },
+ { status: 400 },
+ );
+ }
+
+ const {
+ q: query,
+ type,
+ page,
+ page_size: pageSize,
+ sort,
+ min_rating,
+ commercial_only,
+ } = validationResult.data;
+
+ if (type === "songs") {
+ return NextResponse.json(
+ {
+ error: "Songs are not available yet",
+ message:
+ "Song search functionality is coming soon. Try searching for sound effects instead.",
+ },
+ { status: 501 },
+ );
+ }
+
+ const baseUrl = "https://freesound.org/apiv2/search/text/";
+
+ const sortParam = buildSortParameter({ query, sort });
+
+ const params = new URLSearchParams({
+ query: query || "",
+ token: webEnv.FREESOUND_API_KEY,
+ page: page.toString(),
+ page_size: pageSize.toString(),
+ sort: sortParam,
+ fields:
+ "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
+ });
+
+ const isEffectsSearch = type === "effects" || !type;
+ if (isEffectsSearch) {
+ applyEffectsFilters({ params, min_rating, commercial_only });
+ }
+
+ const response = await fetch(`${baseUrl}?${params.toString()}`);
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ console.error("Freesound API error:", response.status, errorText);
+ return NextResponse.json(
+ { error: "Failed to search sounds" },
+ { status: response.status },
+ );
+ }
+
+ const rawData = await response.json();
+
+ const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
+ if (!freesoundValidation.success) {
+ console.error(
+ "Invalid Freesound API response:",
+ freesoundValidation.error,
+ );
+ return NextResponse.json(
+ { error: "Invalid response from Freesound API" },
+ { status: 502 },
+ );
+ }
+
+ const data = freesoundValidation.data;
+
+ const transformedResults = data.results.map(transformFreesoundResult);
+
+ const responseData = {
+ count: data.count,
+ next: data.next,
+ previous: data.previous,
+ results: transformedResults,
+ query: query || "",
+ type: type || "effects",
+ page,
+ pageSize,
+ sort,
+ minRating: min_rating,
+ };
+
+ const responseValidation = apiResponseSchema.safeParse(responseData);
+ if (!responseValidation.success) {
+ console.error(
+ "Invalid API response structure:",
+ responseValidation.error,
+ );
+ return NextResponse.json(
+ { error: "Internal response formatting error" },
+ { status: 500 },
+ );
+ }
+
+ return NextResponse.json(responseValidation.data);
+ } catch (error) {
+ console.error("Error searching sounds:", error);
+ return NextResponse.json(
+ { error: "Internal server error" },
+ { status: 500 },
+ );
+ }
+}
diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx
index 2c06cb90..e1afc3a8 100644
--- a/apps/web/src/app/blog/[slug]/page.tsx
+++ b/apps/web/src/app/blog/[slug]/page.tsx
@@ -120,7 +120,9 @@ function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) {
function PostTitle({ title }: { title: string }) {
return (
- <h1 className="text-5xl font-bold tracking-tight md:text-4xl text-center">{title}</h1>
+ <h1 className="text-5xl font-bold tracking-tight md:text-4xl text-center">
+ {title}
+ </h1>
);
}
diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx
index 97093912..987af7c6 100644
--- a/apps/web/src/app/editor/[project_id]/page.tsx
+++ b/apps/web/src/app/editor/[project_id]/page.tsx
@@ -15,6 +15,7 @@ import { EditorProvider } from "@/components/providers/editor-provider";
import { Onboarding } from "@/components/editor/onboarding";
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
import { usePanelStore } from "@/stores/panel-store";
+import { usePasteMedia } from "@/hooks/use-paste-media";
export default function Editor() {
const params = useParams();
@@ -35,6 +36,7 @@ export default function Editor() {
}
function EditorLayout() {
+ usePasteMedia();
const { panels, setPanel } = usePanelStore();
return (
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 37b5fe16..2d051c93 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -8,115 +8,115 @@
@plugin "tailwindcss-animate";
:root {
- --background: hsl(0, 0%, 100%);
- --foreground: hsl(0 0% 11%);
- --card: hsl(0, 0%, 100%);
- --card-foreground: hsl(0 0% 11%);
- --popover: hsl(0, 0%, 100%);
- --popover-hover: hsl(0, 0%, 96%);
- --popover-foreground: hsl(0 0% 2%);
- --primary: #009dff;
- --primary-foreground: hsl(0, 0%, 100%);
- --secondary: hsl(204, 100%, 97%);
- --secondary-border: hsl(204, 100%, 94%);
- --secondary-foreground: hsl(200, 98%, 39%);
- --muted: hsl(0 0% 85.1%);
- --muted-foreground: hsl(0 0% 50%);
- --accent: hsl(0, 0%, 96%);
- --accent-foreground: hsl(0 0% 2%);
- --destructive: hsl(0, 83%, 50%);
- --destructive-foreground: hsl(0, 0%, 100%);
- --constructive: hsl(141, 71%, 48%);
- --constructive-foreground: hsl(0, 0%, 100%);
- --border: hsl(0 0% 91%);
- --input: hsl(0 0% 85.1%);
- --ring: hsl(0, 0%, 55%);
- --chart-1: hsl(220 70% 50%);
- --chart-2: hsl(160 60% 45%);
- --chart-3: hsl(30 80% 55%);
- --chart-4: hsl(280 65% 60%);
- --chart-5: hsl(340 75% 55%);
- --sidebar-background: hsl(0 0% 96.1%);
- --sidebar-foreground: hsl(0 0% 2%);
- --sidebar-primary: hsl(0 0% 2%);
- --sidebar-primary-foreground: hsl(0 0% 91%);
- --sidebar-accent: hsl(0 0% 85.1%);
- --sidebar-accent-foreground: hsl(0 0% 2%);
- --sidebar-border: hsl(0 0% 85.1%);
- --sidebar-ring: hsl(0 0% 16.9%);
- --sidebar: hsl(0 0% 98%);
+ --background: hsl(0, 0%, 100%);
+ --foreground: hsl(0 0% 11%);
+ --card: hsl(0, 0%, 100%);
+ --card-foreground: hsl(0 0% 11%);
+ --popover: hsl(0, 0%, 100%);
+ --popover-hover: hsl(0, 0%, 96%);
+ --popover-foreground: hsl(0 0% 2%);
+ --primary: #009dff;
+ --primary-foreground: hsl(0, 0%, 100%);
+ --secondary: hsl(204, 100%, 97%);
+ --secondary-border: hsl(204, 100%, 94%);
+ --secondary-foreground: hsl(200, 98%, 39%);
+ --muted: hsl(0 0% 85.1%);
+ --muted-foreground: hsl(0 0% 50%);
+ --accent: hsl(0, 0%, 96%);
+ --accent-foreground: hsl(0 0% 2%);
+ --destructive: hsl(0, 83%, 50%);
+ --destructive-foreground: hsl(0, 0%, 100%);
+ --constructive: hsl(141, 71%, 48%);
+ --constructive-foreground: hsl(0, 0%, 100%);
+ --border: hsl(0 0% 91%);
+ --input: hsl(0, 0%, 100%);
+ --ring: hsl(0, 0%, 55%);
+ --chart-1: hsl(220 70% 50%);
+ --chart-2: hsl(160 60% 45%);
+ --chart-3: hsl(30 80% 55%);
+ --chart-4: hsl(280 65% 60%);
+ --chart-5: hsl(340 75% 55%);
+ --sidebar-background: hsl(0 0% 96.1%);
+ --sidebar-foreground: hsl(0 0% 2%);
+ --sidebar-primary: hsl(0 0% 2%);
+ --sidebar-primary-foreground: hsl(0 0% 91%);
+ --sidebar-accent: hsl(0 0% 85.1%);
+ --sidebar-accent-foreground: hsl(0 0% 2%);
+ --sidebar-border: hsl(0 0% 85.1%);
+ --sidebar-ring: hsl(0 0% 16.9%);
+ --sidebar: hsl(0 0% 98%);
}
.panel {
- --background: hsl(216 13% 98%);
- --foreground: hsl(0 0% 13%);
- --card: hsl(0, 0%, 98%);
- --card-foreground: hsl(0 0% 13%);
- --primary-foreground: hsl(0, 0%, 98%);
- --secondary: hsl(204, 100%, 95%);
- --secondary-border: hsl(204, 100%, 92%);
- --secondary-foreground: hsl(200, 98%, 37%);
- --muted: hsl(0 0% 83.1%);
- --muted-foreground: hsl(0 0% 48%);
- --accent: hsl(0, 0%, 93%);
- --accent-foreground: hsl(0 0% 5%);
- --destructive: hsl(0, 83%, 50%);
- --destructive-foreground: hsl(0, 0%, 98%);
- --constructive: hsl(141, 71%, 48%);
- --constructive-foreground: hsl(0, 0%, 98%);
- --border: hsl(0 0% 89%);
- --input: hsl(0 0% 83.1%);
- --ring: hsl(0, 0%, 53%);
+ --background: hsl(216 13% 98%);
+ --foreground: hsl(0 0% 13%);
+ --card: hsl(0, 0%, 98%);
+ --card-foreground: hsl(0 0% 13%);
+ --primary-foreground: hsl(0, 0%, 98%);
+ --secondary: hsl(204, 100%, 95%);
+ --secondary-border: hsl(204, 100%, 92%);
+ --secondary-foreground: hsl(200, 98%, 37%);
+ --muted: hsl(0 0% 83.1%);
+ --muted-foreground: hsl(0 0% 48%);
+ --accent: hsl(0, 0%, 93%);
+ --accent-foreground: hsl(0 0% 5%);
+ --destructive: hsl(0, 83%, 50%);
+ --destructive-foreground: hsl(0, 0%, 98%);
+ --constructive: hsl(141, 71%, 48%);
+ --constructive-foreground: hsl(0, 0%, 98%);
+ --border: hsl(0 0% 89%);
+ --input: hsl(0 0% 93%);
+ --ring: hsl(0, 0%, 53%);
}
.dark {
- --background: hsl(0, 0%, 7%);
- --foreground: hsl(0 0% 87%);
- --card: hsl(0, 0%, 7%);
- --card-foreground: hsl(0 0% 87%);
- --popover: hsl(0, 0%, 16%);
- --popover-hover: hsl(0, 0%, 22%);
- --popover-foreground: hsl(0 0% 95%);
- --secondary: hsl(204, 100%, 12%);
- --secondary-border: hsl(204, 100%, 15%);
- --secondary-foreground: hsl(200, 98%, 61%);
- --muted: hsl(0 0% 20%);
- --accent: hsl(0, 0%, 14%);
- --accent-foreground: hsl(0 0% 95%);
- --constructive: hsl(141, 71%, 52%);
- --border: hsl(0 0% 16%);
- --input: hsl(0 0% 20%);
- --ring: hsl(0, 0%, 50%);
- --sidebar-background: hsl(0 0% 8%);
- --sidebar-foreground: hsl(0 0% 95%);
- --sidebar-primary: hsl(0 0% 95%);
- --sidebar-primary-foreground: hsl(0 0% 15%);
- --sidebar-accent: hsl(0 0% 20%);
- --sidebar-accent-foreground: hsl(0 0% 95%);
- --sidebar-border: hsl(0 0% 20%);
- --sidebar-ring: hsl(0 0% 83.1%);
- --sidebar: hsl(0 0% 6%);
+ --background: hsl(0, 0%, 5%);
+ --foreground: hsl(0 0% 87%);
+ --card: hsl(0, 0%, 5%);
+ --card-foreground: hsl(0 0% 87%);
+ --popover: hsl(0, 0%, 5%);
+ --popover-hover: hsl(0, 0%, 22%);
+ --popover-foreground: hsl(0 0% 95%);
+ --secondary: hsl(204, 100%, 12%);
+ --secondary-border: hsl(204, 100%, 15%);
+ --secondary-foreground: hsl(200, 98%, 61%);
+ --muted: hsl(0 0% 20%);
+ --accent: hsl(0, 0%, 14%);
+ --accent-foreground: hsl(0 0% 95%);
+ --constructive: hsl(141, 71%, 52%);
+ --border: hsl(0 0% 16%);
+ --input: hsl(0 0% 5%);
+ --ring: hsl(0, 0%, 50%);
+ --sidebar-background: hsl(0 0% 8%);
+ --sidebar-foreground: hsl(0 0% 95%);
+ --sidebar-primary: hsl(0 0% 95%);
+ --sidebar-primary-foreground: hsl(0 0% 15%);
+ --sidebar-accent: hsl(0 0% 20%);
+ --sidebar-accent-foreground: hsl(0 0% 95%);
+ --sidebar-border: hsl(0 0% 20%);
+ --sidebar-ring: hsl(0 0% 83.1%);
+ --sidebar: hsl(0 0% 6%);
}
.dark .panel {
- --background: hsl(0 0% 10%);
- --foreground: hsl(0 0% 85%);
- --card: hsl(0, 0%, 10%);
- --card-foreground: hsl(0 0% 85%);
- --secondary: hsl(204, 100%, 12%);
- --secondary-border: hsl(204, 100%, 17%);
- --secondary-foreground: hsl(200, 98%, 63%);
- --muted: hsl(0 0% 22%);
- --accent: hsl(0, 0%, 15%);
- --accent-foreground: hsl(0 0% 93%);
- --constructive: hsl(141, 71%, 52%);
- --border: hsl(0 0% 18%);
- --input: hsl(0 0% 22%);
- --ring: hsl(0, 0%, 52%);
+ --background: hsl(0 0% 10%);
+ --foreground: hsl(0 0% 85%);
+ --card: hsl(0, 0%, 10%);
+ --card-foreground: hsl(0 0% 85%);
+ --secondary: hsl(204, 100%, 12%);
+ --secondary-border: hsl(204, 100%, 17%);
+ --secondary-foreground: hsl(200, 98%, 63%);
+ --muted: hsl(0 0% 22%);
+ --accent: hsl(0, 0%, 15%);
+ --accent-foreground: hsl(0 0% 93%);
+ --constructive: hsl(141, 71%, 52%);
+ --border: hsl(0 0% 18%);
+ --input: hsl(0 0% 22%);
+ --ring: hsl(0, 0%, 52%);
}
@layer base {
- /*
+ /*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
@@ -124,166 +124,165 @@
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
- *,
- ::after,
- ::before,
- ::backdrop,
- ::file-selector-button {
- border-color: var(--color-gray-200, currentcolor);
- }
- /* Other default base styles */
- * {
- @apply border-border;
- }
- body {
- @apply bg-background text-foreground;
- /* Prevent back/forward swipe */
- overscroll-behavior-x: contain;
- }
- ::selection {
- @apply bg-primary/35 selection:text-primary-foreground;
- }
+ *,
+ ::after,
+ ::before,
+ ::backdrop,
+ ::file-selector-button {
+ border-color: var(--color-gray-200, currentcolor);
+ }
+ /* Other default base styles */
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ /* Prevent back/forward swipe */
+ overscroll-behavior-x: contain;
+ }
+ ::selection {
+ @apply bg-primary/35 selection:text-primary-foreground;
+ }
}
@theme inline {
- /* Responsive breakpoints */
- --breakpoint-xs: 30rem;
+ /* Responsive breakpoints */
+ --breakpoint-xs: 30rem;
- /* Typography */
- --font-sans: var(--font-inter), sans-serif;
+ /* Typography */
+ --font-sans: var(--font-inter), sans-serif;
- /* Font sizes */
- --text-xl: 1.2rem;
- --text-base: 0.92rem;
- --text-base--line-height: calc(1.5 / 0.95);
- --text-xs: 0.75rem;
- --text-sm: 0.85rem;
- --text-xs--line-height: calc(1 / 0.8);
+ /* Font sizes */
+ --text-xs: 0.72rem;
+ --text-sm: 0.79rem;
+ --text-base: 0.92rem;
+ --text-base--line-height: calc(1.5 / 0.95);
+ --text-xs--line-height: calc(1 / 0.8);
- /* Border radius */
- --radius-lg: 0.82rem;
- --radius-md: 0.65rem;
- --radius-sm: 0.35rem;
+ /* Border radius */
+ --radius-lg: 0.82rem;
+ --radius-md: 0.65rem;
+ --radius-sm: 0.35rem;
- /* Palette mapped to root design tokens */
- --color-background: var(--background);
- --color-foreground: var(--foreground);
+ /* Palette mapped to root design tokens */
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
- --color-card: var(--card);
- --color-card-foreground: var(--card-foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
- --color-popover: var(--popover);
- --color-popover-hover: var(--popover-hover);
- --color-popover-foreground: var(--popover-foreground);
+ --color-popover: var(--popover);
+ --color-popover-hover: var(--popover-hover);
+ --color-popover-foreground: var(--popover-foreground);
- --color-primary: var(--primary);
- --color-primary-foreground: var(--primary-foreground);
- --color-secondary: var(--secondary);
- --color-secondary-border: var(--secondary-border);
- --color-secondary-foreground: var(--secondary-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-border: var(--secondary-border);
+ --color-secondary-foreground: var(--secondary-foreground);
- --color-muted: var(--muted);
- --color-muted-foreground: var(--muted-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
- --color-accent: var(--accent);
- --color-accent-foreground: var(--accent-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
- --color-destructive: var(--destructive);
- --color-destructive-foreground: var(--destructive-foreground);
+ --color-destructive: var(--destructive);
+ --color-destructive-foreground: var(--destructive-foreground);
- --color-constructive: var(--constructive);
- --color-constructive-foreground: var(--constructive-foreground);
+ --color-constructive: var(--constructive);
+ --color-constructive-foreground: var(--constructive-foreground);
- --color-border: var(--border);
- --color-input: var(--input);
- --color-ring: var(--ring);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
- /* Chart colors */
- --color-chart-1: var(--chart-1);
- --color-chart-2: var(--chart-2);
- --color-chart-3: var(--chart-3);
- --color-chart-4: var(--chart-4);
- --color-chart-5: var(--chart-5);
+ /* Chart colors */
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
- /* Sidebar */
- --color-sidebar: var(--sidebar-background);
- --color-sidebar-foreground: var(--sidebar-foreground);
- --color-sidebar-primary: var(--sidebar-primary);
- --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
- --color-sidebar-accent: var(--sidebar-accent);
- --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
- --color-sidebar-border: var(--sidebar-border);
- --color-sidebar-ring: var(--sidebar-ring);
+ /* Sidebar */
+ --color-sidebar: var(--sidebar-background);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
- /* Animations */
- --animate-accordion-down: accordion-down 0.2s ease-out;
- --animate-accordion-up: accordion-up 0.2s ease-out;
+ /* Animations */
+ --animate-accordion-down: accordion-down 0.2s ease-out;
+ --animate-accordion-up: accordion-up 0.2s ease-out;
- @keyframes accordion-down {
- from {
- height: 0;
- }
- to {
- height: var(--radix-accordion-content-height);
- }
- }
+ @keyframes accordion-down {
+ from {
+ height: 0;
+ }
+ to {
+ height: var(--radix-accordion-content-height);
+ }
+ }
- @keyframes accordion-up {
- from {
- height: var(--radix-accordion-content-height);
- }
- to {
- height: 0;
- }
- }
+ @keyframes accordion-up {
+ from {
+ height: var(--radix-accordion-content-height);
+ }
+ to {
+ height: 0;
+ }
+ }
}
@utility scrollbar-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
}
@utility scrollbar-x-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar:horizontal {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar:horizontal {
+ display: none;
+ }
}
@utility scrollbar-y-hidden {
- -ms-overflow-style: none;
- scrollbar-width: none;
- &::-webkit-scrollbar:vertical {
- display: none;
- }
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar:vertical {
+ display: none;
+ }
}
@utility scrollbar-thin {
- &::-webkit-scrollbar {
- width: 6px;
- height: 8px;
- }
- &::-webkit-scrollbar-track {
- background: transparent;
- }
- &::-webkit-scrollbar-thumb {
- background: var(--border);
- border-radius: 4px;
- }
- &::-webkit-scrollbar-thumb:hover {
- background: var(--muted-foreground);
- }
+ &::-webkit-scrollbar {
+ width: 6px;
+ height: 8px;
+ }
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ &::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 4px;
+ }
+ &::-webkit-scrollbar-thumb:hover {
+ background: var(--muted-foreground);
+ }
}
@layer base {
- * {
- @apply border-border outline-ring/50;
- }
- body {
- @apply bg-background text-foreground;
- }
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
}
diff --git a/apps/web/src/app/projects/page.tsx b/apps/web/src/app/projects/page.tsx
index 492e0cbe..b855cefc 100644
--- a/apps/web/src/app/projects/page.tsx
+++ b/apps/web/src/app/projects/page.tsx
@@ -245,7 +245,6 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
</Button>
</SortDropdown>
<Button
- type="button"
variant="text"
className="text-muted-foreground"
onClick={() =>
@@ -768,7 +767,6 @@ function ProjectMenu({
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<Button
- type="button"
variant="background"
className={
isGrid
diff --git a/apps/web/src/components/editor/editor-header.tsx b/apps/web/src/components/editor/editor-header.tsx
index e70bda37..2dea73e2 100644
--- a/apps/web/src/components/editor/editor-header.tsx
+++ b/apps/web/src/components/editor/editor-header.tsx
@@ -19,7 +19,7 @@ import { ThemeToggle } from "../theme-toggle";
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor";
-import { ArrowLeft02Icon, CommandIcon } from "@hugeicons/core-free-icons";
+import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
import Image from "next/image";
@@ -118,32 +118,30 @@ function ProjectDropdown() {
/>
</Button>
</DropdownMenuTrigger>
- <DropdownMenuContent align="start" className="z-100 w-52">
+ <DropdownMenuContent align="start" className="z-100 w-44">
<DropdownMenuItem
- className="flex items-center gap-1.5"
onClick={handleExit}
disabled={isExiting}
+ icon={<HugeiconsIcon icon={Logout05Icon} />}
>
- <HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
Exit project
</DropdownMenuItem>
- <DropdownMenuSeparator />
<DropdownMenuItem
- className="flex items-center gap-1.5"
onClick={() => setOpenDialog("shortcuts")}
+ icon={<HugeiconsIcon icon={CommandIcon} />}
>
- <HugeiconsIcon icon={CommandIcon} className="size-4" />
- Keyboard shortcuts
+ Shortcuts
</DropdownMenuItem>
- <DropdownMenuItem asChild>
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem asChild icon={<FaDiscord className="!size-4" />}>
<Link
href={SOCIAL_LINKS.discord}
target="_blank"
rel="noopener noreferrer"
- className="flex items-center gap-1.5"
>
- <FaDiscord className="size-4" />
Discord
</Link>
</DropdownMenuItem>
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index f39c18e2..bc44e650 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -23,7 +23,11 @@ import {
type ExportQuality,
type ExportResult,
} from "@/types/export";
-import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
+import {
+ Section,
+ SectionContent,
+ SectionHeader,
+} from "@/components/editor/panels/properties/section";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
@@ -162,78 +166,83 @@ function ExportPopover({
{!isExporting && (
<>
<div className="flex flex-col">
- <PropertyGroup
- title="Format"
- defaultExpanded={false}
- hasBorderTop={false}
- >
- <RadioGroup
- value={format}
- onValueChange={(value) => {
- if (isExportFormat(value)) {
- setFormat(value);
- }
- }}
- >
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="mp4" id="mp4" />
- <Label htmlFor="mp4">
- MP4 (H.264) - Better compatibility
- </Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="webm" id="webm" />
- <Label htmlFor="webm">
- WebM (VP9) - Smaller file size
- </Label>
- </div>
- </RadioGroup>
- </PropertyGroup>
+ <Section collapsible defaultOpen={false} hasBorderTop={false}>
+ <SectionHeader title="Format" />
+ <SectionContent>
+ <RadioGroup
+ value={format}
+ onValueChange={(value) => {
+ if (isExportFormat(value)) {
+ setFormat(value);
+ }
+ }}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="mp4" id="mp4" />
+ <Label htmlFor="mp4">
+ MP4 (H.264) - Better compatibility
+ </Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="webm" id="webm" />
+ <Label htmlFor="webm">
+ WebM (VP9) - Smaller file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </SectionContent>
+ </Section>
- <PropertyGroup title="Quality" defaultExpanded={false}>
- <RadioGroup
- value={quality}
- onValueChange={(value) => {
- if (isExportQuality(value)) {
- setQuality(value);
- }
- }}
- >
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="low" id="low" />
- <Label htmlFor="low">Low - Smallest file size</Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="medium" id="medium" />
- <Label htmlFor="medium">Medium - Balanced</Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="high" id="high" />
- <Label htmlFor="high">High - Recommended</Label>
- </div>
+ <Section collapsible defaultOpen={false}>
+ <SectionHeader title="Quality" />
+ <SectionContent>
+ <RadioGroup
+ value={quality}
+ onValueChange={(value) => {
+ if (isExportQuality(value)) {
+ setQuality(value);
+ }
+ }}
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="low" id="low" />
+ <Label htmlFor="low">Low - Smallest file size</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="medium" id="medium" />
+ <Label htmlFor="medium">Medium - Balanced</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="high" id="high" />
+ <Label htmlFor="high">High - Recommended</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="very_high" id="very_high" />
+ <Label htmlFor="very_high">
+ Very High - Largest file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </SectionContent>
+ </Section>
+
+ <Section collapsible defaultOpen={false}>
+ <SectionHeader title="Audio" />
+ <SectionContent>
<div className="flex items-center space-x-2">
- <RadioGroupItem value="very_high" id="very_high" />
- <Label htmlFor="very_high">
- Very High - Largest file size
+ <Checkbox
+ id="include-audio"
+ checked={includeAudio}
+ onCheckedChange={(checked) =>
+ setIncludeAudio(!!checked)
+ }
+ />
+ <Label htmlFor="include-audio">
+ Include audio in export
</Label>
</div>
- </RadioGroup>
- </PropertyGroup>
-
- <PropertyGroup title="Audio" defaultExpanded={false}>
- <div className="flex items-center space-x-2">
- <Checkbox
- id="include-audio"
- checked={includeAudio}
- onCheckedChange={(checked) =>
- setIncludeAudio(!!checked)
- }
- />
- <Label htmlFor="include-audio">
- Include audio in export
- </Label>
- </div>
- </PropertyGroup>
+ </SectionContent>
+ </Section>
</div>
<div className="p-3 pt-0">
diff --git a/apps/web/src/components/editor/panels/assets/draggable-item.tsx b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
index 517fdc92..be4176ce 100644
--- a/apps/web/src/components/editor/panels/assets/draggable-item.tsx
+++ b/apps/web/src/components/editor/panels/assets/draggable-item.tsx
@@ -163,7 +163,9 @@ export function DraggableItem({
<div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
{preview}
</div>
- <span className="w-full flex-1 truncate text-sm text-left">{name}</span>
+ <span className="w-full flex-1 truncate text-sm text-left">
+ {name}
+ </span>
</button>
</div>
)}
diff --git a/apps/web/src/components/editor/panels/assets/index.tsx b/apps/web/src/components/editor/panels/assets/index.tsx
index e7bd3d4f..3600d00b 100644
--- a/apps/web/src/components/editor/panels/assets/index.tsx
+++ b/apps/web/src/components/editor/panels/assets/index.tsx
@@ -4,7 +4,7 @@ import { Separator } from "@/components/ui/separator";
import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store";
import { TabBar } from "./tabbar";
import { Captions } from "./views/captions";
-import { MediaView } from "./views/media";
+import { MediaView } from "./views/assets";
import { SettingsView } from "./views/settings";
import { SoundsView } from "./views/sounds";
import { StickersView } from "./views/stickers";
diff --git a/apps/web/src/components/editor/panels/assets/tabbar.tsx b/apps/web/src/components/editor/panels/assets/tabbar.tsx
index 31842306..61777a7c 100644
--- a/apps/web/src/components/editor/panels/assets/tabbar.tsx
+++ b/apps/web/src/components/editor/panels/assets/tabbar.tsx
@@ -61,7 +61,8 @@ export function TabBar() {
aria-label={tab.label}
className={cn(
"flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5",
- activeTab !== tabKey && "border border-transparent text-muted-foreground",
+ activeTab !== tabKey &&
+ "border border-transparent text-muted-foreground",
)}
onClick={() => setActiveTab(tabKey)}
>
diff --git a/apps/web/src/components/editor/panels/assets/views/media.tsx b/apps/web/src/components/editor/panels/assets/views/assets.tsx
index 59395fd8..808961f1 100644
--- a/apps/web/src/components/editor/panels/assets/views/media.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/assets.tsx
@@ -3,6 +3,7 @@
import Image from "next/image";
import { useMemo, useState } from "react";
import { toast } from "sonner";
+import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
import { Button } from "@/components/ui/button";
@@ -29,14 +30,9 @@ import { useEditor } from "@/hooks/use-editor";
import { useFileUpload } from "@/hooks/use-file-upload";
import { useRevealItem } from "@/hooks/use-reveal-item";
import { processMediaAssets } from "@/lib/media/processing";
-import {
- buildImageElement,
- buildUploadAudioElement,
- buildVideoElement,
-} from "@/lib/timeline/element-utils";
+import { buildElementFromMedia } from "@/lib/timeline/element-utils";
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
import type { MediaAsset } from "@/types/assets";
-import type { CreateTimelineElement } from "@/types/timeline";
import { cn } from "@/utils/ui";
import {
CloudUploadIcon,
@@ -132,7 +128,15 @@ export function MediaView() {
asset: MediaAsset;
startTime: number;
}): boolean => {
- const element = createElementFromMedia({ asset, startTime });
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const element = buildElementFromMedia({
+ mediaId: asset.id,
+ mediaType: asset.type,
+ name: asset.name,
+ duration,
+ startTime,
+ });
editor.timeline.insertElement({
element,
placement: { mode: "auto" },
@@ -194,171 +198,166 @@ export function MediaView() {
const renderCompactPreview = (item: MediaAsset) =>
previewComponents.get(`compact-${item.id}`);
- return (
- <>
- <input {...fileInputProps} />
-
- <div
- className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`}
- {...dragProps}
- >
- <div className="bg-background h-12 px-4 pr-2 flex items-center justify-between border-b">
- <span className="text-muted-foreground text-sm">Assets</span>
- <div className="flex items-center gap-0">
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
+ const mediaActions = (
+ <div>
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ size="icon"
+ variant="ghost"
+ onClick={() =>
+ setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid")
+ }
+ disabled={isProcessing}
+ className="items-center justify-center"
+ >
+ {mediaViewMode === "grid" ? (
+ <HugeiconsIcon icon={LeftToRightListDashIcon} />
+ ) : (
+ <HugeiconsIcon icon={GridViewIcon} />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>
+ {mediaViewMode === "grid"
+ ? "Switch to list view"
+ : "Switch to grid view"}
+ </p>
+ </TooltipContent>
+ <Tooltip>
+ <DropdownMenu>
+ <TooltipTrigger asChild>
+ <DropdownMenuTrigger asChild>
<Button
size="icon"
- variant="text"
- onClick={() =>
- setMediaViewMode(
- mediaViewMode === "grid" ? "list" : "grid",
- )
- }
+ variant="ghost"
disabled={isProcessing}
className="items-center justify-center"
>
- {mediaViewMode === "grid" ? (
- <HugeiconsIcon icon={LeftToRightListDashIcon} />
- ) : (
- <HugeiconsIcon icon={GridViewIcon} />
- )}
+ <HugeiconsIcon icon={SortingOneNineIcon} />
</Button>
- </TooltipTrigger>
- <TooltipContent>
- <p>
- {mediaViewMode === "grid"
- ? "Switch to list view"
- : "Switch to grid view"}
- </p>
- </TooltipContent>
- <Tooltip>
- <DropdownMenu>
- <TooltipTrigger asChild>
- <DropdownMenuTrigger asChild>
- <Button
- size="icon"
- variant="text"
- disabled={isProcessing}
- className="items-center justify-center"
- >
- <HugeiconsIcon icon={SortingOneNineIcon} />
- </Button>
- </DropdownMenuTrigger>
- </TooltipTrigger>
- <DropdownMenuContent align="end">
- <SortMenuItem
- label="Name"
- sortKey="name"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- <SortMenuItem
- label="Type"
- sortKey="type"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- <SortMenuItem
- label="Duration"
- sortKey="duration"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- <SortMenuItem
- label="File size"
- sortKey="size"
- currentSortBy={sortBy}
- currentSortOrder={sortOrder}
- onSort={({ key }) => {
- if (sortBy === key) {
- setSortOrder(sortOrder === "asc" ? "desc" : "asc");
- } else {
- setSortBy(key);
- setSortOrder("asc");
- }
- }}
- />
- </DropdownMenuContent>
- </DropdownMenu>
- <TooltipContent>
- <p>
- Sort by {sortBy} (
- {sortOrder === "asc" ? "ascending" : "descending"})
- </p>
- </TooltipContent>
- </Tooltip>
- </Tooltip>
- </TooltipProvider>
- <Button
- variant="outline"
- onClick={openFilePicker}
- disabled={isProcessing}
- size="sm"
- className="items-center justify-center gap-1.5 ml-1.5 hover:bg-accent px-3"
- >
- <HugeiconsIcon icon={CloudUploadIcon} />
- Import
- </Button>
- </div>
- </div>
+ </DropdownMenuTrigger>
+ </TooltipTrigger>
+ <DropdownMenuContent align="end">
+ <SortMenuItem
+ label="Name"
+ sortKey="name"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ <SortMenuItem
+ label="Type"
+ sortKey="type"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ <SortMenuItem
+ label="Duration"
+ sortKey="duration"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ <SortMenuItem
+ label="File size"
+ sortKey="size"
+ currentSortBy={sortBy}
+ currentSortOrder={sortOrder}
+ onSort={({ key }) => {
+ if (sortBy === key) {
+ setSortOrder(sortOrder === "asc" ? "desc" : "asc");
+ } else {
+ setSortBy(key);
+ setSortOrder("asc");
+ }
+ }}
+ />
+ </DropdownMenuContent>
+ </DropdownMenu>
+ <TooltipContent>
+ <p>
+ Sort by {sortBy} (
+ {sortOrder === "asc" ? "ascending" : "descending"})
+ </p>
+ </TooltipContent>
+ </Tooltip>
+ </Tooltip>
+ </TooltipProvider>
+ <Button
+ variant="outline"
+ onClick={openFilePicker}
+ disabled={isProcessing}
+ size="sm"
+ className="items-center justify-center gap-1.5 ml-1.5"
+ >
+ <HugeiconsIcon icon={CloudUploadIcon} />
+ Import
+ </Button>
+ </div>
+ );
- <div className="scrollbar-thin size-full overflow-y-auto pt-1">
- <div className="w-full flex-1 p-3 pt-0">
- {isDragOver || filteredMediaItems.length === 0 ? (
- <MediaDragOverlay
- isVisible={true}
- isProcessing={isProcessing}
- progress={progress}
- onClick={openFilePicker}
- />
- ) : mediaViewMode === "grid" ? (
- <GridView
- items={filteredMediaItems}
- renderPreview={renderPreview}
- onRemove={handleRemove}
- onAddToTimeline={addElementAtTime}
- highlightedId={highlightedId}
- registerElement={registerElement}
- />
- ) : (
- <ListView
- items={filteredMediaItems}
- renderPreview={renderCompactPreview}
- onRemove={handleRemove}
- onAddToTimeline={addElementAtTime}
- highlightedId={highlightedId}
- registerElement={registerElement}
- />
- )}
- </div>
- </div>
- </div>
+ return (
+ <>
+ <input {...fileInputProps} />
+
+ <PanelView
+ title="Assets"
+ actions={mediaActions}
+ className={isDragOver ? "bg-accent/30" : ""}
+ {...dragProps}
+ >
+ {isDragOver || filteredMediaItems.length === 0 ? (
+ <MediaDragOverlay
+ isVisible={true}
+ isProcessing={isProcessing}
+ progress={progress}
+ onClick={openFilePicker}
+ />
+ ) : mediaViewMode === "grid" ? (
+ <GridView
+ items={filteredMediaItems}
+ renderPreview={renderPreview}
+ onRemove={handleRemove}
+ onAddToTimeline={addElementAtTime}
+ highlightedId={highlightedId}
+ registerElement={registerElement}
+ />
+ ) : (
+ <ListView
+ items={filteredMediaItems}
+ renderPreview={renderCompactPreview}
+ onRemove={handleRemove}
+ onAddToTimeline={addElementAtTime}
+ highlightedId={highlightedId}
+ registerElement={registerElement}
+ />
+ )}
+ </PanelView>
</>
);
}
@@ -636,40 +635,3 @@ function SortMenuItem({
</DropdownMenuItem>
);
}
-
-function createElementFromMedia({
- asset,
- startTime,
-}: {
- asset: MediaAsset;
- startTime: number;
-}): CreateTimelineElement {
- const duration =
- asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
-
- switch (asset.type) {
- case "video":
- return buildVideoElement({
- mediaId: asset.id,
- name: asset.name,
- duration,
- startTime,
- });
- case "image":
- return buildImageElement({
- mediaId: asset.id,
- name: asset.name,
- duration,
- startTime,
- });
- case "audio":
- return buildUploadAudioElement({
- mediaId: asset.id,
- name: asset.name,
- duration,
- startTime,
- });
- default:
- throw new Error(`Unsupported media type: ${asset.type}`);
- }
-}
diff --git a/apps/web/src/components/editor/panels/assets/views/base-view.tsx b/apps/web/src/components/editor/panels/assets/views/base-view.tsx
new file mode 100644
index 00000000..c613bc2a
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/base-view.tsx
@@ -0,0 +1,52 @@
+import { cn } from "@/utils/ui";
+
+interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> {
+ title?: string;
+ actions?: React.ReactNode;
+ children: React.ReactNode;
+ contentClassName?: string;
+ hideHeader?: boolean;
+ ref?: React.Ref<HTMLDivElement>;
+ onScroll?: React.UIEventHandler<HTMLDivElement>;
+ scrollRef?: React.Ref<HTMLDivElement>;
+}
+
+export function PanelView({
+ title,
+ actions,
+ children,
+ className,
+ contentClassName,
+ hideHeader = false,
+ ref,
+ onScroll,
+ scrollRef,
+ ...rest
+}: PanelViewProps) {
+ return (
+ <div
+ className={cn("relative flex h-full flex-col", className)}
+ ref={ref}
+ {...rest}
+ >
+ {!hideHeader && (
+ <div className="bg-background h-11 shrink-0 px-4 pr-2 flex items-center justify-between border-b">
+ <span className="text-muted-foreground text-sm">{title}</span>
+ {actions}
+ </div>
+ )}
+ <div
+ className={cn(
+ "scrollbar-thin size-full overflow-y-auto",
+ hideHeader ? "pt-4" : "pt-2",
+ )}
+ ref={scrollRef}
+ onScroll={onScroll}
+ >
+ <div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}>
+ {children}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/captions.tsx b/apps/web/src/components/editor/panels/assets/views/captions.tsx
index daebf2c6..7a3b16bf 100644
--- a/apps/web/src/components/editor/panels/assets/views/captions.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/captions.tsx
@@ -1,5 +1,5 @@
import { Button } from "@/components/ui/button";
-import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import {
Select,
SelectContent,
@@ -108,10 +108,7 @@ export function Captions() {
};
return (
- <BaseView
- ref={containerRef}
- className="flex h-full flex-col justify-between"
- >
+ <PanelView title="Captions" ref={containerRef}>
<div className="flex flex-col gap-3">
<Label>Language</Label>
<Select
@@ -148,6 +145,6 @@ export function Captions() {
{isProcessing ? processingStep : "Generate transcript"}
</Button>
</div>
- </BaseView>
+ </PanelView>
);
}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx
new file mode 100644
index 00000000..457c08a9
--- /dev/null
+++ b/apps/web/src/components/editor/panels/assets/views/settings-legacy.tsx
@@ -0,0 +1,332 @@
+// @ts-nocheck
+
+"use client";
+
+import Image from "next/image";
+import { memo, useCallback, useMemo } from "react";
+import { PanelView } from "@/components/editor/panels/assets/views/base-view";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ BLUR_INTENSITY_PRESETS,
+ DEFAULT_BLUR_INTENSITY,
+ DEFAULT_COLOR,
+ FPS_PRESETS,
+} from "@/constants/project-constants";
+import { patternCraftGradients } from "@/data/colors/pattern-craft";
+import { colors } from "@/data/colors/solid";
+import { syntaxUIGradients } from "@/data/colors/syntax-ui";
+import { useEditor } from "@/hooks/use-editor";
+import { useEditorStore } from "@/stores/editor-store";
+import { dimensionToAspectRatio } from "@/utils/geometry";
+import { cn } from "@/utils/ui";
+import {
+ PropertyGroup,
+ PropertyItem,
+ PropertyItemLabel,
+ PropertyItemValue,
+} from "@/components/editor/panels/properties/section";
+
+export function SettingsView() {
+ return (
+ <PanelView title="Project settings" contentClassName="px-0">
+ <div className="flex flex-col gap-8">
+ <ProjectInfoView />
+ <BackgroundView />
+ </div>
+ </PanelView>
+ );
+}
+
+function ProjectInfoView() {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ const { canvasPresets } = useEditorStore();
+
+ const findPresetIndexByAspectRatio = ({
+ presets,
+ targetAspectRatio,
+ }: {
+ presets: Array<{ width: number; height: number }>;
+ targetAspectRatio: string;
+ }) => {
+ for (let index = 0; index < presets.length; index++) {
+ const preset = presets[index];
+ const presetAspectRatio = dimensionToAspectRatio({
+ width: preset.width,
+ height: preset.height,
+ });
+ if (presetAspectRatio === targetAspectRatio) {
+ return index;
+ }
+ }
+ return -1;
+ };
+
+ const currentCanvasSize = activeProject.settings.canvasSize;
+ const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
+ const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
+ const presetIndex = findPresetIndexByAspectRatio({
+ presets: canvasPresets,
+ targetAspectRatio: currentAspectRatio,
+ });
+ const originalPresetValue = "original";
+ const selectedPresetValue =
+ presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
+
+ const handleAspectRatioChange = ({ value }: { value: string }) => {
+ if (value === originalPresetValue) {
+ const canvasSize = originalCanvasSize ?? currentCanvasSize;
+ editor.project.updateSettings({
+ settings: { canvasSize },
+ });
+ return;
+ }
+ const index = parseInt(value, 10);
+ const preset = canvasPresets[index];
+ if (preset) {
+ editor.project.updateSettings({ settings: { canvasSize: preset } });
+ }
+ };
+
+ const handleFpsChange = (value: string) => {
+ const fps = parseFloat(value);
+ editor.project.updateSettings({ settings: { fps } });
+ };
+
+ return (
+ <div className="flex flex-col gap-4">
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Name</PropertyItemLabel>
+ <PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue>
+ </PropertyItem>
+
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Aspect ratio</PropertyItemLabel>
+ <PropertyItemValue>
+ <Select
+ value={selectedPresetValue}
+ onValueChange={(value) => handleAspectRatioChange({ value })}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select an aspect ratio" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value={originalPresetValue}>Original</SelectItem>
+ {canvasPresets.map((preset, index) => {
+ const label = dimensionToAspectRatio({
+ width: preset.width,
+ height: preset.height,
+ });
+ return (
+ <SelectItem key={label} value={index.toString()}>
+ {label}
+ </SelectItem>
+ );
+ })}
+ </SelectContent>
+ </Select>
+ </PropertyItemValue>
+ </PropertyItem>
+
+ <PropertyItem direction="column">
+ <PropertyItemLabel>Frame rate</PropertyItemLabel>
+ <PropertyItemValue>
+ <Select
+ value={activeProject.settings.fps.toString()}
+ onValueChange={handleFpsChange}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select a frame rate" />
+ </SelectTrigger>
+ <SelectContent>
+ {FPS_PRESETS.map((preset) => (
+ <SelectItem key={preset.value} value={preset.value}>
+ {preset.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </PropertyItemValue>
+ </PropertyItem>
+ </div>
+ );
+}
+
+const BlurPreview = memo(
+ ({
+ blur,
+ isSelected,
+ onSelect,
+ }: {
+ blur: { label: string; value: number };
+ isSelected: boolean;
+ onSelect: () => void;
+ }) => (
+ <button
+ className={cn(
+ "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
+ isSelected && "border-primary border-2",
+ )}
+ onClick={onSelect}
+ type="button"
+ aria-label={`Select ${blur.label} blur`}
+ >
+ <Image
+ src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
+ alt={`Blur preview ${blur.label}`}
+ fill
+ className="object-cover"
+ style={{ filter: `blur(${blur.value}px)` }}
+ loading="eager"
+ />
+ <div className="absolute right-1 bottom-1 left-1 text-center">
+ <span className="rounded bg-black/50 px-1 text-xs text-white">
+ {blur.label}
+ </span>
+ </div>
+ </button>
+ ),
+);
+
+BlurPreview.displayName = "BlurPreview";
+
+const BackgroundPreviews = memo(
+ ({
+ backgrounds,
+ currentBackgroundColor,
+ isColorBackground,
+ handleColorSelect,
+ useBackgroundColor = false,
+ }: {
+ backgrounds: string[];
+ currentBackgroundColor: string;
+ isColorBackground: boolean;
+ handleColorSelect: ({ bg }: { bg: string }) => void;
+ useBackgroundColor?: boolean;
+ }) => {
+ return useMemo(
+ () =>
+ backgrounds.map((bg, index) => (
+ <button
+ key={`${index}-${bg}`}
+ className={cn(
+ "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
+ isColorBackground &&
+ bg === currentBackgroundColor &&
+ "border-primary border-2",
+ )}
+ style={
+ useBackgroundColor
+ ? { backgroundColor: bg }
+ : {
+ background: bg,
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+ backgroundRepeat: "no-repeat",
+ }
+ }
+ onClick={() => handleColorSelect({ bg })}
+ type="button"
+ aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
+ />
+ )),
+ [
+ backgrounds,
+ isColorBackground,
+ currentBackgroundColor,
+ handleColorSelect,
+ useBackgroundColor,
+ ],
+ );
+ },
+);
+
+BackgroundPreviews.displayName = "BackgroundPreviews";
+
+function BackgroundView() {
+ const editor = useEditor();
+ const activeProject = editor.project.getActive();
+ const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
+
+ const handleBlurSelect = useCallback(
+ async ({ blurIntensity }: { blurIntensity: number }) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "blur", blurIntensity } },
+ });
+ },
+ [editor.project],
+ );
+
+ const handleColorSelect = useCallback(
+ async ({ color }: { color: string }) => {
+ await editor.project.updateSettings({
+ settings: { background: { type: "color", color } },
+ });
+ },
+ [editor.project],
+ );
+
+ const currentBlurIntensity =
+ activeProject.settings.background.type === "blur"
+ ? activeProject.settings.background.blurIntensity
+ : DEFAULT_BLUR_INTENSITY;
+
+ const currentBackgroundColor =
+ activeProject.settings.background.type === "color"
+ ? activeProject.settings.background.color
+ : DEFAULT_COLOR;
+
+ const isBlurBackground = activeProject.settings.background.type === "blur";
+ const isColorBackground = activeProject.settings.background.type === "color";
+
+ const blurPreviews = useMemo(
+ () =>
+ blurLevels.map((blur) => (
+ <BlurPreview
+ key={blur.value}
+ blur={blur}
+ isSelected={isBlurBackground && currentBlurIntensity === blur.value}
+ onSelect={() => handleBlurSelect({ blurIntensity: blur.value })}
+ />
+ )),
+ [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
+ );
+
+ const backgroundSections = [
+ { title: "Colors", backgrounds: colors, useBackgroundColor: true },
+ { title: "Pattern craft", backgrounds: patternCraftGradients },
+ { title: "Syntax UI", backgrounds: syntaxUIGradients },
+ ];
+
+ return (
+ <div className="flex flex-col">
+ <PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
+ <div className="flex flex-wrap gap-2">{blurPreviews}</div>
+ </PropertyGroup>
+
+ {backgroundSections.map((section) => (
+ <PropertyGroup
+ key={section.title}
+ title={section.title}
+ defaultExpanded={false}
+ >
+ <div className="flex flex-wrap gap-2">
+ <BackgroundPreviews
+ backgrounds={section.backgrounds}
+ currentBackgroundColor={currentBackgroundColor}
+ isColorBackground={isColorBackground}
+ handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
+ useBackgroundColor={section.useBackgroundColor}
+ />
+ </div>
+ </PropertyGroup>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/settings.tsx b/apps/web/src/components/editor/panels/assets/views/settings.tsx
index 26afe566..7e10ac9d 100644
--- a/apps/web/src/components/editor/panels/assets/views/settings.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/settings.tsx
@@ -1,8 +1,6 @@
"use client";
-import Image from "next/image";
-import { memo, useCallback, useMemo } from "react";
-import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import {
Select,
SelectContent,
@@ -10,63 +8,54 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import {
- BLUR_INTENSITY_PRESETS,
- DEFAULT_BLUR_INTENSITY,
- DEFAULT_COLOR,
- FPS_PRESETS,
-} from "@/constants/project-constants";
-import { patternCraftGradients } from "@/data/colors/pattern-craft";
-import { colors } from "@/data/colors/solid";
-import { syntaxUIGradients } from "@/data/colors/syntax-ui";
+import { FPS_PRESETS } from "@/constants/project-constants";
import { useEditor } from "@/hooks/use-editor";
import { useEditorStore } from "@/stores/editor-store";
import { dimensionToAspectRatio } from "@/utils/geometry";
-import { cn } from "@/utils/ui";
import {
- PropertyGroup,
- PropertyItem,
- PropertyItemLabel,
- PropertyItemValue,
-} from "@/components/editor/panels/properties/property-item";
-import { ColorPicker } from "@/components/ui/color-picker";
+ Section,
+ SectionContent,
+ SectionHeader,
+} from "@/components/editor/panels/properties/section";
+import { Label } from "@/components/ui/label";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { useState } from "react";
export function SettingsView() {
- return <ProjectSettingsTabs />;
-}
+ const [open, setOpen] = useState(false);
-function ProjectSettingsTabs() {
return (
- <BaseView
- defaultTab="project-info"
- tabs={[
- {
- value: "project-info",
- label: "Project info",
- content: (
- <div className="p-5">
- <ProjectInfoView />
- </div>
- ),
- },
- {
- value: "background",
- label: "Background",
- content: (
- <div className="flex h-full flex-col justify-between">
- <div className="flex-1">
- <BackgroundView />
+ <PanelView contentClassName="px-0" hideHeader>
+ <div className="flex flex-col">
+ <Section hasBorderTop={false}>
+ <SectionContent>
+ <ProjectInfoContent />
+ </SectionContent>
+ </Section>
+ <Popover open={open} onOpenChange={setOpen}>
+ <Section className="cursor-pointer">
+ <PopoverTrigger asChild>
+ <div>
+ <SectionHeader title="Background">
+ <div className="size-4 rounded-sm bg-red-500" />
+ </SectionHeader>
</div>
- </div>
- ),
- },
- ]}
- className="flex h-full flex-col justify-between p-0"
- />
+ </PopoverTrigger>
+ </Section>
+ <PopoverContent>
+ <div className="size-4 rounded-sm bg-red-500" />
+ </PopoverContent>
+ </Popover>
+ </div>
+ </PanelView>
);
}
-function ProjectInfoView() {
+function ProjectInfoContent() {
const editor = useEditor();
const activeProject = editor.project.getActive();
const { canvasPresets } = useEditorStore();
@@ -124,232 +113,55 @@ function ProjectInfoView() {
return (
<div className="flex flex-col gap-4">
- <PropertyItem direction="column">
- <PropertyItemLabel>Name</PropertyItemLabel>
- <PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue>
- </PropertyItem>
-
- <PropertyItem direction="column">
- <PropertyItemLabel>Aspect ratio</PropertyItemLabel>
- <PropertyItemValue>
- <Select
- value={selectedPresetValue}
- onValueChange={(value) => handleAspectRatioChange({ value })}
- >
- <SelectTrigger>
- <SelectValue placeholder="Select an aspect ratio" />
- </SelectTrigger>
- <SelectContent>
- <SelectItem value={originalPresetValue}>Original</SelectItem>
- {canvasPresets.map((preset, index) => {
- const label = dimensionToAspectRatio({
- width: preset.width,
- height: preset.height,
- });
- return (
- <SelectItem key={label} value={index.toString()}>
- {label}
- </SelectItem>
- );
- })}
- </SelectContent>
- </Select>
- </PropertyItemValue>
- </PropertyItem>
-
- <PropertyItem direction="column">
- <PropertyItemLabel>Frame rate</PropertyItemLabel>
- <PropertyItemValue>
- <Select
- value={activeProject.settings.fps.toString()}
- onValueChange={handleFpsChange}
- >
- <SelectTrigger>
- <SelectValue placeholder="Select a frame rate" />
- </SelectTrigger>
- <SelectContent>
- {FPS_PRESETS.map((preset) => (
- <SelectItem key={preset.value} value={preset.value}>
- {preset.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </PropertyItemValue>
- </PropertyItem>
- </div>
- );
-}
-
-const BlurPreview = memo(
- ({
- blur,
- isSelected,
- onSelect,
- }: {
- blur: { label: string; value: number };
- isSelected: boolean;
- onSelect: () => void;
- }) => (
- <button
- className={cn(
- "border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
- isSelected && "border-primary border-2",
- )}
- onClick={onSelect}
- type="button"
- aria-label={`Select ${blur.label} blur`}
- >
- <Image
- src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
- alt={`Blur preview ${blur.label}`}
- fill
- className="object-cover"
- style={{ filter: `blur(${blur.value}px)` }}
- loading="eager"
- />
- <div className="absolute right-1 bottom-1 left-1 text-center">
- <span className="rounded bg-black/50 px-1 text-xs text-white">
- {blur.label}
+ <div className="flex flex-col gap-2">
+ <Label>Name</Label>
+ <span className="leading-none text-sm">
+ {activeProject.metadata.name}
</span>
</div>
- </button>
- ),
-);
-
-BlurPreview.displayName = "BlurPreview";
-
-const BackgroundPreviews = memo(
- ({
- backgrounds,
- currentBackgroundColor,
- isColorBackground,
- handleColorSelect,
- useBackgroundColor = false,
- }: {
- backgrounds: string[];
- currentBackgroundColor: string;
- isColorBackground: boolean;
- handleColorSelect: ({ bg }: { bg: string }) => void;
- useBackgroundColor?: boolean;
- }) => {
- return useMemo(
- () =>
- backgrounds.map((bg, index) => (
- <button
- key={`${index}-${bg}`}
- className={cn(
- "border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
- isColorBackground &&
- bg === currentBackgroundColor &&
- "border-primary border-2",
- )}
- style={
- useBackgroundColor
- ? { backgroundColor: bg }
- : {
- background: bg,
- backgroundSize: "cover",
- backgroundPosition: "center",
- backgroundRepeat: "no-repeat",
- }
- }
- onClick={() => handleColorSelect({ bg })}
- type="button"
- aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
- />
- )),
- [
- backgrounds,
- isColorBackground,
- currentBackgroundColor,
- handleColorSelect,
- useBackgroundColor,
- ],
- );
- },
-);
-
-BackgroundPreviews.displayName = "BackgroundPreviews";
-
-function BackgroundView() {
- const editor = useEditor();
- const activeProject = editor.project.getActive();
- const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
-
- const handleBlurSelect = useCallback(
- async ({ blurIntensity }: { blurIntensity: number }) => {
- await editor.project.updateSettings({
- settings: { background: { type: "blur", blurIntensity } },
- });
- },
- [editor.project],
- );
-
- const handleColorSelect = useCallback(
- async ({ color }: { color: string }) => {
- await editor.project.updateSettings({
- settings: { background: { type: "color", color } },
- });
- },
- [editor.project],
- );
-
- const currentBlurIntensity =
- activeProject.settings.background.type === "blur"
- ? activeProject.settings.background.blurIntensity
- : DEFAULT_BLUR_INTENSITY;
-
- const currentBackgroundColor =
- activeProject.settings.background.type === "color"
- ? activeProject.settings.background.color
- : DEFAULT_COLOR;
-
- const isBlurBackground = activeProject.settings.background.type === "blur";
- const isColorBackground = activeProject.settings.background.type === "color";
-
- const blurPreviews = useMemo(
- () =>
- blurLevels.map((blur) => (
- <BlurPreview
- key={blur.value}
- blur={blur}
- isSelected={isBlurBackground && currentBlurIntensity === blur.value}
- onSelect={() => handleBlurSelect({ blurIntensity: blur.value })}
- />
- )),
- [blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
- );
-
- const backgroundSections = [
- { title: "Colors", backgrounds: colors, useBackgroundColor: true },
- { title: "Pattern craft", backgrounds: patternCraftGradients },
- { title: "Syntax UI", backgrounds: syntaxUIGradients },
- ];
-
- return (
- <div className="flex h-full flex-col">
- <PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
- <div className="flex flex-wrap gap-2">{blurPreviews}</div>
- </PropertyGroup>
-
- {backgroundSections.map((section) => (
- <PropertyGroup
- key={section.title}
- title={section.title}
- defaultExpanded={false}
+ <div className="flex flex-col gap-2">
+ <Label>Aspect ratio</Label>
+ <Select
+ value={selectedPresetValue}
+ onValueChange={(value) => handleAspectRatioChange({ value })}
>
- <div className="flex flex-wrap gap-2">
- <BackgroundPreviews
- backgrounds={section.backgrounds}
- currentBackgroundColor={currentBackgroundColor}
- isColorBackground={isColorBackground}
- handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
- useBackgroundColor={section.useBackgroundColor}
- />
- </div>
- </PropertyGroup>
- ))}
+ <SelectTrigger className="w-fit">
+ <SelectValue placeholder="Select an aspect ratio" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value={originalPresetValue}>Original</SelectItem>
+ {canvasPresets.map((preset, index) => {
+ const label = dimensionToAspectRatio({
+ width: preset.width,
+ height: preset.height,
+ });
+ return (
+ <SelectItem key={label} value={index.toString()}>
+ {label}
+ </SelectItem>
+ );
+ })}
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label>Frame rate</Label>
+ <Select
+ value={activeProject.settings.fps.toString()}
+ onValueChange={handleFpsChange}
+ >
+ <SelectTrigger className="w-fit">
+ <SelectValue placeholder="Select a frame rate" />
+ </SelectTrigger>
+ <SelectContent>
+ {FPS_PRESETS.map((preset) => (
+ <SelectItem key={preset.value} value={preset.value}>
+ {preset.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
</div>
);
}
diff --git a/apps/web/src/components/editor/panels/assets/views/sounds.tsx b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
index 30c70a83..b1c57c3d 100644
--- a/apps/web/src/components/editor/panels/assets/views/sounds.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/sounds.tsx
@@ -235,7 +235,7 @@ function SoundEffectsView() {
<div className="flex items-center gap-3">
<Input
placeholder="Search sound effects"
- className="bg-accent w-full"
+ className="w-full"
containerClassName="w-full"
value={searchQuery}
onChange={({ currentTarget }) =>
diff --git a/apps/web/src/components/editor/panels/assets/views/stickers.tsx b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
index 68ba91d2..671effec 100644
--- a/apps/web/src/components/editor/panels/assets/views/stickers.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/stickers.tsx
@@ -4,144 +4,104 @@ import Image from "next/image";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
+import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
import { Button } from "@/components/ui/button";
-import { InputWithBack } from "@/components/ui/input-with-back";
-import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
-import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
-import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
import {
- buildIconSvgUrl,
- getIconSvgUrl,
- ICONIFY_HOSTS,
- POPULAR_COLLECTIONS,
-} from "@/lib/iconify-api";
+ resolveStickerId,
+ type StickerItem as StickerData,
+} from "@/lib/stickers";
import { useStickersStore } from "@/stores/stickers-store";
-import type { StickerCategory } from "@/types/stickers";
import { cn } from "@/utils/ui";
import {
- ArrowRightIcon,
HappyIcon,
ClockIcon,
- LayoutGridIcon,
MultiplicationSignIcon,
- SparklesIcon,
- HashtagIcon,
+ Search01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { Spinner } from "@/components/ui/spinner";
-
-function isStickerCategory(value: string): value is StickerCategory {
- return STICKER_CATEGORIES.includes(value as StickerCategory);
-}
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { OcSlidersVerticalIcon } from "@opencut/ui/icons";
+import type { StickerCategory } from "@/types/stickers";
+import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
+import { parseStickerId } from "@/lib/stickers/sticker-id";
export function StickersView() {
const { selectedCategory, setSelectedCategory } = useStickersStore();
return (
- <BaseView
- value={selectedCategory}
- onValueChange={(v) => {
- if (isStickerCategory(v)) {
- setSelectedCategory({ category: v });
- }
- }}
- tabs={[
- {
- value: "all",
- label: "All",
- icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />,
- content: <StickersContentView category="all" />,
- },
- {
- value: "general",
- label: "Icons",
- icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />,
- content: <StickersContentView category="general" />,
- },
- {
- value: "brands",
- label: "Brands",
- icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />,
- content: <StickersContentView category="brands" />,
- },
- {
- value: "emoji",
- label: "Emoji",
- icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />,
- content: <StickersContentView category="emoji" />,
- },
- ]}
- className="flex h-full flex-col overflow-hidden p-0"
- />
+ <PanelView
+ title="Stickers"
+ actions={
+ <div className="flex items-center">
+ <Select
+ value={selectedCategory}
+ onValueChange={(value: StickerCategory) =>
+ setSelectedCategory({ category: value })
+ }
+ >
+ <SelectTrigger variant="outline" size="sm" className="mr-1.5">
+ <SelectValue placeholder="All" />
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(STICKER_CATEGORIES).map(([category, label]) => (
+ <SelectItem key={category} value={category}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+
+ <Button variant="ghost" size="icon">
+ <HugeiconsIcon icon={Search01Icon} className="!size-3.5" />
+ </Button>
+
+ <Button variant="ghost" size="icon">
+ <OcSlidersVerticalIcon className="!size-3.5" />
+ </Button>
+ </div>
+ }
+ >
+ <StickersContentView />
+ </PanelView>
);
}
function StickerGrid({
- icons,
- onAdd,
- addingSticker,
- capSize = false,
+ items,
+ shouldCapSize = false,
}: {
- icons: string[];
- onAdd: (iconName: string) => void;
- addingSticker: string | null;
- capSize?: boolean;
+ items: StickerData[];
+ shouldCapSize?: boolean;
}) {
const gridStyle: CSSProperties & {
"--sticker-min": string;
"--sticker-max"?: string;
} = {
- gridTemplateColumns: capSize
+ gridTemplateColumns: shouldCapSize
? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))"
: "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))",
"--sticker-min": "96px",
- ...(capSize ? { "--sticker-max": "160px" } : {}),
+ ...(shouldCapSize ? { "--sticker-max": "160px" } : {}),
};
return (
<div className="grid gap-2" style={gridStyle}>
- {icons.map((iconName) => (
- <StickerItem
- key={iconName}
- iconName={iconName}
- onAdd={onAdd}
- isAdding={addingSticker === iconName}
- capSize={capSize}
- />
- ))}
- </div>
- );
-}
-
-function CollectionGrid({
- collections,
- onSelectCollection,
-}: {
- collections: Array<{
- prefix: string;
- name: string;
- total: number;
- category?: string;
- }>;
- onSelectCollection: ({ prefix }: { prefix: string }) => void;
-}) {
- return (
- <div className="grid grid-cols-1 gap-2">
- {collections.map((collection) => (
- <CollectionItem
- key={collection.prefix}
- title={collection.name}
- subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`}
- onClick={() => onSelectCollection({ prefix: collection.prefix })}
- />
+ {items.map((item) => (
+ <StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
))}
</div>
);
@@ -162,372 +122,125 @@ function EmptyView({ message }: { message: string }) {
);
}
-function StickersContentView({ category }: { category: StickerCategory }) {
+function StickersContentView() {
const {
searchQuery,
- selectedCollection,
viewMode,
- collections,
- currentCollection,
searchResults,
recentStickers,
- isLoadingCollections,
- isLoadingCollection,
isSearching,
- setSearchQuery,
- setSelectedCollection,
- loadCollections,
- searchStickers,
- addStickerToTimeline,
clearRecentStickers,
- setSelectedCategory,
- addingSticker,
} = useStickersStore();
- const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
- const [collectionsToShow, setCollectionsToShow] = useState(20);
- const [showCollectionItems, setShowCollectionItems] = useState(false);
-
- const filteredCollections = useMemo(() => {
- if (category === "all") {
- return Object.entries(collections).map(([prefix, collection]) => ({
- prefix,
- name: collection.name,
- total: collection.total,
- category: collection.category,
- }));
- }
-
- const collectionList =
- POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS];
- if (!collectionList) return [];
-
- return collectionList
- .map((c) => {
- const collection = collections[c.prefix];
- return collection
- ? {
- prefix: c.prefix,
- name: c.name,
- total: collection.total,
- }
- : null;
- })
- .filter(Boolean) as Array<{
- prefix: string;
- name: string;
- total: number;
- }>;
- }, [collections, category]);
-
- const { scrollAreaRef, handleScroll } = useInfiniteScroll({
- onLoadMore: () => setCollectionsToShow((prev) => prev + 20),
- hasMore: filteredCollections.length > collectionsToShow,
- isLoading: isLoadingCollections,
- enabled: viewMode === "browse" && !selectedCollection && category === "all",
- });
-
- useEffect(() => {
- if (Object.keys(collections).length === 0) {
- loadCollections();
- }
- }, [collections, loadCollections]);
-
- useEffect(() => {
- const timer = setTimeout(() => {
- if (localSearchQuery !== searchQuery) {
- setSearchQuery({ query: localSearchQuery });
- if (localSearchQuery.trim()) {
- searchStickers({ query: localSearchQuery });
- }
- }
- }, 500);
-
- return () => clearTimeout(timer);
- }, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]);
-
- const handleAddSticker = async (iconName: string) => {
- try {
- await addStickerToTimeline({ iconName });
- } catch (error) {
- console.error("Failed to add sticker:", error);
- toast.error("Failed to add sticker to timeline");
- }
- };
-
- const iconsToDisplay = useMemo(() => {
+ const itemsToDisplay = useMemo(() => {
if (viewMode === "search" && searchResults) {
- return searchResults.icons;
- }
-
- if (viewMode === "collection" && currentCollection) {
- const icons: string[] = [];
-
- if (currentCollection.uncategorized) {
- icons.push(
- ...currentCollection.uncategorized.map(
- (name) => `${currentCollection.prefix}:${name}`,
- ),
- );
- }
-
- if (currentCollection.categories) {
- Object.values(currentCollection.categories).forEach((categoryIcons) => {
- icons.push(
- ...categoryIcons.map(
- (name) => `${currentCollection.prefix}:${name}`,
- ),
- );
- });
- }
-
- return icons.slice(0, 100);
+ return searchResults.items;
}
return [];
- }, [viewMode, searchResults, currentCollection]);
+ }, [viewMode, searchResults]);
- const isInCollection = viewMode === "collection" && !!selectedCollection;
-
- useEffect(() => {
- if (isInCollection) {
- setShowCollectionItems(false);
- const timer = setTimeout(() => setShowCollectionItems(true), 350);
- return () => clearTimeout(timer);
- } else {
- setShowCollectionItems(false);
+ const recentStickerItems = useMemo(() => {
+ const items: StickerData[] = [];
+ for (const stickerId of recentStickers) {
+ const recentStickerItem = toRecentStickerItem({ stickerId });
+ if (recentStickerItem) {
+ items.push(recentStickerItem);
+ }
}
- }, [isInCollection]);
+ return items;
+ }, [recentStickers]);
return (
- <div className="mt-1 flex h-full flex-col gap-5 p-4">
- <div className="space-y-3">
- <InputWithBack
- isExpanded={isInCollection}
- setIsExpanded={(expanded) => {
- if (!expanded && isInCollection) {
- setSelectedCollection({ collection: null });
- }
- }}
- placeholder={
- category === "all"
- ? "Search all stickers"
- : category === "general"
- ? "Search icons"
- : category === "brands"
- ? "Search brands"
- : "Search Emojis"
- }
- value={localSearchQuery}
- onChange={setLocalSearchQuery}
- disableAnimation={true}
- />
- </div>
-
- <div className="relative h-full overflow-hidden">
- <ScrollArea
- className="h-full flex-1"
- ref={scrollAreaRef}
- onScrollCapture={handleScroll}
- >
- <div className="flex h-full flex-col gap-4">
- {recentStickers.length > 0 && viewMode === "browse" && (
- <div className="h-full">
- <div className="mb-2 flex items-center gap-2">
- <HugeiconsIcon
- icon={ClockIcon}
- className="text-muted-foreground size-4"
- />
- <span className="text-sm font-medium">Recent</span>
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger asChild>
- <button
- type="button"
- onClick={clearRecentStickers}
- className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
- >
- <HugeiconsIcon
- icon={MultiplicationSignIcon}
- className="text-muted-foreground size-3"
- />
- </button>
- </TooltipTrigger>
- <TooltipContent>
- <p>Clear recent stickers</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- <StickerGrid
- icons={recentStickers.slice(0, 12)}
- onAdd={handleAddSticker}
- addingSticker={addingSticker}
- capSize
- />
- </div>
- )}
-
- {viewMode === "collection" && selectedCollection && (
- <div className="h-full">
- {isLoadingCollection ? (
- <div className="flex items-center justify-center py-8">
- <Spinner className="text-muted-foreground size-6" />
- </div>
- ) : showCollectionItems ? (
- <StickerGrid
- icons={iconsToDisplay}
- onAdd={handleAddSticker}
- addingSticker={addingSticker}
- />
- ) : (
- <div className="flex items-center justify-center py-8">
- <Spinner className="text-muted-foreground size-6" />
- </div>
- )}
- </div>
- )}
-
- {viewMode === "search" && (
- <div className="h-full">
- {isSearching ? (
- <div className="flex items-center justify-center py-8">
- <Spinner className="text-muted-foreground size-6" />
- </div>
- ) : searchResults?.icons.length ? (
- <>
- <div className="mb-3 flex items-center justify-between">
- <span className="text-muted-foreground text-sm">
- {searchResults.total} results
- </span>
- </div>
- <StickerGrid
- icons={iconsToDisplay}
- onAdd={handleAddSticker}
- addingSticker={addingSticker}
- capSize
+ <div className="flex h-full flex-col gap-4">
+ {recentStickerItems.length > 0 && viewMode === "browse" && (
+ <div className="flex h-full flex-col gap-2">
+ <div className="flex items-center gap-2">
+ <HugeiconsIcon
+ icon={ClockIcon}
+ className="text-muted-foreground size-4"
+ />
+ <span className="text-sm font-medium">Recent</span>
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <button
+ type="button"
+ onClick={clearRecentStickers}
+ className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
+ >
+ <HugeiconsIcon
+ icon={MultiplicationSignIcon}
+ className="text-muted-foreground size-3"
/>
- </>
- ) : searchQuery ? (
- <div className="flex flex-col items-center justify-center gap-3 py-8">
- <EmptyView
- message={`No stickers found for "${searchQuery}"`}
- />
- {category !== "all" && (
- <Button
- variant="outline"
- onClick={() => {
- const q = localSearchQuery || searchQuery;
- if (q) {
- setSearchQuery({ query: q });
- }
- setSelectedCategory({ category: "all" });
- if (q) {
- searchStickers({ query: q });
- }
- }}
- >
- Search in all icons
- </Button>
- )}
- </div>
- ) : null}
- </div>
- )}
-
- {viewMode === "browse" && !selectedCollection && (
- <div className="h-full space-y-4">
- {isLoadingCollections ? (
- <div className="flex items-center justify-center py-8">
- <Spinner className="text-muted-foreground size-6" />
- </div>
- ) : (
- <>
- {category !== "all" && (
- <div className="h-full">
- <CollectionGrid
- collections={filteredCollections}
- onSelectCollection={({ prefix }) =>
- setSelectedCollection({ collection: prefix })
- }
- />
- </div>
- )}
+ </button>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p>Clear recent stickers</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ <StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize />
+ </div>
+ )}
- {category === "all" && filteredCollections.length > 0 && (
- <div className="h-full">
- <CollectionGrid
- collections={filteredCollections.slice(
- 0,
- collectionsToShow,
- )}
- onSelectCollection={({ prefix }) =>
- setSelectedCollection({ collection: prefix })
- }
- />
- </div>
- )}
- </>
- )}
+ {viewMode === "search" && (
+ <div className="h-full">
+ {isSearching ? (
+ <div className="flex items-center justify-center py-8">
+ <Spinner className="text-muted-foreground size-6" />
+ </div>
+ ) : searchResults?.items.length ? (
+ <div className="flex flex-col gap-3">
+ <div className="flex items-center justify-between">
+ <span className="text-muted-foreground text-sm">
+ {searchResults.total} results
+ </span>
</div>
- )}
- </div>
- </ScrollArea>
- </div>
+ <StickerGrid items={itemsToDisplay} shouldCapSize />
+ </div>
+ ) : searchQuery ? (
+ <EmptyView message={`No stickers found for "${searchQuery}"`} />
+ ) : null}
+ </div>
+ )}
</div>
);
}
-interface CollectionItemProps {
- title: string;
- subtitle: string;
- onClick: () => void;
-}
-
-function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
- return (
- <Button
- variant="outline"
- className="h-auto justify-between rounded-md py-2"
- onClick={onClick}
- >
- <div className="text-left">
- <p className="font-medium">{title}</p>
- <p className="text-muted-foreground text-xs">{subtitle}</p>
- </div>
- <HugeiconsIcon icon={ArrowRightIcon} className="size-4" />
- </Button>
- );
-}
-
interface StickerItemProps {
- iconName: string;
- onAdd: (iconName: string) => void;
- isAdding?: boolean;
- capSize?: boolean;
+ item: StickerData;
+ shouldCapSize?: boolean;
}
-function StickerItem({
- iconName,
- onAdd,
- isAdding,
- capSize = false,
-}: StickerItemProps) {
- const [imageError, setImageError] = useState(false);
- const [hostIndex, setHostIndex] = useState(0);
+function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
+ const { addingSticker, addStickerToTimeline } = useStickersStore();
+ const isAdding = addingSticker === item.id;
+ const [hasImageError, setHasImageError] = useState(false);
useEffect(() => {
- if (!iconName) {
+ if (!item.id) {
return;
}
- setImageError(false);
- setHostIndex(0);
- }, [iconName]);
+ setHasImageError(false);
+ }, [item.id]);
- const displayName = iconName.split(":")[1] || iconName;
- const collectionPrefix = iconName.split(":")[0];
+ const displayName = item.name;
+
+ const handleAdd = async () => {
+ try {
+ await addStickerToTimeline({
+ stickerId: item.id,
+ name: item.name,
+ });
+ } catch (error) {
+ console.error("Failed to add sticker:", error);
+ toast.error("Failed to add sticker to timeline");
+ }
+ };
- const preview = imageError ? (
+ const preview = hasImageError ? (
<div className="flex size-full items-center justify-center p-2">
<span className="text-muted-foreground text-center text-xs break-all">
{displayName}
@@ -536,21 +249,13 @@ function StickerItem({
) : (
<div className="flex size-full items-center justify-center p-4">
<Image
- src={
- hostIndex === 0
- ? getIconSvgUrl(iconName, { width: 64, height: 64 })
- : buildIconSvgUrl(
- ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
- iconName,
- { width: 64, height: 64 },
- )
- }
+ src={item.previewUrl}
alt={displayName}
width={64}
height={64}
className="size-full object-contain"
style={
- capSize
+ shouldCapSize
? {
maxWidth: "var(--sticker-max, 160px)",
maxHeight: "var(--sticker-max, 160px)",
@@ -558,12 +263,7 @@ function StickerItem({
: undefined
}
onError={() => {
- const next = hostIndex + 1;
- if (next < ICONIFY_HOSTS.length) {
- setHostIndex(next);
- } else {
- setImageError(true);
- }
+ setHasImageError(true);
}}
loading="lazy"
unoptimized
@@ -572,44 +272,63 @@ function StickerItem({
);
return (
- <Tooltip>
- <TooltipTrigger asChild>
- <div
- className={cn(
- "relative",
- isAdding && "pointer-events-none opacity-50",
- )}
- >
- <DraggableItem
- name={displayName}
- preview={preview}
- dragData={{
- id: iconName,
- type: "sticker",
- name: displayName,
- iconName,
- }}
- onAddToTimeline={() => onAdd(iconName)}
- aspectRatio={1}
- shouldShowLabel={false}
- isRounded={true}
- variant="card"
- className=""
- containerClassName="w-full"
- />
- {isAdding && (
- <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
- <Spinner className="size-6 text-white" />
- </div>
- )}
- </div>
- </TooltipTrigger>
- <TooltipContent>
- <div className="space-y-1">
- <p className="font-medium">{displayName}</p>
- <p className="text-muted-foreground text-xs">{collectionPrefix}</p>
+ <div
+ className={cn("relative", isAdding && "pointer-events-none opacity-50")}
+ >
+ <DraggableItem
+ name={displayName}
+ preview={preview}
+ dragData={{
+ id: item.id,
+ type: "sticker",
+ name: displayName,
+ stickerId: item.id,
+ }}
+ onAddToTimeline={handleAdd}
+ aspectRatio={1}
+ shouldShowLabel={false}
+ isRounded
+ variant="card"
+ containerClassName="w-full"
+ />
+ {isAdding && (
+ <div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
+ <Spinner className="size-6 text-white" />
</div>
- </TooltipContent>
- </Tooltip>
+ )}
+ </div>
+ );
+}
+
+function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
+ const stickerIdParts = stickerId.split(":");
+ if (stickerIdParts.length <= 1) {
+ return stickerId;
+ }
+ return (
+ stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
+ stickerId
);
}
+
+function toRecentStickerItem({
+ stickerId,
+}: {
+ stickerId: string;
+}): StickerData | null {
+ try {
+ const { providerId } = parseStickerId({ stickerId });
+ return {
+ id: stickerId,
+ provider: providerId,
+ name: getStickerNameFromId({ stickerId }),
+ previewUrl: resolveStickerId({
+ stickerId,
+ options: { width: 64, height: 64 },
+ }),
+ metadata: {},
+ };
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/web/src/components/editor/panels/assets/views/text.tsx b/apps/web/src/components/editor/panels/assets/views/text.tsx
index 6360c363..3673acc3 100644
--- a/apps/web/src/components/editor/panels/assets/views/text.tsx
+++ b/apps/web/src/components/editor/panels/assets/views/text.tsx
@@ -1,5 +1,5 @@
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
-import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
+import { PanelView } from "@/components/editor/panels/assets/views/base-view";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
import { buildTextElement } from "@/lib/timeline/element-utils";
@@ -23,7 +23,7 @@ export function TextView() {
};
return (
- <BaseView>
+ <PanelView title="Text">
<DraggableItem
name="Default text"
preview={
@@ -41,6 +41,6 @@ export function TextView() {
onAddToTimeline={handleAddToTimeline}
shouldShowLabel={false}
/>
- </BaseView>
+ </PanelView>
);
}
diff --git a/apps/web/src/components/editor/panels/panel-base-view.tsx b/apps/web/src/components/editor/panels/panel-base-view.tsx
deleted file mode 100644
index dd6151d3..00000000
--- a/apps/web/src/components/editor/panels/panel-base-view.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { ScrollArea } from "@/components/ui/scroll-area";
-import { Separator } from "@/components/ui/separator";
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
-import { cn } from "@/utils/ui";
-
-interface PanelBaseViewProps {
- children?: React.ReactNode;
- defaultTab?: string;
- value?: string;
- onValueChange?: (value: string) => void;
- tabs?: {
- value: string;
- label: string;
- icon?: React.ReactNode;
- content: React.ReactNode;
- }[];
- className?: string;
- ref?: React.Ref<HTMLDivElement>;
-}
-
-function ViewContent({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return (
- <ScrollArea className="flex-1 scrollbar-hidden">
- <div className={cn("p-5", className)}>{children}</div>
- </ScrollArea>
- );
-}
-
-export function PanelBaseView({
- children,
- defaultTab,
- value,
- onValueChange,
- tabs,
- className = "",
- ref,
-}: PanelBaseViewProps) {
- return (
- <div className={cn("flex h-full flex-col", className)} ref={ref}>
- {!tabs || tabs.length === 0 ? (
- <ViewContent className={className}>{children}</ViewContent>
- ) : (
- <Tabs
- defaultValue={defaultTab}
- value={value}
- onValueChange={onValueChange}
- className="flex h-full flex-col"
- >
- <div className="bg-background sticky top-0 z-10">
- <div className="px-3 pt-3 pb-0">
- <TabsList>
- {tabs.map((tab) => (
- <TabsTrigger key={tab.value} value={tab.value}>
- {tab.icon ? (
- <span className="mr-1 inline-flex items-center">
- {tab.icon}
- </span>
- ) : null}
- {tab.label}
- </TabsTrigger>
- ))}
- </TabsList>
- </div>
- <Separator className="mt-3" />
- </div>
- {tabs.map((tab) => (
- <TabsContent
- key={tab.value}
- value={tab.value}
- className="mt-0 flex min-h-0 flex-1 flex-col"
- >
- <ViewContent className={className}>{tab.content}</ViewContent>
- </TabsContent>
- ))}
- </Tabs>
- )}
- </div>
- );
-}
diff --git a/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx
new file mode 100644
index 00000000..4ace25ac
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/bookmark-note-overlay.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import { findCurrentScene } from "@/lib/scenes";
+import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks";
+
+export function BookmarkNoteOverlay() {
+ const editor = useEditor();
+ const currentTime = editor.playback.getCurrentTime();
+ const activeProject = editor.project.getActive();
+
+ if (!activeProject) {
+ return null;
+ }
+
+ const activeScene = findCurrentScene({
+ scenes: activeProject.scenes,
+ currentSceneId: activeProject.currentSceneId,
+ });
+
+ if (!activeScene) {
+ return null;
+ }
+
+ const bookmarks = activeScene.bookmarks;
+ const activeBookmarks = getBookmarksActiveAtTime({
+ bookmarks,
+ time: currentTime,
+ });
+ const bookmarksWithNotes = activeBookmarks.filter(
+ (bookmark) => bookmark.note != null && bookmark.note.trim() !== "",
+ );
+
+ if (bookmarksWithNotes.length === 0) {
+ return null;
+ }
+
+ return (
+ <div
+ className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5"
+ aria-live="polite"
+ >
+ {bookmarksWithNotes.map((bookmark) => (
+ <div
+ key={bookmark.time}
+ className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm"
+ style={{
+ backgroundColor: "rgb(0 0 0 / 0.5)",
+ borderLeft: bookmark.color
+ ? `3px solid ${bookmark.color}`
+ : "3px solid var(--primary)",
+ }}
+ >
+ {bookmark.note}
+ </div>
+ ))}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/context-menu.tsx b/apps/web/src/components/editor/panels/preview/context-menu.tsx
new file mode 100644
index 00000000..d5cf535a
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/context-menu.tsx
@@ -0,0 +1,35 @@
+"use client";
+
+import {
+ ContextMenuCheckboxItem,
+ ContextMenuContent,
+ ContextMenuItem,
+} from "@/components/ui/context-menu";
+import { usePreviewStore } from "@/stores/preview-store";
+
+export function PreviewContextMenu({
+ onToggleFullscreen,
+ containerRef,
+}: {
+ onToggleFullscreen: () => void;
+ containerRef: React.RefObject<HTMLElement | null>;
+}) {
+ const { overlays, setOverlayVisibility } = usePreviewStore();
+
+ return (
+ <ContextMenuContent className="w-56" container={containerRef.current}>
+ <ContextMenuItem onClick={onToggleFullscreen} inset>
+ Full screen
+ </ContextMenuItem>
+ <ContextMenuCheckboxItem
+ checked={overlays.bookmarks}
+ onCheckedChange={(checked) =>
+ setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
+ }
+ >
+ Show bookmarks
+ </ContextMenuCheckboxItem>
+ <ContextMenuItem inset>Show grid</ContextMenuItem>
+ </ContextMenuContent>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/index.tsx b/apps/web/src/components/editor/panels/preview/index.tsx
index 902547bd..90cb11a3 100644
--- a/apps/web/src/components/editor/panels/preview/index.tsx
+++ b/apps/web/src/components/editor/panels/preview/index.tsx
@@ -9,18 +9,13 @@ import { useFullscreen } from "@/hooks/use-fullscreen";
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
import type { RootNode } from "@/services/renderer/nodes/root-node";
import { buildScene } from "@/services/renderer/scene-builder";
-import { formatTimeCode, getLastFrameTime } from "@/lib/time";
+import { getLastFrameTime } from "@/lib/time";
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
-import { EditableTimecode } from "@/components/editable-timecode";
-import { invokeAction } from "@/lib/actions";
-import { Button } from "@/components/ui/button";
-import {
- FullScreenIcon,
- PauseIcon,
- PlayIcon,
-} from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { cn } from "@/utils/ui";
+import { BookmarkNoteOverlay } from "./bookmark-note-overlay";
+import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
+import { usePreviewStore } from "@/stores/preview-store";
+import { PreviewContextMenu } from "./context-menu";
+import { PreviewToolbar } from "./toolbar";
function usePreviewSize() {
const editor = useEditor();
@@ -32,6 +27,30 @@ function usePreviewSize() {
};
}
+export function PreviewPanel() {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
+
+ return (
+ <div
+ ref={containerRef}
+ className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
+ >
+ <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
+ <PreviewCanvas
+ onToggleFullscreen={toggleFullscreen}
+ containerRef={containerRef}
+ />
+ <RenderTreeController />
+ </div>
+ <PreviewToolbar
+ isFullscreen={isFullscreen}
+ onToggleFullscreen={toggleFullscreen}
+ />
+ </div>
+ );
+}
+
function RenderTreeController() {
const editor = useEditor();
const tracks = editor.timeline.getTracks();
@@ -58,98 +77,24 @@ function RenderTreeController() {
return null;
}
-export function PreviewPanel() {
- const containerRef = useRef<HTMLDivElement>(null);
- const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
-
- return (
- <div
- ref={containerRef}
- className={cn(
- "panel bg-background relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm border",
- isFullscreen && "bg-background",
- )}
- >
- <div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
- <PreviewCanvas />
- <RenderTreeController />
- </div>
- <PreviewToolbar
- isFullscreen={isFullscreen}
- onToggleFullscreen={toggleFullscreen}
- />
- </div>
- );
-}
-
-function PreviewToolbar({
- isFullscreen,
+function PreviewCanvas({
onToggleFullscreen,
+ containerRef,
}: {
- isFullscreen: boolean;
onToggleFullscreen: () => void;
+ containerRef: React.RefObject<HTMLElement | null>;
}) {
- const editor = useEditor();
- const isPlaying = editor.playback.getIsPlaying();
- const currentTime = editor.playback.getCurrentTime();
- const totalDuration = editor.timeline.getTotalDuration();
- const fps = editor.project.getActive().settings.fps;
-
- return (
- <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
- <div className="flex items-center mt-1">
- <EditableTimecode
- time={currentTime}
- duration={totalDuration}
- format="HH:MM:SS:FF"
- fps={fps}
- onTimeChange={({ time }) => editor.playback.seek({ time })}
- className="text-center"
- />
- <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
- <span className="text-muted-foreground font-mono text-xs">
- {formatTimeCode({
- timeInSeconds: totalDuration,
- format: "HH:MM:SS:FF",
- fps,
- })}
- </span>
- </div>
-
- <Button
- variant="text"
- size="icon"
- type="button"
- onClick={() => invokeAction("toggle-play")}
- >
- <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
- </Button>
-
- <div className="justify-self-end">
- <Button
- variant="text"
- size="icon"
- type="button"
- onClick={onToggleFullscreen}
- title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
- >
- <HugeiconsIcon icon={FullScreenIcon} />
- </Button>
- </div>
- </div>
- );
-}
-
-function PreviewCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null);
- const containerRef = useRef<HTMLDivElement>(null);
+ const outerContainerRef = useRef<HTMLDivElement>(null);
+ const canvasBoundsRef = useRef<HTMLDivElement>(null);
const lastFrameRef = useRef(-1);
const lastSceneRef = useRef<RootNode | null>(null);
const renderingRef = useRef(false);
const { width: nativeWidth, height: nativeHeight } = usePreviewSize();
- const containerSize = useContainerSize({ containerRef });
+ const containerSize = useContainerSize({ containerRef: outerContainerRef });
const editor = useEditor();
const activeProject = editor.project.getActive();
+ const { overlays } = usePreviewStore();
const renderer = useMemo(() => {
return new CanvasRenderer({
@@ -224,24 +169,42 @@ function PreviewCanvas() {
return (
<div
- ref={containerRef}
- className="relative flex h-full w-full items-center justify-center"
+ ref={outerContainerRef}
+ className="relative flex size-full items-center justify-center"
>
- <canvas
- ref={canvasRef}
- width={nativeWidth}
- height={nativeHeight}
- className="block border"
- style={{
- width: displaySize.width,
- height: displaySize.height,
- background:
- activeProject.settings.background.type === "blur"
- ? "transparent"
- : activeProject?.settings.background.color,
- }}
- />
- <PreviewInteractionOverlay canvasRef={canvasRef} />
+ <ContextMenu>
+ <ContextMenuTrigger asChild>
+ <div
+ ref={canvasBoundsRef}
+ className="relative"
+ style={{ width: displaySize.width, height: displaySize.height }}
+ >
+ <canvas
+ ref={canvasRef}
+ width={nativeWidth}
+ height={nativeHeight}
+ className="block border"
+ style={{
+ width: displaySize.width,
+ height: displaySize.height,
+ background:
+ activeProject.settings.background.type === "blur"
+ ? "transparent"
+ : activeProject?.settings.background.color,
+ }}
+ />
+ <PreviewInteractionOverlay
+ canvasRef={canvasRef}
+ containerRef={canvasBoundsRef}
+ />
+ {overlays.bookmarks && <BookmarkNoteOverlay />}
+ </div>
+ </ContextMenuTrigger>
+ <PreviewContextMenu
+ onToggleFullscreen={onToggleFullscreen}
+ containerRef={containerRef}
+ />
+ </ContextMenu>
</div>
);
}
diff --git a/apps/web/src/components/editor/layout-guide-overlay.tsx b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx
index 0f02da1c..44efb4c8 100644
--- a/apps/web/src/components/editor/layout-guide-overlay.tsx
+++ b/apps/web/src/components/editor/panels/preview/layout-guide-overlay.tsx
@@ -1,27 +1,27 @@
-"use client";
-
-import { useEditorStore } from "@/stores/editor-store";
-import Image from "next/image";
-
-function TikTokGuide() {
- return (
- <div className="pointer-events-none absolute inset-0">
- <Image
- src="/platform-guides/tiktok-blueprint.png"
- alt="TikTok layout guide"
- className="absolute inset-0 size-full object-contain"
- draggable={false}
- fill
- />
- </div>
- );
-}
-
-export function LayoutGuideOverlay() {
- const { layoutGuide } = useEditorStore();
-
- if (layoutGuide.platform === null) return null;
- if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
-
- return null;
-}
+"use client";
+
+import { usePreviewStore } from "@/stores/preview-store";
+import Image from "next/image";
+
+function TikTokGuide() {
+ return (
+ <div className="pointer-events-none absolute inset-0">
+ <Image
+ src="/platform-guides/tiktok-blueprint.png"
+ alt="TikTok layout guide"
+ className="absolute inset-0 size-full object-contain"
+ draggable={false}
+ fill
+ />
+ </div>
+ );
+}
+
+export function LayoutGuideOverlay() {
+ const { layoutGuide } = usePreviewStore();
+
+ if (layoutGuide.platform === null) return null;
+ if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
+
+ return null;
+}
diff --git a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
index c13379f0..4c33a1a1 100644
--- a/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
+++ b/apps/web/src/components/editor/panels/preview/preview-interaction-overlay.tsx
@@ -1,20 +1,54 @@
import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
-import { cn } from "@/utils/ui";
+import { TransformHandles } from "./transform-handles";
+import { SnapGuides } from "./snap-guides";
+import { TextEditOverlay } from "./text-edit-overlay";
export function PreviewInteractionOverlay({
canvasRef,
+ containerRef,
}: {
canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ containerRef: React.RefObject<HTMLDivElement | null>;
}) {
- const { onPointerDown, onPointerMove, onPointerUp } =
- usePreviewInteraction({ canvasRef });
+ const {
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+ onDoubleClick,
+ snapLines,
+ editingText,
+ commitTextEdit,
+ cancelTextEdit,
+ } = usePreviewInteraction({ canvasRef });
return (
- <div
- className={cn("absolute inset-0 pointer-events-auto")}
- onPointerDown={onPointerDown}
- onPointerMove={onPointerMove}
- onPointerUp={onPointerUp}
- />
+ <div className="absolute inset-0">
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */}
+ <div
+ className="absolute inset-0 pointer-events-auto"
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onDoubleClick={onDoubleClick}
+ />
+ {editingText ? (
+ <TextEditOverlay
+ canvasRef={canvasRef}
+ containerRef={containerRef}
+ trackId={editingText.trackId}
+ elementId={editingText.elementId}
+ element={editingText.element}
+ onCommit={commitTextEdit}
+ onCancel={cancelTextEdit}
+ />
+ ) : (
+ <TransformHandles canvasRef={canvasRef} containerRef={containerRef} />
+ )}
+ <SnapGuides
+ lines={snapLines}
+ canvasRef={canvasRef}
+ containerRef={containerRef}
+ />
+ </div>
);
}
diff --git a/apps/web/src/components/editor/panels/preview/snap-guides.tsx b/apps/web/src/components/editor/panels/preview/snap-guides.tsx
new file mode 100644
index 00000000..4aa714a1
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/snap-guides.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import type { SnapLine } from "@/lib/preview/preview-snap";
+import { positionToOverlay } from "@/lib/preview/preview-coords";
+
+export function SnapGuides({
+ lines,
+ canvasRef,
+ containerRef,
+}: {
+ lines: SnapLine[];
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ containerRef: React.RefObject<HTMLDivElement | null>;
+}) {
+ const editor = useEditor();
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+ const canvasRect = canvasRef.current?.getBoundingClientRect();
+ const containerRect = containerRef.current?.getBoundingClientRect();
+
+ if (!canvasRect || !containerRect || lines.length === 0) {
+ return null;
+ }
+
+ const toOverlayX = (logicalX: number) =>
+ positionToOverlay({
+ positionX: logicalX,
+ positionY: 0,
+ canvasRect,
+ containerRect,
+ canvasSize,
+ }).x;
+
+ const toOverlayY = (logicalY: number) =>
+ positionToOverlay({
+ positionX: 0,
+ positionY: logicalY,
+ canvasRect,
+ containerRect,
+ canvasSize,
+ }).y;
+
+ return (
+ <div className="pointer-events-none absolute inset-0" aria-hidden>
+ {lines.map((line) => {
+ if (line.type === "vertical") {
+ return (
+ <div
+ key={`vertical-${line.position}`}
+ className="absolute top-0 bottom-0 w-px bg-white/70"
+ style={{ left: toOverlayX(line.position) }}
+ />
+ );
+ }
+ return (
+ <div
+ key={`horizontal-${line.position}`}
+ className="absolute left-0 right-0 h-px bg-white/70"
+ style={{ top: toOverlayY(line.position) }}
+ />
+ );
+ })}
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
new file mode 100644
index 00000000..265e972d
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/text-edit-overlay.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import { useCallback, useEffect, useRef } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import type { TextElement } from "@/types/timeline";
+import {
+ positionToOverlay,
+ getDisplayScale,
+} from "@/lib/preview/preview-coords";
+import {
+ DEFAULT_LINE_HEIGHT,
+ FONT_SIZE_SCALE_REFERENCE,
+} from "@/constants/text-constants";
+
+const TEXT_BACKGROUND_PADDING = "4px 8px";
+const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06;
+
+export function TextEditOverlay({
+ canvasRef,
+ containerRef,
+ trackId,
+ elementId,
+ element,
+ onCommit,
+ onCancel,
+}: {
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ containerRef: React.RefObject<HTMLDivElement | null>;
+ trackId: string;
+ elementId: string;
+ element: TextElement;
+ onCommit: () => void;
+ onCancel: () => void;
+}) {
+ const editor = useEditor();
+ const divRef = useRef<HTMLDivElement>(null);
+
+ useEffect(() => {
+ const div = divRef.current;
+ if (!div) return;
+ div.focus();
+ const range = document.createRange();
+ range.selectNodeContents(div);
+ const selection = window.getSelection();
+ selection?.removeAllRanges();
+ selection?.addRange(range);
+ }, []);
+
+ const handleInput = useCallback(() => {
+ const div = divRef.current;
+ if (!div) return;
+ const text = div.innerText;
+ editor.timeline.previewElements({
+ updates: [{ trackId, elementId, updates: { content: text } }],
+ });
+ }, [editor.timeline, trackId, elementId]);
+
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ const { key } = event;
+ if (key === "Escape") {
+ event.preventDefault();
+ onCancel();
+ return;
+ }
+ },
+ [onCancel],
+ );
+
+ const canvasRect = canvasRef.current?.getBoundingClientRect();
+ const containerRect = containerRef.current?.getBoundingClientRect();
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ if (!canvasRect || !containerRect || !canvasSize) return null;
+
+ const { x: posX, y: posY } = positionToOverlay({
+ positionX: element.transform.position.x,
+ positionY: element.transform.position.y,
+ canvasRect,
+ containerRect,
+ canvasSize,
+ });
+
+ const { x: displayScaleX } = getDisplayScale({
+ canvasRect,
+ canvasSize,
+ });
+
+ const displayFontSize =
+ element.fontSize *
+ (canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
+ displayScaleX;
+
+ const verticalAlignmentOffset =
+ displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM;
+
+ const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
+ const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
+ const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
+ const letterSpacing = element.letterSpacing ?? 0;
+ const backgroundColor =
+ element.backgroundColor === "transparent"
+ ? "transparent"
+ : element.backgroundColor;
+
+ return (
+ <div
+ className="absolute"
+ style={{
+ left: posX,
+ top: posY - verticalAlignmentOffset,
+ transform: `translate(-50%, -50%) scale(${element.transform.scale}) rotate(${element.transform.rotate}deg)`,
+ transformOrigin: "center center",
+ }}
+ >
+ {/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
+ <div
+ ref={divRef}
+ contentEditable
+ suppressContentEditableWarning
+ tabIndex={0}
+ role="textbox"
+ aria-label="Edit text"
+ className="cursor-text select-text outline-none whitespace-pre"
+ style={{
+ fontSize: displayFontSize,
+ fontFamily: element.fontFamily,
+ fontWeight,
+ fontStyle,
+ textAlign: element.textAlign,
+ letterSpacing: `${letterSpacing}px`,
+ lineHeight,
+ color: element.color,
+ backgroundColor,
+ minHeight: displayFontSize * lineHeight,
+ textDecoration: element.textDecoration ?? "none",
+ padding:
+ backgroundColor === "transparent" ? 0 : TEXT_BACKGROUND_PADDING,
+ minWidth: 1,
+ }}
+ onInput={handleInput}
+ onBlur={onCommit}
+ onKeyDown={handleKeyDown}
+ >
+ {element.content || ""}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/toolbar.tsx b/apps/web/src/components/editor/panels/preview/toolbar.tsx
new file mode 100644
index 00000000..7f5cd9bc
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/toolbar.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import { useEditor } from "@/hooks/use-editor";
+import { formatTimeCode } from "@/lib/time";
+import { invokeAction } from "@/lib/actions";
+import { EditableTimecode } from "@/components/editable-timecode";
+import { Button } from "@/components/ui/button";
+import {
+ FullScreenIcon,
+ PauseIcon,
+ PlayIcon,
+} from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { OcSocialIcon } from "@opencut/ui/icons";
+import { Separator } from "@/components/ui/separator";
+
+export function PreviewToolbar({
+ isFullscreen,
+ onToggleFullscreen,
+}: {
+ isFullscreen: boolean;
+ onToggleFullscreen: () => void;
+}) {
+ const editor = useEditor();
+ const isPlaying = editor.playback.getIsPlaying();
+ const currentTime = editor.playback.getCurrentTime();
+ const totalDuration = editor.timeline.getTotalDuration();
+ const fps = editor.project.getActive().settings.fps;
+
+ return (
+ <div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
+ <div className="flex items-center">
+ <EditableTimecode
+ time={currentTime}
+ duration={totalDuration}
+ format="HH:MM:SS:FF"
+ fps={fps}
+ onTimeChange={({ time }) => editor.playback.seek({ time })}
+ className="text-center"
+ />
+ <span className="text-muted-foreground px-2 font-mono text-xs">/</span>
+ <span className="text-muted-foreground font-mono text-xs">
+ {formatTimeCode({
+ timeInSeconds: totalDuration,
+ format: "HH:MM:SS:FF",
+ fps,
+ })}
+ </span>
+ </div>
+
+ <Button
+ variant="text"
+ size="icon"
+ onClick={() => invokeAction("toggle-play")}
+ >
+ <HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
+ </Button>
+
+ <div className="justify-self-end flex items-center gap-2.5">
+ <Button
+ variant="secondary"
+ size="sm"
+ className="[&_svg]:size-auto px-1 h-7"
+ onClick={onToggleFullscreen}
+ title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
+ >
+ <OcSocialIcon size={20} />
+ </Button>
+ <Separator orientation="vertical" className="h-4" />
+ <Button
+ variant="text"
+ onClick={onToggleFullscreen}
+ title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
+ >
+ <HugeiconsIcon icon={FullScreenIcon} />
+ </Button>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/preview/transform-handles.tsx b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
new file mode 100644
index 00000000..976c3019
--- /dev/null
+++ b/apps/web/src/components/editor/panels/preview/transform-handles.tsx
@@ -0,0 +1,283 @@
+"use client";
+
+import { useTransformHandles } from "@/hooks/use-transform-handles";
+import { useEditor } from "@/hooks/use-editor";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import { SnapGuides } from "./snap-guides";
+import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords";
+import type { ElementBounds } from "@/lib/preview/element-bounds";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { Rotate01Icon } from "@hugeicons/core-free-icons";
+
+const HANDLE_SIZE = 10;
+const ROTATION_HANDLE_OFFSET = 24;
+const ROTATION_HANDLE_RADIUS = 10;
+const CORNER_HIT_AREA_SIZE = 18;
+
+type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
+const CORNERS: Corner[] = [
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right",
+];
+
+function getCornerPosition({
+ bounds,
+ corner,
+}: {
+ bounds: ElementBounds;
+ corner: Corner;
+}): { x: number; y: number } {
+ const halfW = bounds.width / 2;
+ const halfH = bounds.height / 2;
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+
+ const localX =
+ corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
+ const localY =
+ corner === "top-left" || corner === "top-right" ? -halfH : halfH;
+
+ return {
+ x: bounds.cx + (localX * cos - localY * sin),
+ y: bounds.cy + (localX * sin + localY * cos),
+ };
+}
+
+function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
+ x: number;
+ y: number;
+} {
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+ const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET;
+ return {
+ x: bounds.cx - localY * sin,
+ y: bounds.cy + localY * cos,
+ };
+}
+
+function getOverlayContext({
+ canvasRef,
+ containerRef,
+}: {
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ containerRef: React.RefObject<HTMLDivElement | null>;
+}): {
+ canvasRect: DOMRect;
+ containerRect: DOMRect;
+} | null {
+ const canvasRect = canvasRef.current?.getBoundingClientRect();
+ const containerRect = containerRef.current?.getBoundingClientRect();
+ if (!canvasRect || !containerRect) return null;
+ return { canvasRect, containerRect };
+}
+
+export function TransformHandles({
+ canvasRef,
+ containerRef,
+}: {
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
+ containerRef: React.RefObject<HTMLDivElement | null>;
+}) {
+ const {
+ selectedWithBounds,
+ hasVisualSelection,
+ snapLines,
+ handleCornerPointerDown,
+ handleRotationPointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ } = useTransformHandles({ canvasRef });
+
+ const editor = useEditor();
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ if (!hasVisualSelection || !selectedWithBounds) return null;
+
+ const overlayContext = getOverlayContext({ canvasRef, containerRef });
+ if (!overlayContext) return null;
+
+ const { bounds, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return null;
+
+ const { canvasRect, containerRect } = overlayContext;
+ const displayScale = getDisplayScale({ canvasRect, canvasSize });
+
+ const toOverlay = ({
+ canvasX,
+ canvasY,
+ }: {
+ canvasX: number;
+ canvasY: number;
+ }) =>
+ canvasToOverlay({
+ canvasX,
+ canvasY,
+ canvasRect,
+ containerRect,
+ canvasSize,
+ });
+
+ const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
+ const outlineWidth = bounds.width * displayScale.x;
+ const outlineHeight = bounds.height * displayScale.y;
+
+ const rotationHandle = getRotationHandlePosition({ bounds });
+ const rotationHandleScreen = toOverlay({
+ canvasX: rotationHandle.x,
+ canvasY: rotationHandle.y,
+ });
+
+ return (
+ <div className="pointer-events-none absolute inset-0" aria-hidden>
+ <SnapGuides
+ lines={snapLines}
+ canvasRef={canvasRef}
+ containerRef={containerRef}
+ />
+ <BoundingBoxOutline
+ center={center}
+ outlineWidth={outlineWidth}
+ outlineHeight={outlineHeight}
+ rotation={bounds.rotation}
+ />
+ {CORNERS.map((corner) => {
+ const cornerPosition = getCornerPosition({ bounds, corner });
+ const screen = toOverlay({
+ canvasX: cornerPosition.x,
+ canvasY: cornerPosition.y,
+ });
+ return (
+ <CornerHandle
+ key={corner}
+ corner={corner}
+ screen={screen}
+ onPointerDown={(event) =>
+ handleCornerPointerDown({ event, corner })
+ }
+ onPointerMove={(event) => handlePointerMove({ event })}
+ onPointerUp={(event) => handlePointerUp({ event })}
+ />
+ );
+ })}
+ <RotationHandle
+ screen={rotationHandleScreen}
+ onPointerDown={(event) => handleRotationPointerDown({ event })}
+ onPointerMove={(event) => handlePointerMove({ event })}
+ onPointerUp={(event) => handlePointerUp({ event })}
+ />
+ </div>
+ );
+}
+
+function BoundingBoxOutline({
+ center,
+ outlineWidth,
+ outlineHeight,
+ rotation,
+}: {
+ center: { x: number; y: number };
+ outlineWidth: number;
+ outlineHeight: number;
+ rotation: number;
+}) {
+ return (
+ <div
+ className="pointer-events-none absolute"
+ style={{
+ left: center.x - outlineWidth / 2,
+ top: center.y - outlineHeight / 2,
+ width: outlineWidth,
+ height: outlineHeight,
+ transform: `rotate(${rotation}deg)`,
+ transformOrigin: "center center",
+ border: "1px solid white",
+ boxSizing: "border-box",
+ opacity: 0.75,
+ }}
+ />
+ );
+}
+
+function CornerHandle({
+ corner,
+ screen,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ corner: Corner;
+ screen: { x: number; y: number };
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ const isNesw = corner === "top-right" || corner === "bottom-left";
+
+ return (
+ <button
+ type="button"
+ className={cn(
+ "absolute flex items-center justify-center outline-none",
+ isNesw ? "cursor-nesw-resize" : "cursor-nwse-resize",
+ )}
+ style={{
+ left: screen.x - CORNER_HIT_AREA_SIZE / 2,
+ top: screen.y - CORNER_HIT_AREA_SIZE / 2,
+ width: CORNER_HIT_AREA_SIZE,
+ height: CORNER_HIT_AREA_SIZE,
+ pointerEvents: "auto",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
+ onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
+ >
+ <div
+ className="rounded-sm bg-white"
+ style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
+ />
+ </button>
+ );
+}
+
+function RotationHandle({
+ screen,
+ onPointerDown,
+ onPointerMove,
+ onPointerUp,
+}: {
+ screen: { x: number; y: number };
+ onPointerDown: (event: React.PointerEvent) => void;
+ onPointerMove: (event: React.PointerEvent) => void;
+ onPointerUp: (event: React.PointerEvent) => void;
+}) {
+ return (
+ <button
+ type="button"
+ className="absolute flex items-center justify-center rounded-full bg-white text-black shadow-sm outline-none"
+ style={{
+ left: screen.x - ROTATION_HANDLE_RADIUS,
+ top: screen.y - ROTATION_HANDLE_RADIUS,
+ width: ROTATION_HANDLE_RADIUS * 2,
+ height: ROTATION_HANDLE_RADIUS * 2,
+ pointerEvents: "auto",
+ }}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={onPointerUp}
+ onPointerLeave={onPointerUp}
+ onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
+ onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
+ >
+ <HugeiconsIcon icon={Rotate01Icon} className="size-3" strokeWidth={2.5} />
+ </button>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/empty-view.tsx b/apps/web/src/components/editor/panels/properties/empty-view.tsx
index 8ccf07ee..c5576dda 100644
--- a/apps/web/src/components/editor/panels/properties/empty-view.tsx
+++ b/apps/web/src/components/editor/panels/properties/empty-view.tsx
@@ -17,4 +17,4 @@ export function EmptyView() {
</div>
</div>
);
-} \ No newline at end of file
+}
diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts
new file mode 100644
index 00000000..395666cb
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/hooks/use-property-draft.ts
@@ -0,0 +1,72 @@
+import { useReducer, useRef } from "react";
+import { evaluateMathExpression } from "@/utils/math";
+
+function looksLikeExpression({ input }: { input: string }): boolean {
+ const trimmed = input.trim();
+ if (!trimmed) return false;
+ if (/[+*/]/.test(input)) return true;
+ const minusIndex = trimmed.indexOf("-");
+ return minusIndex > 0;
+}
+
+export function usePropertyDraft<T>({
+ displayValue: sourceDisplay,
+ parse,
+ onPreview,
+ onCommit,
+ supportsExpressions = true,
+}: {
+ displayValue: string;
+ parse: (input: string) => T | null;
+ onPreview: (value: T) => void;
+ onCommit: () => void;
+ supportsExpressions?: boolean;
+}) {
+ const [, forceRender] = useReducer(
+ (renderVersion: number) => renderVersion + 1,
+ 0,
+ );
+ const isEditing = useRef(false);
+ const draft = useRef("");
+
+ return {
+ displayValue: isEditing.current ? draft.current : sourceDisplay,
+ scrubTo: (value: number) => {
+ const parsed = parse(String(value));
+ if (parsed !== null) onPreview(parsed);
+ },
+ commitScrub: onCommit,
+ onFocus: () => {
+ isEditing.current = true;
+ draft.current = sourceDisplay;
+ forceRender();
+ },
+ onChange: (
+ event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
+ ) => {
+ draft.current = event.target.value;
+ forceRender();
+
+ const parsed = parse(event.target.value);
+ if (parsed !== null) {
+ onPreview(parsed);
+ }
+ },
+ onBlur: () => {
+ if (
+ supportsExpressions &&
+ looksLikeExpression({ input: draft.current })
+ ) {
+ const evaluated = evaluateMathExpression({ input: draft.current });
+ if (evaluated !== null) {
+ const parsed = parse(String(evaluated));
+ if (parsed !== null) onPreview(parsed);
+ }
+ }
+ onCommit();
+ isEditing.current = false;
+ draft.current = "";
+ forceRender();
+ },
+ };
+}
diff --git a/apps/web/src/components/editor/panels/properties/property-item.tsx b/apps/web/src/components/editor/panels/properties/property-item.tsx
deleted file mode 100644
index a682097a..00000000
--- a/apps/web/src/components/editor/panels/properties/property-item.tsx
+++ /dev/null
@@ -1,132 +0,0 @@
-import { useState } from "react";
-import { cn } from "@/utils/ui";
-import { HugeiconsIcon } from "@hugeicons/react";
-import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
-
-interface PropertyItemProps {
- direction?: "row" | "column";
- children: React.ReactNode;
- className?: string;
-}
-
-export function PropertyItem({
- direction = "row",
- children,
- className,
-}: PropertyItemProps) {
- return (
- <div
- className={cn(
- "flex gap-2",
- direction === "row"
- ? "items-center justify-between gap-6"
- : "flex-col gap-1.5",
- className,
- )}
- >
- {children}
- </div>
- );
-}
-
-export function PropertyItemLabel({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return (
- <span className={cn("text-muted-foreground text-xs", className)}>
- {children}
- </span>
- );
-}
-
-export function PropertyItemValue({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return <div className={cn("flex-1 text-sm", className)}>{children}</div>;
-}
-
-interface PropertyGroupProps {
- title: string;
- children: React.ReactNode;
- defaultExpanded?: boolean;
- collapsible?: boolean;
- className?: string;
- hasBorderTop?: boolean;
- hasBorderBottom?: boolean;
-}
-
-export function PropertyGroup({
- title,
- children,
- defaultExpanded = true,
- collapsible = true,
- className,
- hasBorderTop = true,
- hasBorderBottom = true,
-}: PropertyGroupProps) {
- const [isExpanded, setIsExpanded] = useState(defaultExpanded);
-
- return (
- <div
- className={cn(
- "flex flex-col",
- hasBorderTop && "border-t",
- hasBorderBottom && "last:border-b",
- className,
- )}
- >
- {collapsible ? (
- <button
- type="button"
- className="flex items-center justify-between p-3.5 cursor-pointer"
- onClick={() => setIsExpanded(!isExpanded)}
- >
- <PropertyGroupTitle isExpanded={isExpanded}>
- {title}
- </PropertyGroupTitle>
- <HugeiconsIcon
- icon={isExpanded ? MinusSignIcon : PlusSignIcon}
- className={cn(
- "size-3",
- isExpanded ? "text-foreground" : "text-muted-foreground",
- )}
- />
- </button>
- ) : (
- <div className="flex items-center justify-between p-4">
- <PropertyGroupTitle isExpanded>{title}</PropertyGroupTitle>
- </div>
- )}
- {(collapsible ? isExpanded : true) && (
- <div className="p-3 pt-0">{children}</div>
- )}
- </div>
- );
-}
-
-function PropertyGroupTitle({
- children,
- isExpanded = false,
-}: {
- children: React.ReactNode;
- isExpanded?: boolean;
-}) {
- return (
- <span
- className={cn(
- "text-xs font-medium",
- isExpanded ? "text-foreground" : "text-muted-foreground",
- )}
- >
- {children}
- </span>
- );
-}
diff --git a/apps/web/src/components/editor/panels/properties/section.tsx b/apps/web/src/components/editor/panels/properties/section.tsx
new file mode 100644
index 00000000..0b45065f
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/section.tsx
@@ -0,0 +1,161 @@
+import { createContext, useContext, useState } from "react";
+import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ArrowDownIcon } from "@hugeicons/core-free-icons";
+
+const sectionExpandedCache = new Map<string, boolean>();
+
+interface SectionContext {
+ isOpen: boolean;
+ toggle: () => void;
+ collapsible: boolean;
+}
+
+const SectionCtx = createContext<SectionContext | null>(null);
+
+function useSectionContext() {
+ return useContext(SectionCtx);
+}
+
+interface SectionProps {
+ children: React.ReactNode;
+ collapsible?: boolean;
+ defaultOpen?: boolean;
+ sectionKey?: string;
+ className?: string;
+ hasBorderTop?: boolean;
+ hasBorderBottom?: boolean;
+}
+
+export function Section({
+ children,
+ collapsible = false,
+ defaultOpen = true,
+ sectionKey,
+ className,
+ hasBorderTop = true,
+ hasBorderBottom = true,
+}: SectionProps) {
+ const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
+ const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
+
+ const toggle = () => {
+ const next = !isOpen;
+ setIsOpen(next);
+ if (sectionKey) sectionExpandedCache.set(sectionKey, next);
+ };
+
+ return (
+ <SectionCtx.Provider value={{ isOpen, toggle, collapsible }}>
+ <div
+ className={cn(
+ "flex flex-col",
+ hasBorderTop && "border-t",
+ hasBorderBottom && "last:border-b",
+ className,
+ )}
+ >
+ {children}
+ </div>
+ </SectionCtx.Provider>
+ );
+}
+
+interface SectionHeaderProps {
+ title: string;
+ children?: React.ReactNode;
+ onClick?: () => void;
+ className?: string;
+}
+
+export function SectionHeader({
+ title,
+ children,
+ onClick,
+ className,
+}: SectionHeaderProps) {
+ const ctx = useSectionContext();
+ const isCollapsible = ctx?.collapsible ?? false;
+ const isOpen = ctx?.isOpen ?? true;
+ const isInteractive = isCollapsible || !!onClick;
+
+ const handleClick = isCollapsible ? ctx?.toggle : onClick;
+
+ const content = (
+ <>
+ <span
+ className={cn(
+ "text-sm font-medium",
+ isOpen ? "text-foreground" : "text-muted-foreground",
+ )}
+ >
+ {title}
+ </span>
+ <div className="flex items-center gap-1">
+ {children}
+ {isCollapsible && (
+ <HugeiconsIcon
+ icon={ArrowDownIcon}
+ className={cn(
+ "size-3 shrink-0 transition-transform duration-200 ease-out",
+ isOpen
+ ? "rotate-0 text-foreground"
+ : "-rotate-90 text-muted-foreground",
+ )}
+ />
+ )}
+ </div>
+ </>
+ );
+
+ const baseClassName = cn(
+ "flex w-full items-center justify-between h-11 px-3.5",
+ className,
+ );
+
+ if (isInteractive) {
+ return (
+ <button
+ type="button"
+ className={cn(baseClassName, "cursor-pointer text-left")}
+ onClick={(event) => {
+ handleClick?.();
+ event.currentTarget.blur();
+ }}
+ >
+ {content}
+ </button>
+ );
+ }
+
+ return <div className={baseClassName}>{content}</div>;
+}
+
+export function SectionContent({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ const ctx = useSectionContext();
+ const isCollapsible = ctx?.collapsible ?? false;
+ const isOpen = ctx?.isOpen ?? true;
+
+ if (isCollapsible) {
+ return (
+ <div
+ className={cn(
+ "grid transition-[grid-template-rows] duration-100 ease-out",
+ isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
+ )}
+ >
+ <div className="overflow-hidden">
+ <div className={cn("p-4 pt-0", className)}>{children}</div>
+ </div>
+ </div>
+ );
+ }
+
+ return <div className={cn("p-4 pt-0", className)}>{children}</div>;
+}
diff --git a/apps/web/src/components/editor/panels/properties/sections/blending.tsx b/apps/web/src/components/editor/panels/properties/sections/blending.tsx
new file mode 100644
index 00000000..430efa25
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/sections/blending.tsx
@@ -0,0 +1,196 @@
+import { useEditor } from "@/hooks/use-editor";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { clamp } from "@/utils/math";
+import { NumberField } from "@/components/ui/number-field";
+import {
+ DEFAULT_BLEND_MODE,
+ DEFAULT_OPACITY,
+} from "@/constants/timeline-constants";
+import { OcCheckerboardIcon } from "@opencut/ui/icons";
+import { Fragment, useRef } from "react";
+import { Section, SectionContent, SectionHeader } from "../section";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type { BlendMode } from "@/types/rendering";
+import type { ElementType } from "@/types/timeline";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { RainDropIcon } from "@hugeicons/core-free-icons";
+
+type BlendingElement = {
+ id: string;
+ opacity: number;
+ type: ElementType;
+ blendMode?: BlendMode;
+};
+
+const BLEND_MODE_GROUPS = [
+ [{ value: "normal", label: "Normal" }],
+ [
+ { value: "darken", label: "Darken" },
+ { value: "multiply", label: "Multiply" },
+ { value: "color-burn", label: "Color Burn" },
+ ],
+ [
+ { value: "lighten", label: "Lighten" },
+ { value: "screen", label: "Screen" },
+ { value: "plus-lighter", label: "Plus Lighter" },
+ { value: "color-dodge", label: "Color Dodge" },
+ ],
+ [
+ { value: "overlay", label: "Overlay" },
+ { value: "soft-light", label: "Soft Light" },
+ { value: "hard-light", label: "Hard Light" },
+ ],
+ [
+ { value: "difference", label: "Difference" },
+ { value: "exclusion", label: "Exclusion" },
+ ],
+ [
+ { value: "hue", label: "Hue" },
+ { value: "saturation", label: "Saturation" },
+ { value: "color", label: "Color" },
+ { value: "luminosity", label: "Luminosity" },
+ ],
+];
+
+export function BlendingSection({
+ element,
+ trackId,
+}: {
+ element: BlendingElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE;
+ const didSelectRef = useRef(false);
+ const committedBlendModeRef = useRef(blendMode);
+ if (!editor.timeline.isPreviewActive()) {
+ committedBlendModeRef.current = blendMode;
+ }
+
+ const previewBlendMode = (value: BlendMode) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { blendMode: value } },
+ ],
+ });
+
+ const commitBlendMode = (value: string) => {
+ if (editor.timeline.isPreviewActive()) {
+ editor.timeline.commitPreview();
+ } else {
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { blendMode: value as BlendMode },
+ },
+ ],
+ });
+ }
+ didSelectRef.current = true;
+ };
+
+ const handleBlendModeOpenChange = (isOpen: boolean) => {
+ if (!isOpen) {
+ if (!didSelectRef.current) editor.timeline.discardPreview();
+ didSelectRef.current = false;
+ }
+ };
+
+ const opacity = usePropertyDraft({
+ displayValue: Math.round(element.opacity * 100).toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: parsed, min: 0, max: 100 }) / 100;
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
+ updates: [
+ { trackId, elementId: element.id, updates: { opacity: value } },
+ ],
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.type}:blending`}>
+ <SectionHeader title="Blending" />
+ <SectionContent>
+ <div className="flex items-start gap-2">
+ <div className="w-1/2 space-y-1.5">
+ <NumberField
+ className="w-full"
+ icon={
+ <OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
+ }
+ value={opacity.displayValue}
+ min={0}
+ max={100}
+ onFocus={opacity.onFocus}
+ onChange={opacity.onChange}
+ onBlur={opacity.onBlur}
+ onScrub={opacity.scrubTo}
+ onScrubEnd={opacity.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { opacity: DEFAULT_OPACITY },
+ },
+ ],
+ })
+ }
+ isDefault={element.opacity === DEFAULT_OPACITY}
+ dragSensitivity="slow"
+ />
+ </div>
+ <div className="w-1/2 space-y-1.5">
+ <Select
+ value={committedBlendModeRef.current}
+ onOpenChange={handleBlendModeOpenChange}
+ onValueChange={commitBlendMode}
+ >
+ <SelectTrigger
+ icon={<HugeiconsIcon icon={RainDropIcon} />}
+ className="w-full"
+ >
+ <SelectValue placeholder="Select blend mode" />
+ </SelectTrigger>
+ <SelectContent className="w-36">
+ {BLEND_MODE_GROUPS.map((group, groupIndex) => (
+ <Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
+ {group.map((option) => (
+ <SelectItem
+ key={option.value}
+ value={option.value}
+ onPointerEnter={() =>
+ previewBlendMode(option.value as BlendMode)
+ }
+ >
+ {option.label}
+ </SelectItem>
+ ))}
+ {groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
+ <SelectSeparator />
+ ) : null}
+ </Fragment>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/sections/index.tsx b/apps/web/src/components/editor/panels/properties/sections/index.tsx
new file mode 100644
index 00000000..8be58f39
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/sections/index.tsx
@@ -0,0 +1,2 @@
+export * from "./transform";
+export * from "./blending";
diff --git a/apps/web/src/components/editor/panels/properties/sections/transform.tsx b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
new file mode 100644
index 00000000..17a54b4c
--- /dev/null
+++ b/apps/web/src/components/editor/panels/properties/sections/transform.tsx
@@ -0,0 +1,248 @@
+import { NumberField } from "@/components/ui/number-field";
+import { useEditor } from "@/hooks/use-editor";
+import { usePropertyDraft } from "../hooks/use-property-draft";
+import { clamp } from "@/utils/math";
+import type { ElementType, Transform } from "@/types/timeline";
+import { Section, SectionContent, SectionHeader } from "../section";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ ArrowExpandIcon,
+ Link05Icon,
+ RotateClockwiseIcon,
+} from "@hugeicons/core-free-icons";
+import { useState } from "react";
+import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
+
+type TransformElement = {
+ id: string;
+ transform: Transform;
+ type: ElementType;
+};
+
+function parseFloat_({ input }: { input: string }): number | null {
+ const parsed = parseFloat(input);
+ return Number.isNaN(parsed) ? null : parsed;
+}
+
+export function TransformSection({
+ element,
+ trackId,
+}: {
+ element: TransformElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const [isScaleLocked, setIsScaleLocked] = useState(false);
+
+ const previewTransform = (transform: Partial<Transform>) => {
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { transform: { ...element.transform, ...transform } },
+ },
+ ],
+ });
+ };
+
+ const commit = () => editor.timeline.commitPreview();
+
+ const positionX = usePropertyDraft({
+ displayValue: Math.round(element.transform.position.x).toString(),
+ parse: (input) => parseFloat_({ input }),
+ onPreview: (value) =>
+ previewTransform({
+ position: { ...element.transform.position, x: value },
+ }),
+ onCommit: commit,
+ });
+
+ const positionY = usePropertyDraft({
+ displayValue: Math.round(element.transform.position.y).toString(),
+ parse: (input) => parseFloat_({ input }),
+ onPreview: (value) =>
+ previewTransform({
+ position: { ...element.transform.position, y: value },
+ }),
+ onCommit: commit,
+ });
+
+ const scale = usePropertyDraft({
+ displayValue: Math.round(element.transform.scale * 100).toString(),
+ parse: (input) => {
+ const parsed = parseFloat_({ input });
+ if (parsed === null) return null;
+ return Math.max(parsed, 1) / 100;
+ },
+ onPreview: (value) => previewTransform({ scale: value }),
+ onCommit: commit,
+ });
+ const scaleFieldProps = {
+ className: "flex-1",
+ value: scale.displayValue,
+ onFocus: scale.onFocus,
+ onChange: scale.onChange,
+ onBlur: scale.onBlur,
+ dragSensitivity: "slow" as const,
+ onScrub: scale.scrubTo,
+ onScrubEnd: scale.commitScrub,
+ onReset: () =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ transform: {
+ ...element.transform,
+ scale: DEFAULT_TRANSFORM.scale,
+ },
+ },
+ },
+ ],
+ }),
+ isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale,
+ };
+
+ const rotation = usePropertyDraft({
+ displayValue: Math.round(element.transform.rotate).toString(),
+ parse: (input) => {
+ const parsed = parseFloat_({ input });
+ if (parsed === null) return null;
+ return clamp({ value: parsed, min: -360, max: 360 });
+ },
+ onPreview: (value) => previewTransform({ rotate: value }),
+ onCommit: commit,
+ });
+
+ return (
+ <Section collapsible sectionKey={`${element.type}:transform`}>
+ <SectionHeader title="Transform" />
+ <SectionContent>
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center gap-2">
+ {isScaleLocked ? (
+ <>
+ <NumberField icon="W" {...scaleFieldProps} />
+ <NumberField icon="H" {...scaleFieldProps} />
+ </>
+ ) : (
+ <NumberField
+ icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
+ {...scaleFieldProps}
+ className="flex-1"
+ />
+ )}
+ <Button
+ variant={isScaleLocked ? "secondary" : "ghost"}
+ size="icon"
+ aria-pressed={isScaleLocked}
+ onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
+ >
+ <HugeiconsIcon icon={Link05Icon} />
+ </Button>
+ </div>
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon="X"
+ className="flex-1"
+ value={positionX.displayValue}
+ onFocus={positionX.onFocus}
+ onChange={positionX.onChange}
+ onBlur={positionX.onBlur}
+ onScrub={positionX.scrubTo}
+ onScrubEnd={positionX.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ transform: {
+ ...element.transform,
+ position: {
+ ...element.transform.position,
+ x: DEFAULT_TRANSFORM.position.x,
+ },
+ },
+ },
+ },
+ ],
+ })
+ }
+ isDefault={
+ element.transform.position.x === DEFAULT_TRANSFORM.position.x
+ }
+ />
+ <NumberField
+ icon="Y"
+ className="flex-1"
+ value={positionY.displayValue}
+ onFocus={positionY.onFocus}
+ onChange={positionY.onChange}
+ onBlur={positionY.onBlur}
+ onScrub={positionY.scrubTo}
+ onScrubEnd={positionY.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ transform: {
+ ...element.transform,
+ position: {
+ ...element.transform.position,
+ y: DEFAULT_TRANSFORM.position.y,
+ },
+ },
+ },
+ },
+ ],
+ })
+ }
+ isDefault={
+ element.transform.position.y === DEFAULT_TRANSFORM.position.y
+ }
+ />
+ </div>
+
+ <div className="flex items-center gap-2">
+ <NumberField
+ icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
+ className="flex-1"
+ value={rotation.displayValue}
+ onFocus={rotation.onFocus}
+ onChange={rotation.onChange}
+ onBlur={rotation.onBlur}
+ dragSensitivity="slow"
+ onScrub={rotation.scrubTo}
+ onScrubEnd={rotation.commitScrub}
+ onReset={() =>
+ editor.timeline.updateElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: {
+ transform: {
+ ...element.transform,
+ rotate: DEFAULT_TRANSFORM.rotate,
+ },
+ },
+ },
+ ],
+ })
+ }
+ isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
+ />
+ </div>
+ </div>
+ </SectionContent>
+ </Section>
+ );
+}
diff --git a/apps/web/src/components/editor/panels/properties/text-properties.tsx b/apps/web/src/components/editor/panels/properties/text-properties.tsx
index 22e50e4e..cfca52dc 100644
--- a/apps/web/src/components/editor/panels/properties/text-properties.tsx
+++ b/apps/web/src/components/editor/panels/properties/text-properties.tsx
@@ -1,24 +1,31 @@
import { Textarea } from "@/components/ui/textarea";
import { FontPicker } from "@/components/ui/font-picker";
-import type { FontFamily } from "@/constants/font-constants";
import type { TextElement } from "@/types/timeline";
import { Slider } from "@/components/ui/slider";
-import { Input } from "@/components/ui/input";
-import { Button } from "@/components/ui/button";
-import { useReducer, useRef } from "react";
-import { PanelBaseView } from "@/components/editor/panels/panel-base-view";
-import {
- PropertyGroup,
- PropertyItem,
- PropertyItemLabel,
- PropertyItemValue,
-} from "./property-item";
+import { NumberField } from "@/components/ui/number-field";
+import { useRef } from "react";
+import { Section, SectionContent, SectionHeader } from "./section";
import { ColorPicker } from "@/components/ui/color-picker";
import { uppercase } from "@/utils/string";
import { clamp } from "@/utils/math";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_COLOR } from "@/constants/project-constants";
-import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/constants/text-constants";
+import {
+ DEFAULT_TEXT_ELEMENT,
+ MAX_FONT_SIZE,
+ MIN_FONT_SIZE,
+} from "@/constants/text-constants";
+import { usePropertyDraft } from "./hooks/use-property-draft";
+import { TransformSection, BlendingSection } from "./sections";
+import {
+ Select,
+ SelectTrigger,
+ SelectValue,
+ SelectContent,
+ SelectItem,
+} from "@/components/ui/select";
+import { OcFontWeightIcon } from "@opencut/ui/icons";
+import { Label } from "@/components/ui/label";
export function TextProperties({
element,
@@ -27,624 +34,229 @@ export function TextProperties({
element: TextElement;
trackId: string;
}) {
- const editor = useEditor();
- const containerRef = useRef<HTMLDivElement>(null);
- const [, forceRender] = useReducer((x: number) => x + 1, 0);
- const isEditingFontSize = useRef(false);
- const isEditingOpacity = useRef(false);
- const isEditingContent = useRef(false);
- const fontSizeDraft = useRef("");
- const opacityDraft = useRef("");
- const contentDraft = useRef("");
-
- const fontSizeDisplay = isEditingFontSize.current
- ? fontSizeDraft.current
- : element.fontSize.toString();
- const opacityDisplay = isEditingOpacity.current
- ? opacityDraft.current
- : Math.round(element.opacity * 100).toString();
- const contentDisplay = isEditingContent.current
- ? contentDraft.current
- : element.content;
-
- const lastSelectedColor = useRef(DEFAULT_COLOR);
- const initialFontSizeRef = useRef<number | null>(null);
- const initialOpacityRef = useRef<number | null>(null);
- const initialContentRef = useRef<string | null>(null);
- const initialColorRef = useRef<string | null>(null);
- const initialBgColorRef = useRef<string | null>(null);
-
- const handleFontSizeChange = ({ value }: { value: string }) => {
- fontSizeDraft.current = value;
- forceRender();
+ return (
+ <div className="flex h-full flex-col">
+ <ContentSection element={element} trackId={trackId} />
+ <TransformSection element={element} trackId={trackId} />
+ <BlendingSection element={element} trackId={trackId} />
+ <FontSection element={element} trackId={trackId} />
+ <ColorSection element={element} trackId={trackId} />
+ </div>
+ );
+}
- if (value.trim() !== "") {
- if (initialFontSizeRef.current === null) {
- initialFontSizeRef.current = element.fontSize;
- }
- const parsed = parseInt(value, 10);
- const fontSize = Number.isNaN(parsed)
- ? element.fontSize
- : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
- editor.timeline.updateElements({
- updates: [{ trackId, elementId: element.id, updates: { fontSize } }],
- pushHistory: false,
- });
- }
- };
+function ContentSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
- const handleFontSizeBlur = () => {
- if (initialFontSizeRef.current !== null) {
- const parsed = parseInt(fontSizeDraft.current, 10);
- const fontSize = Number.isNaN(parsed)
- ? element.fontSize
- : clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
- editor.timeline.updateElements({
+ const content = usePropertyDraft({
+ displayValue: element.content,
+ parse: (input) => input,
+ onPreview: (value) =>
+ editor.timeline.previewElements({
updates: [
- {
- trackId,
- elementId: element.id,
- updates: { fontSize: initialFontSizeRef.current },
- },
+ { trackId, elementId: element.id, updates: { content: value } },
],
- pushHistory: false,
- });
- editor.timeline.updateElements({
- updates: [{ trackId, elementId: element.id, updates: { fontSize } }],
- pushHistory: true,
- });
- initialFontSizeRef.current = null;
- }
- isEditingFontSize.current = false;
- fontSizeDraft.current = "";
- forceRender();
- };
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
- const handleOpacityChange = ({ value }: { value: string }) => {
- opacityDraft.current = value;
- forceRender();
-
- if (value.trim() !== "") {
- if (initialOpacityRef.current === null) {
- initialOpacityRef.current = element.opacity;
- }
- const parsed = parseInt(value, 10);
- const opacityPercent = Number.isNaN(parsed)
- ? Math.round(element.opacity * 100)
- : clamp({ value: parsed, min: 0, max: 100 });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: opacityPercent / 100 },
- },
- ],
- pushHistory: false,
- });
- }
- };
+ return (
+ <Section collapsible sectionKey="text:content" hasBorderTop={false}>
+ <SectionHeader title="Content" />
+ <SectionContent>
+ <Textarea
+ placeholder="Name"
+ value={content.displayValue}
+ className="min-h-20"
+ onFocus={content.onFocus}
+ onChange={content.onChange}
+ onBlur={content.onBlur}
+ />
+ </SectionContent>
+ </Section>
+ );
+}
- const handleOpacityBlur = () => {
- if (initialOpacityRef.current !== null) {
- const parsed = parseInt(opacityDraft.current, 10);
- const opacityPercent = Number.isNaN(parsed)
- ? Math.round(element.opacity * 100)
- : clamp({ value: parsed, min: 0, max: 100 });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: initialOpacityRef.current },
- },
- ],
- pushHistory: false,
- });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: opacityPercent / 100 },
- },
- ],
- pushHistory: true,
- });
- initialOpacityRef.current = null;
- }
- isEditingOpacity.current = false;
- opacityDraft.current = "";
- forceRender();
- };
+function FontSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
- const handleColorChange = ({ color }: { color: string }) => {
- if (color !== "transparent") {
- lastSelectedColor.current = color;
- }
- if (initialBgColorRef.current === null) {
- initialBgColorRef.current = element.backgroundColor;
- }
- if (initialBgColorRef.current !== null) {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { backgroundColor: color },
- },
- ],
- pushHistory: false,
- });
- } else {
- editor.timeline.updateElements({
+ const fontSize = usePropertyDraft({
+ displayValue: element.fontSize.toString(),
+ parse: (input) => {
+ const parsed = parseFloat(input);
+ if (Number.isNaN(parsed)) return null;
+ return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
+ },
+ onPreview: (value) =>
+ editor.timeline.previewElements({
updates: [
- {
- trackId,
- elementId: element.id,
- updates: { backgroundColor: color },
- },
+ { trackId, elementId: element.id, updates: { fontSize: value } },
],
- });
- }
- };
-
- const handleColorChangeEnd = ({ color }: { color: string }) => {
- if (initialBgColorRef.current !== null) {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { backgroundColor: initialBgColorRef.current },
- },
- ],
- pushHistory: false,
- });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { backgroundColor: `#${color}` },
- },
- ],
- pushHistory: true,
- });
- initialBgColorRef.current = null;
- }
- };
+ }),
+ onCommit: () => editor.timeline.commitPreview(),
+ });
return (
- <div className="flex h-full flex-col" ref={containerRef}>
- <PanelBaseView className="p-0">
- <PropertyGroup title="Content" hasBorderTop={false} collapsible={false}>
- <Textarea
- placeholder="Name"
- value={contentDisplay}
- className="bg-accent min-h-20"
- onFocus={() => {
- isEditingContent.current = true;
- contentDraft.current = element.content;
- initialContentRef.current = element.content;
- forceRender();
- }}
- onChange={(event) => {
- contentDraft.current = event.target.value;
- forceRender();
- if (initialContentRef.current === null) {
- initialContentRef.current = element.content;
- }
+ <Section collapsible sectionKey="text:font">
+ <SectionHeader title="Font" />
+ <SectionContent>
+ <div className="flex flex-col gap-2">
+ <FontPicker
+ defaultValue={element.fontFamily}
+ onValueChange={(value) =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
- updates: { content: event.target.value },
+ updates: { fontFamily: value },
},
],
- pushHistory: false,
- });
- }}
- onBlur={() => {
- if (initialContentRef.current !== null) {
- const finalContent = contentDraft.current;
- editor.timeline.updateElements({
+ })
+ }
+ />
+ <Select value={element.fontWeight}>
+ <SelectTrigger className="w-full" icon={<OcFontWeightIcon />}>
+ <SelectValue placeholder="Select weight" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="100">Thin</SelectItem>
+ <SelectItem value="200">Extra Light</SelectItem>
+ <SelectItem value="300">Light</SelectItem>
+ <SelectItem value="400">Normal</SelectItem>
+ <SelectItem value="500">Medium</SelectItem>
+ <SelectItem value="600">Semi Bold</SelectItem>
+ <SelectItem value="700">Bold</SelectItem>
+ <SelectItem value="800">Extra Bold</SelectItem>
+ <SelectItem value="900">Black</SelectItem>
+ </SelectContent>
+ </Select>
+
+ <Label>Font size</Label>
+ <div className="flex items-center gap-2">
+ <Slider
+ value={[element.fontSize]}
+ min={MIN_FONT_SIZE}
+ max={MAX_FONT_SIZE}
+ step={1}
+ onValueChange={([value]) =>
+ editor.timeline.previewElements({
updates: [
{
trackId,
elementId: element.id,
- updates: { content: initialContentRef.current },
+ updates: { fontSize: value },
},
],
- pushHistory: false,
- });
+ })
+ }
+ onValueCommit={() => editor.timeline.commitPreview()}
+ className="w-full"
+ />
+ <NumberField
+ className="w-18 shrink-0"
+ value={fontSize.displayValue}
+ min={MIN_FONT_SIZE}
+ max={MAX_FONT_SIZE}
+ onFocus={fontSize.onFocus}
+ onChange={fontSize.onChange}
+ onBlur={fontSize.onBlur}
+ onReset={() =>
editor.timeline.updateElements({
updates: [
{
trackId,
elementId: element.id,
- updates: { content: finalContent },
+ updates: {
+ fontSize: DEFAULT_TEXT_ELEMENT.fontSize,
+ },
},
],
- pushHistory: true,
- });
- initialContentRef.current = null;
+ })
}
- isEditingContent.current = false;
- contentDraft.current = "";
- forceRender();
+ isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
+ />
+ </div>
+ </div>
+ </SectionContent>
+ </Section>
+ );
+}
+
+function ColorSection({
+ element,
+ trackId,
+}: {
+ element: TextElement;
+ trackId: string;
+}) {
+ const editor = useEditor();
+ const lastSelectedColor = useRef(DEFAULT_COLOR);
+
+ return (
+ <Section collapsible sectionKey="text:color" hasBorderBottom={false}>
+ <SectionHeader title="Color" />
+ <SectionContent>
+ <div className="flex flex-col gap-6">
+ <ColorPicker
+ value={uppercase({
+ string: (element.color || "FFFFFF").replace("#", ""),
+ })}
+ onChange={(color) =>
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { color: `#${color}` },
+ },
+ ],
+ })
+ }
+ onChangeEnd={() => editor.timeline.commitPreview()}
+ />
+ <ColorPicker
+ value={
+ element.backgroundColor === "transparent"
+ ? lastSelectedColor.current.replace("#", "")
+ : element.backgroundColor.replace("#", "")
+ }
+ onChange={(color) => {
+ const hexColor = `#${color}`;
+ if (color !== "transparent") {
+ lastSelectedColor.current = hexColor;
+ }
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId: element.id,
+ updates: { backgroundColor: hexColor },
+ },
+ ],
+ });
}}
+ onChangeEnd={() => editor.timeline.commitPreview()}
+ className={
+ element.backgroundColor === "transparent"
+ ? "pointer-events-none opacity-50"
+ : ""
+ }
/>
- </PropertyGroup>
- <PropertyGroup title="Typography" collapsible={false}>
- <div className="space-y-6">
- <PropertyItem direction="column">
- <PropertyItemLabel>Font</PropertyItemLabel>
- <PropertyItemValue>
- <FontPicker
- defaultValue={element.fontFamily}
- onValueChange={(value: FontFamily) =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { fontFamily: value },
- },
- ],
- })
- }
- />
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Style</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Button
- variant={
- element.fontWeight === "bold" ? "default" : "outline"
- }
- size="sm"
- onClick={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- fontWeight:
- element.fontWeight === "bold"
- ? "normal"
- : "bold",
- },
- },
- ],
- })
- }
- className="h-8 px-3 font-bold"
- >
- B
- </Button>
- <Button
- variant={
- element.fontStyle === "italic" ? "default" : "outline"
- }
- size="sm"
- onClick={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- fontStyle:
- element.fontStyle === "italic"
- ? "normal"
- : "italic",
- },
- },
- ],
- })
- }
- className="h-8 px-3 italic"
- >
- I
- </Button>
- <Button
- variant={
- element.textDecoration === "underline"
- ? "default"
- : "outline"
- }
- size="sm"
- onClick={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- textDecoration:
- element.textDecoration === "underline"
- ? "none"
- : "underline",
- },
- },
- ],
- })
- }
- className="h-8 px-3 underline"
- >
- U
- </Button>
- <Button
- variant={
- element.textDecoration === "line-through"
- ? "default"
- : "outline"
- }
- size="sm"
- onClick={() =>
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- textDecoration:
- element.textDecoration === "line-through"
- ? "none"
- : "line-through",
- },
- },
- ],
- })
- }
- className="h-8 px-3 line-through"
- >
- S
- </Button>
- </div>
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Font size</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Slider
- value={[element.fontSize]}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- step={1}
- onValueChange={([value]) => {
- if (initialFontSizeRef.current === null) {
- initialFontSizeRef.current = element.fontSize;
- }
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { fontSize: value },
- },
- ],
- pushHistory: false,
- });
- }}
- onValueCommit={([value]) => {
- if (initialFontSizeRef.current !== null) {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: {
- fontSize: initialFontSizeRef.current,
- },
- },
- ],
- pushHistory: false,
- });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { fontSize: value },
- },
- ],
- pushHistory: true,
- });
- initialFontSizeRef.current = null;
- }
- }}
- className="w-full"
- />
- <Input
- type="number"
- value={fontSizeDisplay}
- min={MIN_FONT_SIZE}
- max={MAX_FONT_SIZE}
- onFocus={() => {
- isEditingFontSize.current = true;
- fontSizeDraft.current = element.fontSize.toString();
- forceRender();
- }}
- onChange={(e) =>
- handleFontSizeChange({ value: e.target.value })
- }
- onBlur={handleFontSizeBlur}
- className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
- />
- </div>
- </PropertyItemValue>
- </PropertyItem>
- </div>
- </PropertyGroup>
- <PropertyGroup title="Appearance" collapsible={false}>
- <div className="space-y-6">
- <PropertyItem direction="column">
- <PropertyItemLabel>Color</PropertyItemLabel>
- <PropertyItemValue>
- <ColorPicker
- value={uppercase({
- string: (element.color || "FFFFFF").replace("#", ""),
- })}
- onChange={(color) => {
- if (initialColorRef.current === null) {
- initialColorRef.current = element.color || "#FFFFFF";
- }
- if (initialColorRef.current !== null) {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { color: `#${color}` },
- },
- ],
- pushHistory: false,
- });
- } else {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { color: `#${color}` },
- },
- ],
- });
- }
- }}
- onChangeEnd={(color) => {
- if (initialColorRef.current !== null) {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { color: initialColorRef.current },
- },
- ],
- pushHistory: false,
- });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { color: `#${color}` },
- },
- ],
- pushHistory: true,
- });
- initialColorRef.current = null;
- }
- }}
- containerRef={containerRef}
- />
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Opacity</PropertyItemLabel>
- <PropertyItemValue>
- <div className="flex items-center gap-2">
- <Slider
- value={[element.opacity * 100]}
- min={0}
- max={100}
- step={1}
- onValueChange={([value]) => {
- if (initialOpacityRef.current === null) {
- initialOpacityRef.current = element.opacity;
- }
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: value / 100 },
- },
- ],
- pushHistory: false,
- });
- }}
- onValueCommit={([value]) => {
- if (initialOpacityRef.current !== null) {
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: initialOpacityRef.current },
- },
- ],
- pushHistory: false,
- });
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId: element.id,
- updates: { opacity: value / 100 },
- },
- ],
- pushHistory: true,
- });
- initialOpacityRef.current = null;
- }
- }}
- className="w-full"
- />
- <Input
- type="number"
- value={opacityDisplay}
- min={0}
- max={100}
- onFocus={() => {
- isEditingOpacity.current = true;
- opacityDraft.current = Math.round(
- element.opacity * 100,
- ).toString();
- forceRender();
- }}
- onChange={(e) =>
- handleOpacityChange({ value: e.target.value })
- }
- onBlur={handleOpacityBlur}
- className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
- />
- </div>
- </PropertyItemValue>
- </PropertyItem>
- <PropertyItem direction="column">
- <PropertyItemLabel>Background</PropertyItemLabel>
- <PropertyItemValue>
- <ColorPicker
- value={
- element.backgroundColor === "transparent"
- ? lastSelectedColor.current.replace("#", "")
- : element.backgroundColor.replace("#", "")
- }
- onChange={(color) =>
- handleColorChange({ color: `#${color}` })
- }
- onChangeEnd={(color) => handleColorChangeEnd({ color })}
- containerRef={containerRef}
- className={
- element.backgroundColor === "transparent"
- ? "pointer-events-none opacity-50"
- : ""
- }
- />
- </PropertyItemValue>
- </PropertyItem>
- </div>
- </PropertyGroup>
- </PanelBaseView>
- </div>
+ </div>
+ </SectionContent>
+ </Section>
);
}
diff --git a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
index cfcb1e23..64824f9a 100644
--- a/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
+++ b/apps/web/src/components/editor/panels/timeline/bookmarks.tsx
@@ -1,21 +1,75 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { EditorCore } from "@/core";
import { useEditor } from "@/hooks/use-editor";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
+import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
+import {
+ DEFAULT_BOOKMARK_COLOR,
+ TIMELINE_CONSTANTS,
+} from "@/constants/timeline-constants";
+import { DEFAULT_FPS } from "@/constants/project-constants";
import { getSnappedSeekTime } from "@/lib/time";
-import { Bookmark02Icon } from "@hugeicons/core-free-icons";
+import {
+ ArrowTurnBackwardIcon,
+ Delete02Icon,
+} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
+import type { Bookmark } from "@/types/timeline";
+import {
+ Popover,
+ PopoverAnchor,
+ PopoverContent,
+} from "@/components/ui/popover";
+import { Input } from "@/components/ui/input";
+import { ColorPicker } from "@/components/ui/color-picker";
+import { Button } from "@/components/ui/button";
+import { Label } from "@/components/ui/label";
+import { uppercase } from "@/utils/string";
+import { clamp } from "@/utils/math";
+
+const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND;
+const MIN_BOOKMARK_WIDTH_PX = 2;
+const BOOKMARK_MARKER_WIDTH_PX = 12;
+const BOOKMARK_MARKER_HEIGHT_PX = 15;
+const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2;
+const BOOKMARK_MARKER_CLIP_PATH =
+ "polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)";
+
+function seekToBookmarkTime({
+ editor,
+ time,
+}: {
+ editor: EditorCore;
+ time: number;
+}) {
+ const activeProject = editor.project.getActive();
+ const duration = editor.timeline.getTotalDuration();
+ const fps = activeProject?.settings.fps ?? DEFAULT_FPS;
+ const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps });
+ editor.playback.seek({ time: snappedTime });
+}
interface TimelineBookmarksRowProps {
zoomLevel: number;
dynamicTimelineWidth: number;
- handleWheel: (e: React.WheelEvent) => void;
- handleTimelineContentClick: (e: React.MouseEvent) => void;
- handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
- handleRulerMouseDown: (e: React.MouseEvent) => void;
+ dragState: BookmarkDragState;
+ onBookmarkMouseDown: (params: {
+ event: React.MouseEvent;
+ bookmark: Bookmark;
+ }) => void;
+ handleWheel: (event: React.WheelEvent) => void;
+ handleTimelineContentClick: (event: React.MouseEvent) => void;
+ handleRulerTrackingMouseDown: (event: React.MouseEvent) => void;
+ handleRulerMouseDown: (event: React.MouseEvent) => void;
}
export function TimelineBookmarksRow({
zoomLevel,
dynamicTimelineWidth,
+ dragState,
+ onBookmarkMouseDown,
handleWheel,
handleTimelineContentClick,
handleRulerTrackingMouseDown,
@@ -34,17 +88,23 @@ export function TimelineBookmarksRow({
aria-label="Timeline ruler"
type="button"
onWheel={handleWheel}
- onClick={handleTimelineContentClick}
+ onClick={(event) => {
+ if (!event.currentTarget.contains(event.target as Node)) return;
+ handleTimelineContentClick(event);
+ }}
onMouseDown={(event) => {
+ if (!event.currentTarget.contains(event.target as Node)) return;
handleRulerMouseDown(event);
handleRulerTrackingMouseDown(event);
}}
>
- {activeScene.bookmarks.map((time: number) => (
+ {activeScene.bookmarks.map((bookmark) => (
<TimelineBookmark
- key={`bookmark-row-${time}`}
- time={time}
+ key={`bookmark-${bookmark.time}`}
+ bookmark={bookmark}
zoomLevel={zoomLevel}
+ dragState={dragState}
+ onBookmarkMouseDown={onBookmarkMouseDown}
/>
))}
</button>
@@ -52,60 +112,305 @@ export function TimelineBookmarksRow({
);
}
-export function TimelineBookmark({
- time,
+function TimelineBookmark({
+ bookmark,
zoomLevel,
+ dragState,
+ onBookmarkMouseDown,
}: {
- time: number;
+ bookmark: Bookmark;
zoomLevel: number;
+ dragState: BookmarkDragState;
+ onBookmarkMouseDown: (params: {
+ event: React.MouseEvent;
+ bookmark: Bookmark;
+ }) => void;
}) {
const editor = useEditor();
- const activeProject = editor.project.getActive();
const duration = editor.timeline.getTotalDuration();
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+
+ const isDragging =
+ dragState.isDragging &&
+ dragState.bookmarkTime !== null &&
+ Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON;
+
+ const displayTime = isDragging ? dragState.currentTime : bookmark.time;
+ const time = bookmark.time;
+ const bookmarkDuration = bookmark.duration ?? 0;
+ const durationWidth =
+ bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0;
+ const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX;
+ const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0);
+ const left = displayTime * PIXELS_PER_SECOND * zoomLevel;
+ const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
+ const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
+ const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR;
+
+ const handleSeek = () => seekToBookmarkTime({ editor, time });
+
+ const handleClick = (event: React.MouseEvent) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (event.detail === 2) {
+ setIsPopoverOpen(true);
+ } else {
+ handleSeek();
+ }
+ };
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key !== "Enter" && event.key !== " ") return;
+ event.preventDefault();
+ handleSeek();
+ };
- const handleBookmarkActivate = ({
- event,
- }: {
- event:
- | React.MouseEvent<HTMLButtonElement>
- | React.KeyboardEvent<HTMLButtonElement>;
- }) => {
+ const handleMouseDown = (event: React.MouseEvent) => {
+ onBookmarkMouseDown({ event, bookmark });
+ event.preventDefault();
event.stopPropagation();
- const framesPerSecond = activeProject?.settings.fps ?? 30;
- const snappedTime = getSnappedSeekTime({
- rawTime: time,
- duration,
- fps: framesPerSecond,
- });
- editor.playback.seek({ time: snappedTime });
};
return (
- <button
- className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0"
- style={{
- left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
- }}
- aria-label={`Seek to bookmark at ${time}s`}
- type="button"
- onMouseDown={(event) => {
- event.preventDefault();
- event.stopPropagation();
- }}
- onClick={(event) => handleBookmarkActivate({ event })}
- onKeyDown={(event) => {
- if (event.key !== "Enter" && event.key !== " ") return;
- event.preventDefault();
- handleBookmarkActivate({ event });
- }}
- >
- <div className="text-primary absolute top-[-1px] left-[-5px]">
- <HugeiconsIcon
- icon={Bookmark02Icon}
- aria-hidden="true"
- className="fill-primary size-3"
+ <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
+ <PopoverAnchor asChild>
+ <button
+ className={`absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
+ style={{
+ left: `${bookmarkLeft}px`,
+ width: `${bookmarkWidth}px`,
+ }}
+ aria-label={`Bookmark at ${time.toFixed(1)}s`}
+ type="button"
+ onMouseDown={handleMouseDown}
+ onClick={handleClick}
+ onKeyDown={handleKeyDown}
+ >
+ {hasDurationRange ? (
+ <div
+ className="absolute opacity-30"
+ style={{
+ top: 1.5,
+ height: BOOKMARK_MARKER_HEIGHT_PX - 2.5,
+ left: BOOKMARK_HALF_WIDTH_PX,
+ width: durationWidth,
+ backgroundColor: iconColor,
+ }}
+ />
+ ) : null}
+ <div
+ className="absolute top-0 overflow-hidden"
+ style={{
+ left: 0,
+ width: BOOKMARK_HALF_WIDTH_PX,
+ height: BOOKMARK_MARKER_HEIGHT_PX,
+ }}
+ >
+ <div
+ className="absolute inset-0"
+ style={{
+ width: BOOKMARK_MARKER_WIDTH_PX,
+ height: BOOKMARK_MARKER_HEIGHT_PX,
+ clipPath: BOOKMARK_MARKER_CLIP_PATH,
+ backgroundColor: "hsl(var(--background))",
+ }}
+ />
+ <div
+ className="absolute"
+ style={{
+ top: 1,
+ left: 1,
+ width: BOOKMARK_MARKER_WIDTH_PX - 2,
+ height: BOOKMARK_MARKER_HEIGHT_PX - 2,
+ clipPath: BOOKMARK_MARKER_CLIP_PATH,
+ backgroundColor: iconColor,
+ }}
+ />
+ </div>
+ <div
+ className="absolute top-0 overflow-hidden"
+ style={{
+ left: rightHalfLeft,
+ width: BOOKMARK_HALF_WIDTH_PX,
+ height: BOOKMARK_MARKER_HEIGHT_PX,
+ }}
+ >
+ <div
+ className="absolute top-0"
+ style={{
+ left: -BOOKMARK_HALF_WIDTH_PX,
+ width: BOOKMARK_MARKER_WIDTH_PX,
+ height: BOOKMARK_MARKER_HEIGHT_PX,
+ clipPath: BOOKMARK_MARKER_CLIP_PATH,
+ backgroundColor: "hsl(var(--background))",
+ }}
+ />
+ <div
+ className="absolute"
+ style={{
+ top: 1,
+ left: 1 - BOOKMARK_HALF_WIDTH_PX,
+ width: BOOKMARK_MARKER_WIDTH_PX - 2,
+ height: BOOKMARK_MARKER_HEIGHT_PX - 2,
+ clipPath: BOOKMARK_MARKER_CLIP_PATH,
+ backgroundColor: iconColor,
+ }}
+ />
+ </div>
+ </button>
+ </PopoverAnchor>
+ <PopoverContent
+ className="w-64 flex flex-col gap-3 p-3"
+ align="start"
+ side="bottom"
+ sideOffset={8}
+ onOpenAutoFocus={(event) => event.preventDefault()}
+ >
+ <BookmarkPopoverContent
+ bookmark={bookmark}
+ time={time}
+ timelineDuration={duration}
+ onPopoverClose={() => setIsPopoverOpen(false)}
/>
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function BookmarkPopoverContent({
+ bookmark,
+ time,
+ timelineDuration,
+ onPopoverClose,
+}: {
+ bookmark: Bookmark;
+ time: number;
+ timelineDuration: number;
+ onPopoverClose: () => void;
+}) {
+ const editor = useEditor();
+ const [draftColorHex, setDraftColorHex] = useState(
+ (bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
+ );
+
+ useEffect(() => {
+ setDraftColorHex(
+ (bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
+ );
+ }, [bookmark.color]);
+
+ const handleRemove = () => {
+ editor.scenes.removeBookmark({ time });
+ onPopoverClose();
+ };
+
+ const handleUpdate = ({
+ note,
+ color,
+ duration,
+ }: Partial<{ note: string; color: string; duration: number }>) => {
+ const updates: Partial<{ note: string; color: string; duration: number }> =
+ {};
+ if (note !== undefined && note !== bookmark.note) updates.note = note;
+ if (
+ color !== undefined &&
+ color.toUpperCase() !== (bookmark.color ?? "").toUpperCase()
+ ) {
+ updates.color = color;
+ }
+ if (duration !== undefined && duration !== bookmark.duration) {
+ updates.duration = duration;
+ }
+ if (Object.keys(updates).length === 0) return;
+ editor.scenes.updateBookmark({ time, updates });
+ };
+
+ return (
+ <>
+ <div className="flex flex-col gap-2">
+ <Label className="text-xs">Note</Label>
+ <Input
+ placeholder="Add a note..."
+ value={bookmark.note ?? ""}
+ onChange={(event) => handleUpdate({ note: event.target.value })}
+ className="h-8 text-sm"
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label className="text-xs">Color</Label>
+ <div className="relative">
+ <ColorPicker
+ value={uppercase({ string: draftColorHex })}
+ onChange={(color) => setDraftColorHex(uppercase({ string: color }))}
+ onChangeEnd={(color) =>
+ handleUpdate({ color: `#${uppercase({ string: color })}` })
+ }
+ className="bg-background border"
+ />
+ {bookmark.color &&
+ bookmark.color.replace(/^#/, "").toUpperCase() !==
+ DEFAULT_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
+ <Button
+ type="button"
+ variant="text"
+ size="text"
+ aria-label="Reset to default color"
+ className="absolute top-1/2 right-1 -translate-y-1/2 mr-1"
+ onClick={() =>
+ editor.scenes.updateBookmark({
+ time,
+ updates: { color: undefined },
+ })
+ }
+ >
+ <HugeiconsIcon
+ icon={ArrowTurnBackwardIcon}
+ className="!size-3.5"
+ />
+ </Button>
+ )}
+ </div>
</div>
- </button>
+ <div className="flex flex-col gap-2">
+ <Label className="text-xs">Duration</Label>
+ <div className="flex items-center gap-1.5">
+ <Input
+ type="number"
+ min={0}
+ step={0.1}
+ value={bookmark.duration ?? 0}
+ onChange={(event) => {
+ const parsed = parseFloat(event.target.value);
+ const value = Number.isNaN(parsed)
+ ? 0
+ : clamp({
+ value: parsed,
+ min: 0,
+ max: Math.max(0, timelineDuration - time),
+ });
+ handleUpdate({ duration: value });
+ }}
+ className="h-8 text-sm"
+ containerClassName="w-full"
+ />
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ className="text-destructive hover:bg-destructive/10"
+ onClick={handleRemove}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ handleRemove();
+ }
+ }}
+ aria-label="delete bookmark"
+ >
+ <HugeiconsIcon icon={Delete02Icon} className="!size-3.5" />
+ Delete
+ </Button>
+ </>
);
}
diff --git a/apps/web/src/components/editor/panels/timeline/index.tsx b/apps/web/src/components/editor/panels/timeline/index.tsx
index 94edc2d0..7e6ff326 100644
--- a/apps/web/src/components/editor/panels/timeline/index.tsx
+++ b/apps/web/src/components/editor/panels/timeline/index.tsx
@@ -47,6 +47,8 @@ import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
import { TimelineRuler } from "./timeline-ruler";
import { TimelineBookmarksRow } from "./bookmarks";
+import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
+import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useTimelineStore } from "@/stores/timeline-store";
import { useEditor } from "@/hooks/use-editor";
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
@@ -126,6 +128,17 @@ export function Timeline() {
onSnapPointChange: handleSnapPointChange,
});
+ const {
+ dragState: bookmarkDragState,
+ handleBookmarkMouseDown,
+ lastMouseXRef: bookmarkLastMouseXRef,
+ } = useBookmarkDrag({
+ zoomLevel,
+ scrollRef: tracksScrollRef,
+ snappingEnabled,
+ onSnapPointChange: handleSnapPointChange,
+ });
+
const { handleRulerMouseDown: handlePlayheadRulerMouseDown } =
useTimelinePlayhead({
zoomLevel,
@@ -168,10 +181,18 @@ export function Timeline() {
containerWidth,
);
+ useEdgeAutoScroll({
+ isActive: bookmarkDragState.isDragging,
+ getMouseClientX: () => bookmarkLastMouseXRef.current,
+ rulerScrollRef: tracksScrollRef,
+ tracksScrollRef,
+ contentWidth: dynamicTimelineWidth,
+ });
+
const showSnapIndicator =
snappingEnabled &&
currentSnapPoint !== null &&
- (dragState.isDragging || isResizing);
+ (dragState.isDragging || bookmarkDragState.isDragging || isResizing);
const {
handleTracksMouseDown,
@@ -351,7 +372,7 @@ export function Timeline() {
>
<div
ref={timelineHeaderRef}
- className="bg-background sticky top-0 z-30 flex flex-col"
+ className="bg-background sticky top-0 flex flex-col"
>
<TimelineRuler
zoomLevel={zoomLevel}
@@ -366,6 +387,8 @@ export function Timeline() {
<TimelineBookmarksRow
zoomLevel={zoomLevel}
dynamicTimelineWidth={dynamicTimelineWidth}
+ dragState={bookmarkDragState}
+ onBookmarkMouseDown={handleBookmarkMouseDown}
handleWheel={handleWheel}
handleTimelineContentClick={handleRulerClick}
handleRulerTrackingMouseDown={handleRulerMouseDown}
@@ -433,7 +456,7 @@ export function Timeline() {
/>
</div>
</ContextMenuTrigger>
- <ContextMenuContent className="z-200 w-40">
+ <ContextMenuContent className="w-40">
<ContextMenuItem
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
onClick={(e) => {
diff --git a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
index 914edccb..e70f0cd7 100644
--- a/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
+++ b/apps/web/src/components/editor/panels/timeline/snap-indicator.tsx
@@ -38,7 +38,7 @@ export function SnapIndicator({
return (
<div
- className="pointer-events-none absolute z-90"
+ className="pointer-events-none absolute"
style={{
left: `${leftPosition}px`,
top: topPosition,
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
index 59ce88da..628855f6 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-element.tsx
@@ -29,6 +29,7 @@ import type { MediaAsset } from "@/types/assets";
import { mediaSupportsAudio } from "@/lib/media/media-utils";
import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
+import { resolveStickerId } from "@/lib/stickers";
import Image from "next/image";
import {
ScissorIcon,
@@ -139,9 +140,7 @@ export function TimelineElement({
<ContextMenu>
<ContextMenuTrigger asChild>
<div
- className={`absolute top-0 h-full select-none ${
- isBeingDragged ? "z-30" : "z-10"
- }`}
+ className={`absolute top-0 h-full select-none`}
style={{
left: `${elementLeft}px`,
width: `${elementWidth}px`,
@@ -155,7 +154,6 @@ export function TimelineElement({
element={element}
track={track}
isSelected={isSelected}
- isBeingDragged={isBeingDragged}
hasAudio={hasAudio}
isMuted={isMuted}
mediaAssets={mediaAssets}
@@ -165,7 +163,7 @@ export function TimelineElement({
/>
</div>
</ContextMenuTrigger>
- <ContextMenuContent className="z-200 w-64">
+ <ContextMenuContent className="w-64">
<ActionMenuItem
action="split"
icon={<HugeiconsIcon icon={ScissorIcon} />}
@@ -227,7 +225,6 @@ function ElementInner({
element,
track,
isSelected,
- isBeingDragged,
hasAudio,
isMuted,
mediaAssets,
@@ -238,7 +235,6 @@ function ElementInner({
element: TimelineElementType;
track: TimelineTrack;
isSelected: boolean;
- isBeingDragged: boolean;
hasAudio: boolean;
isMuted: boolean;
mediaAssets: MediaAsset[];
@@ -248,7 +244,7 @@ function ElementInner({
element: TimelineElementType,
) => void;
handleResizeStart: (params: {
- e: React.MouseEvent;
+ event: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => void;
@@ -259,7 +255,7 @@ function ElementInner({
{
type: track.type,
},
- )} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
+ )} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
>
<button
type="button"
@@ -321,7 +317,7 @@ function ResizeHandle({
side: "left" | "right";
elementId: string;
handleResizeStart: (params: {
- e: React.MouseEvent;
+ event: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => void;
@@ -330,8 +326,8 @@ function ResizeHandle({
return (
<button
type="button"
- className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
- onMouseDown={(e) => handleResizeStart({ e, elementId, side })}
+ className={`bg-primary absolute top-0 bottom-0 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
+ onMouseDown={(event) => handleResizeStart({ event, elementId, side })}
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
>
<div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
@@ -362,7 +358,10 @@ function ElementContent({
return (
<div className="flex size-full items-center gap-2 pl-2">
<Image
- src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`}
+ src={resolveStickerId({
+ stickerId: element.stickerId,
+ options: { width: 20, height: 20 },
+ })}
alt={element.name}
className="size-5 shrink-0"
width={20}
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
index 8af35dc9..ebb2a37e 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-playhead.tsx
@@ -72,20 +72,22 @@ export function TimelinePlayhead({
aria-valuemax={duration}
aria-valuenow={playheadPosition}
tabIndex={0}
- className="pointer-events-auto absolute z-60"
+ className="pointer-events-none absolute z-5"
style={{
left: `${leftPosition}px`,
top: 0,
height: `${totalHeight}px`,
width: "2px",
}}
- onMouseDown={handlePlayheadMouseDown}
onKeyDown={handlePlayheadKeyDown}
>
- <div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" />
+ <div className="bg-foreground pointer-events-none absolute left-0 h-full w-0.5" />
- <div
- className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
+ <button
+ type="button"
+ aria-label="Drag playhead"
+ className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
+ onMouseDown={handlePlayheadMouseDown}
/>
</div>
);
diff --git a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
index 0b02c63f..03ce88eb 100644
--- a/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
+++ b/apps/web/src/components/editor/panels/timeline/timeline-toolbar.tsx
@@ -171,7 +171,7 @@ function SceneSelector() {
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
<SplitButtonSeparator />
<ScenesView>
- <SplitButtonRight onClick={() => {}} type="button">
+ <SplitButtonRight onClick={() => {}}>
<HugeiconsIcon icon={Layers01Icon} className="size-4" />
</SplitButtonRight>
</ScenesView>
@@ -222,7 +222,6 @@ function ToolbarRightSection({
<Button
variant="text"
size="icon"
- type="button"
onClick={() => onZoom({ direction: "out" })}
>
<HugeiconsIcon icon={SearchMinusIcon} />
@@ -240,7 +239,6 @@ function ToolbarRightSection({
<Button
variant="text"
size="icon"
- type="button"
onClick={() => onZoom({ direction: "in" })}
>
<HugeiconsIcon icon={SearchAddIcon} />
@@ -269,7 +267,6 @@ function ToolbarButton({
<Button
variant={isActive ? "secondary" : "text"}
size="icon"
- type="button"
onClick={(event) => onClick({ event })}
className={cn(
"rounded-sm",
diff --git a/apps/web/src/components/gitHub-contribute-section.tsx b/apps/web/src/components/gitHub-contribute-section.tsx
index f96def4a..153a662a 100644
--- a/apps/web/src/components/gitHub-contribute-section.tsx
+++ b/apps/web/src/components/gitHub-contribute-section.tsx
@@ -1,10 +1,7 @@
import { Button } from "./ui/button";
import Link from "next/link";
import { SOCIAL_LINKS } from "@/constants/site-constants";
-import {
- GithubIcon,
- Link04Icon,
-} from "@hugeicons/core-free-icons";
+import { GithubIcon, Link04Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
export function GitHubContributeSection({
diff --git a/apps/web/src/components/providers/editor-provider.tsx b/apps/web/src/components/providers/editor-provider.tsx
index b1b5ab3a..746cfd16 100644
--- a/apps/web/src/components/providers/editor-provider.tsx
+++ b/apps/web/src/components/providers/editor-provider.tsx
@@ -9,6 +9,7 @@ import {
useKeybindingDisabler,
} from "@/hooks/use-keybindings";
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
+import { prefetchFontAtlas } from "@/lib/fonts/google-fonts";
interface EditorProviderProps {
projectId: string;
@@ -42,6 +43,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
if (cancelled) return;
setIsLoading(false);
+ prefetchFontAtlas();
} catch (err) {
if (cancelled) return;
diff --git a/apps/web/src/components/theme-toggle.tsx b/apps/web/src/components/theme-toggle.tsx
index b6e8d274..6fd84fa3 100644
--- a/apps/web/src/components/theme-toggle.tsx
+++ b/apps/web/src/components/theme-toggle.tsx
@@ -1,36 +1,39 @@
-"use client";
-
-import { Button } from "./ui/button";
-import { useTheme } from "next-themes";
-import { cn } from "@/utils/ui";
-import { Sun03Icon } from "@hugeicons/core-free-icons";
-import { HugeiconsIcon } from "@hugeicons/react";
-
-interface ThemeToggleProps {
- className?: string;
- iconClassName?: string;
- onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void;
-}
-
-export function ThemeToggle({
- className,
- iconClassName,
- onToggle,
-}: ThemeToggleProps) {
- const { theme, setTheme } = useTheme();
-
- return (
- <Button
- size="icon"
- variant="ghost"
- className={cn("size-8", className)}
- onClick={(e) => {
- setTheme(theme === "dark" ? "light" : "dark");
- onToggle?.(e);
- }}
- >
- <HugeiconsIcon icon={Sun03Icon} className={cn("!size-[1.1rem]", iconClassName)} />
- <span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
- </Button>
- );
-}
+"use client";
+
+import { Button } from "./ui/button";
+import { useTheme } from "next-themes";
+import { cn } from "@/utils/ui";
+import { Sun03Icon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+
+interface ThemeToggleProps {
+ className?: string;
+ iconClassName?: string;
+ onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void;
+}
+
+export function ThemeToggle({
+ className,
+ iconClassName,
+ onToggle,
+}: ThemeToggleProps) {
+ const { theme, setTheme } = useTheme();
+
+ return (
+ <Button
+ size="icon"
+ variant="ghost"
+ className={cn("size-8", className)}
+ onClick={(e) => {
+ setTheme(theme === "dark" ? "light" : "dark");
+ onToggle?.(e);
+ }}
+ >
+ <HugeiconsIcon
+ icon={Sun03Icon}
+ className={cn("!size-[1.1rem]", iconClassName)}
+ />
+ <span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
+ </Button>
+ );
+}
diff --git a/apps/web/src/components/ui/alert.tsx b/apps/web/src/components/ui/alert.tsx
index e941ca43..dcc190d3 100644
--- a/apps/web/src/components/ui/alert.tsx
+++ b/apps/web/src/components/ui/alert.tsx
@@ -1,6 +1,5 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
-import { AlertTriangle } from "lucide-react";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
import { Alert02Icon } from "@hugeicons/core-free-icons";
@@ -32,7 +31,10 @@ const Alert = React.forwardRef<
{...props}
>
{variant === "destructive" && (
- <HugeiconsIcon icon={Alert02Icon} className="size-5 text-destructive mt-0.5" />
+ <HugeiconsIcon
+ icon={Alert02Icon}
+ className="size-5 text-destructive mt-0.5"
+ />
)}
{children}
</div>
@@ -45,7 +47,10 @@ const AlertTitle = React.forwardRef<
>(({ className, ...props }, ref) => (
<h5
ref={ref}
- className={cn("mb-2 text-base leading-none font-semibold tracking-tight", className)}
+ className={cn(
+ "mb-2 text-base leading-none font-semibold tracking-tight",
+ className,
+ )}
{...props}
/>
));
diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx
index 0c2fb4c4..a3b59233 100644
--- a/apps/web/src/components/ui/avatar.tsx
+++ b/apps/web/src/components/ui/avatar.tsx
@@ -38,10 +38,10 @@ const AvatarFallback = React.forwardRef<
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
- className={cn(
- "bg-muted flex size-full items-center justify-center rounded-full",
- className,
- )}
+ className={cn(
+ "bg-muted flex size-full items-center justify-center rounded-full",
+ className,
+ )}
{...props}
/>
));
diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx
index e95f2965..927cca89 100644
--- a/apps/web/src/components/ui/breadcrumb.tsx
+++ b/apps/web/src/components/ui/breadcrumb.tsx
@@ -1,108 +1,99 @@
-import * as React from "react";
-import { Slot as SlotPrimitive } from "radix-ui";
import { ChevronRight, MoreHorizontal } from "lucide-react";
+import { Slot } from "radix-ui";
import { cn } from "@/utils/ui";
-const Breadcrumb = React.forwardRef<
- HTMLElement,
- React.ComponentPropsWithoutRef<"nav"> & {
- separator?: React.ReactNode;
- }
->(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
-Breadcrumb.displayName = "Breadcrumb";
+function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
+ return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
+}
-const BreadcrumbList = React.forwardRef<
- HTMLOListElement,
- React.ComponentPropsWithoutRef<"ol">
->(({ className, ...props }, ref) => (
- <ol
- ref={ref}
- className={cn(
- "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
- className,
- )}
- {...props}
- />
-));
-BreadcrumbList.displayName = "BreadcrumbList";
+function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
+ return (
+ <ol
+ data-slot="breadcrumb-list"
+ className={cn(
+ "text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
+ className,
+ )}
+ {...props}
+ />
+ );
+}
-const BreadcrumbItem = React.forwardRef<
- HTMLLIElement,
- React.ComponentPropsWithoutRef<"li">
->(({ className, ...props }, ref) => (
- <li
- ref={ref}
- className={cn("inline-flex items-center gap-1.5", className)}
- {...props}
- />
-));
-BreadcrumbItem.displayName = "BreadcrumbItem";
+function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
+ return (
+ <li
+ data-slot="breadcrumb-item"
+ className={cn("inline-flex items-center gap-1.5", className)}
+ {...props}
+ />
+ );
+}
-const BreadcrumbLink = React.forwardRef<
- HTMLAnchorElement,
- React.ComponentPropsWithoutRef<"a"> & {
- asChild?: boolean;
- }
->(({ asChild, className, ...props }, ref) => {
- const Comp = asChild ? SlotPrimitive.Slot : "a";
+function BreadcrumbLink({
+ asChild,
+ className,
+ ...props
+}: React.ComponentProps<"a"> & {
+ asChild?: boolean;
+}) {
+ const Comp = asChild ? Slot.Root : "a";
return (
<Comp
- ref={ref}
- className={cn("hover:text-foreground", className)}
+ data-slot="breadcrumb-link"
+ className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
-});
-BreadcrumbLink.displayName = "BreadcrumbLink";
+}
-const BreadcrumbPage = React.forwardRef<
- HTMLSpanElement,
- React.ComponentPropsWithoutRef<"span">
->(({ className, ...props }, ref) => (
- <span
- ref={ref}
- role="link"
- aria-disabled="true"
- aria-current="page"
- className={cn("text-foreground font-normal", className)}
- {...props}
- />
-));
-BreadcrumbPage.displayName = "BreadcrumbPage";
+function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="breadcrumb-page"
+ aria-current="page"
+ className={cn("text-foreground font-normal", className)}
+ {...props}
+ />
+ );
+}
-const BreadcrumbSeparator = ({
+function BreadcrumbSeparator({
children,
className,
...props
-}: React.ComponentProps<"li">) => (
- <li
- role="presentation"
- aria-hidden="true"
- className={cn("[&>svg]:size-3.5", className)}
- {...props}
- >
- {children ?? <ChevronRight />}
- </li>
-);
-BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
+}: React.ComponentProps<"li">) {
+ return (
+ <li
+ data-slot="breadcrumb-separator"
+ role="presentation"
+ aria-hidden="true"
+ className={cn("[&>svg]:size-3.5", className)}
+ {...props}
+ >
+ {children ?? <ChevronRight />}
+ </li>
+ );
+}
-const BreadcrumbEllipsis = ({
+function BreadcrumbEllipsis({
className,
...props
-}: React.ComponentProps<"span">) => (
- <span
- role="presentation"
- aria-hidden="true"
- className={cn("flex size-9 items-center justify-center", className)}
- {...props}
- >
- <MoreHorizontal className="size-4" />
- <span className="sr-only">More</span>
- </span>
-);
-BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
+}: React.ComponentProps<"span">) {
+ return (
+ <span
+ data-slot="breadcrumb-ellipsis"
+ role="presentation"
+ aria-hidden="true"
+ className={cn("flex size-9 items-center justify-center", className)}
+ {...props}
+ >
+ <MoreHorizontal className="size-4" />
+ <span className="sr-only">More</span>
+ </span>
+ );
+}
export {
Breadcrumb,
diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx
index 2fc2c2e6..fba3109f 100644
--- a/apps/web/src/components/ui/button.tsx
+++ b/apps/web/src/components/ui/button.tsx
@@ -1,7 +1,6 @@
import * as React from "react";
import { Slot as SlotPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
-
import { cn } from "@/utils/ui";
const buttonVariants = cva(
@@ -9,18 +8,14 @@ const buttonVariants = cva(
{
variants: {
variant: {
- default:
- "bg-primary text-primary-foreground hover:bg-primary/90",
- background:
- "bg-background text-foreground hover:bg-background/90",
- foreground:
- "bg-foreground text-background hover:bg-foreground/90",
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ background: "bg-background text-foreground hover:bg-background/90",
+ foreground: "bg-foreground text-background hover:bg-foreground/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/80",
"destructive-foreground":
"border bg-background hover:bg-destructive/15 text-destructive",
- outline:
- "border border-border bg-transparent hover:bg-accent/50",
+ outline: "border border-border bg-transparent hover:bg-accent/50",
secondary:
"bg-secondary text-secondary-foreground border border-secondary-border",
text: "bg-transparent rounded-none opacity-100 hover:opacity-75",
@@ -28,10 +23,10 @@ const buttonVariants = cva(
link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
},
size: {
- default: "h-9.5 px-4 py-2",
- sm: "h-8 p-1 px-2 text-xs rounded-sm",
+ default: "h-9 px-4 py-2",
+ sm: "h-7.5 p-1 px-2.5 text-sm rounded-sm",
lg: "h-10 p-5 px-6",
- icon: "size-7",
+ icon: "size-7 rounded-sm",
text: "p-0",
},
},
@@ -54,8 +49,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
const effectiveSize = size ?? (variant === "text" ? "text" : "default");
return (
<Comp
- className={cn(buttonVariants({ variant, size: effectiveSize, className }))}
+ className={cn(
+ buttonVariants({ variant, size: effectiveSize, className }),
+ )}
ref={ref}
+ type="button"
{...props}
/>
);
diff --git a/apps/web/src/components/ui/calendar.tsx b/apps/web/src/components/ui/calendar.tsx
index a0664cc3..3d088b27 100644
--- a/apps/web/src/components/ui/calendar.tsx
+++ b/apps/web/src/components/ui/calendar.tsx
@@ -60,8 +60,8 @@ function Calendar({
...classNames,
}}
components={{
- IconLeft: ({ ...props }) => <ChevronLeft className="size-4" />,
- IconRight: ({ ...props }) => <ChevronRight className="size-4" />,
+ IconLeft: () => <ChevronLeft className="size-4" />,
+ IconRight: () => <ChevronRight className="size-4" />,
}}
{...props}
/>
diff --git a/apps/web/src/components/ui/carousel.tsx b/apps/web/src/components/ui/carousel.tsx
deleted file mode 100644
index 9018b9ea..00000000
--- a/apps/web/src/components/ui/carousel.tsx
+++ /dev/null
@@ -1,262 +0,0 @@
-"use client";
-
-import useEmblaCarousel, {
- type UseEmblaCarouselType,
-} from "embla-carousel-react";
-import { ArrowLeft, ArrowRight } from "lucide-react";
-import * as React from "react";
-
-import { cn } from "@/utils/ui";
-import { Button } from "./button";
-
-type CarouselApi = UseEmblaCarouselType[1];
-type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
-type CarouselOptions = UseCarouselParameters[0];
-type CarouselPlugin = UseCarouselParameters[1];
-
-type CarouselProps = {
- opts?: CarouselOptions;
- plugins?: CarouselPlugin;
- orientation?: "horizontal" | "vertical";
- setApi?: (api: CarouselApi) => void;
-};
-
-type CarouselContextProps = {
- carouselRef: ReturnType<typeof useEmblaCarousel>[0];
- api: ReturnType<typeof useEmblaCarousel>[1];
- scrollPrev: () => void;
- scrollNext: () => void;
- canScrollPrev: boolean;
- canScrollNext: boolean;
-} & CarouselProps;
-
-const CarouselContext = React.createContext<CarouselContextProps | null>(null);
-
-function useCarousel() {
- const context = React.useContext(CarouselContext);
-
- if (!context) {
- throw new Error("useCarousel must be used within a <Carousel />");
- }
-
- return context;
-}
-
-const Carousel = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes<HTMLDivElement> & CarouselProps
->(
- (
- {
- orientation = "horizontal",
- opts,
- setApi,
- plugins,
- className,
- children,
- ...props
- },
- ref,
- ) => {
- const [carouselRef, api] = useEmblaCarousel(
- {
- ...opts,
- axis: orientation === "horizontal" ? "x" : "y",
- },
- plugins,
- );
- const [canScrollPrev, setCanScrollPrev] = React.useState(false);
- const [canScrollNext, setCanScrollNext] = React.useState(false);
-
- const onSelect = React.useCallback((api: CarouselApi) => {
- if (!api) {
- return;
- }
-
- setCanScrollPrev(api.canScrollPrev());
- setCanScrollNext(api.canScrollNext());
- }, []);
-
- const scrollPrev = React.useCallback(() => {
- api?.scrollPrev();
- }, [api]);
-
- const scrollNext = React.useCallback(() => {
- api?.scrollNext();
- }, [api]);
-
- const handleKeyDown = React.useCallback(
- (event: React.KeyboardEvent<HTMLDivElement>) => {
- if (event.key === "ArrowLeft") {
- event.preventDefault();
- scrollPrev();
- } else if (event.key === "ArrowRight") {
- event.preventDefault();
- scrollNext();
- }
- },
- [scrollPrev, scrollNext],
- );
-
- React.useEffect(() => {
- if (!api || !setApi) {
- return;
- }
-
- setApi(api);
- }, [api, setApi]);
-
- React.useEffect(() => {
- if (!api) {
- return;
- }
-
- onSelect(api);
- api.on("reInit", onSelect);
- api.on("select", onSelect);
-
- return () => {
- api?.off("select", onSelect);
- };
- }, [api, onSelect]);
-
- return (
- <CarouselContext.Provider
- value={{
- carouselRef,
- api,
- opts,
- orientation:
- orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
- scrollPrev,
- scrollNext,
- canScrollPrev,
- canScrollNext,
- }}
- >
- <div
- ref={ref}
- onKeyDownCapture={handleKeyDown}
- className={cn("relative", className)}
- role="region"
- aria-roledescription="carousel"
- {...props}
- >
- {children}
- </div>
- </CarouselContext.Provider>
- );
- },
-);
-Carousel.displayName = "Carousel";
-
-const CarouselContent = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes<HTMLDivElement>
->(({ className, ...props }, ref) => {
- const { carouselRef, orientation } = useCarousel();
-
- return (
- <div ref={carouselRef} className="overflow-hidden">
- <div
- ref={ref}
- className={cn(
- "flex",
- orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
- className,
- )}
- {...props}
- />
- </div>
- );
-});
-CarouselContent.displayName = "CarouselContent";
-
-const CarouselItem = React.forwardRef<
- HTMLDivElement,
- React.HTMLAttributes<HTMLDivElement>
->(({ className, ...props }, ref) => {
- const { orientation } = useCarousel();
-
- return (
- <div
- ref={ref}
- role="group"
- aria-roledescription="slide"
- className={cn(
- "min-w-0 shrink-0 grow-0 basis-full",
- orientation === "horizontal" ? "pl-4" : "pt-4",
- className,
- )}
- {...props}
- />
- );
-});
-CarouselItem.displayName = "CarouselItem";
-
-const CarouselPrevious = React.forwardRef<
- HTMLButtonElement,
- React.ComponentProps<typeof Button>
->(({ className, variant = "outline", size = "icon", ...props }, ref) => {
- const { orientation, scrollPrev, canScrollPrev } = useCarousel();
-
- return (
- <Button
- ref={ref}
- variant={variant}
- size={size}
- className={cn(
- "absolute size-8 rounded-full",
- orientation === "horizontal"
- ? "top-1/2 -left-12 -translate-y-1/2"
- : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
- className,
- )}
- disabled={!canScrollPrev}
- onClick={scrollPrev}
- {...props}
- >
- <ArrowLeft className="size-4" />
- <span className="sr-only">Previous slide</span>
- </Button>
- );
-});
-CarouselPrevious.displayName = "CarouselPrevious";
-
-const CarouselNext = React.forwardRef<
- HTMLButtonElement,
- React.ComponentProps<typeof Button>
->(({ className, variant = "outline", size = "icon", ...props }, ref) => {
- const { orientation, scrollNext, canScrollNext } = useCarousel();
-
- return (
- <Button
- ref={ref}
- variant={variant}
- size={size}
- className={cn(
- "absolute size-8 rounded-full",
- orientation === "horizontal"
- ? "top-1/2 -right-12 -translate-y-1/2"
- : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
- className,
- )}
- disabled={!canScrollNext}
- onClick={scrollNext}
- {...props}
- >
- <ArrowRight className="size-4" />
- <span className="sr-only">Next slide</span>
- </Button>
- );
-});
-CarouselNext.displayName = "CarouselNext";
-
-export {
- type CarouselApi,
- Carousel,
- CarouselContent,
- CarouselItem,
- CarouselPrevious,
- CarouselNext,
-};
diff --git a/apps/web/src/components/ui/chart.tsx b/apps/web/src/components/ui/chart.tsx
deleted file mode 100644
index 2bd04b20..00000000
--- a/apps/web/src/components/ui/chart.tsx
+++ /dev/null
@@ -1,370 +0,0 @@
-"use client";
-
-import * as React from "react";
-import * as RechartsPrimitive from "recharts";
-import {
- NameType,
- Payload,
- ValueType,
-} from "recharts/types/component/DefaultTooltipContent";
-
-import { cn } from "@/utils/ui";
-
-// Format: { THEME_NAME: CSS_SELECTOR }
-const THEMES = { light: "", dark: ".dark" } as const;
-
-export type ChartConfig = {
- [k in string]: {
- label?: React.ReactNode;
- icon?: React.ComponentType;
- } & (
- | { color?: string; theme?: never }
- | { color?: never; theme: Record<keyof typeof THEMES, string> }
- );
-};
-
-type ChartContextProps = {
- config: ChartConfig;
-};
-
-const ChartContext = React.createContext<ChartContextProps | null>(null);
-
-function useChart() {
- const context = React.useContext(ChartContext);
-
- if (!context) {
- throw new Error("useChart must be used within a <ChartContainer />");
- }
-
- return context;
-}
-
-const ChartContainer = React.forwardRef<
- HTMLDivElement,
- React.ComponentProps<"div"> & {
- config: ChartConfig;
- children: React.ComponentProps<
- typeof RechartsPrimitive.ResponsiveContainer
- >["children"];
- }
->(({ id, className, children, config, ...props }, ref) => {
- const uniqueId = React.useId();
- const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
-
- return (
- <ChartContext.Provider value={{ config }}>
- <div
- data-chart={chartId}
- ref={ref}
- className={cn(
- "[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
- className,
- )}
- {...props}
- >
- <ChartStyle id={chartId} config={config} />
- <RechartsPrimitive.ResponsiveContainer>
- {children}
- </RechartsPrimitive.ResponsiveContainer>
- </div>
- </ChartContext.Provider>
- );
-});
-ChartContainer.displayName = "Chart";
-
-const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
- const colorConfig = Object.entries(config).filter(
- ([_, config]) => config.theme || config.color,
- );
-
- if (!colorConfig.length) {
- return null;
- }
-
- return (
- <style
- dangerouslySetInnerHTML={{
- __html: Object.entries(THEMES)
- .map(
- ([theme, prefix]) => `
-${prefix} [data-chart=${id}] {
-${colorConfig
- .map(([key, itemConfig]) => {
- const color =
- itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
- itemConfig.color;
- return color ? ` --color-${key}: ${color};` : null;
- })
- .join("\n")}
-}
-`,
- )
- .join("\n"),
- }}
- />
- );
-};
-
-const ChartTooltip = RechartsPrimitive.Tooltip;
-
-const ChartTooltipContent = React.forwardRef<
- HTMLDivElement,
- React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
- React.ComponentProps<"div"> & {
- hideLabel?: boolean;
- hideIndicator?: boolean;
- indicator?: "line" | "dot" | "dashed";
- nameKey?: string;
- labelKey?: string;
- }
->(
- (
- {
- active,
- payload,
- className,
- indicator = "dot",
- hideLabel = false,
- hideIndicator = false,
- label,
- labelFormatter,
- labelClassName,
- formatter,
- color,
- nameKey,
- labelKey,
- },
- ref,
- ) => {
- const { config } = useChart();
-
- const tooltipLabel = React.useMemo(() => {
- if (hideLabel || !payload?.length) {
- return null;
- }
-
- const [item] = payload;
- const key = `${labelKey || item.dataKey || item.name || "value"}`;
- const itemConfig = getPayloadConfigFromPayload(config, item, key);
- const value =
- !labelKey && typeof label === "string"
- ? config[label as keyof typeof config]?.label || label
- : itemConfig?.label;
-
- if (labelFormatter) {
- return (
- <div className={cn("font-medium", labelClassName)}>
- {labelFormatter(value, payload)}
- </div>
- );
- }
-
- if (!value) {
- return null;
- }
-
- return <div className={cn("font-medium", labelClassName)}>{value}</div>;
- }, [
- label,
- labelFormatter,
- payload,
- hideLabel,
- labelClassName,
- config,
- labelKey,
- ]);
-
- if (!active || !payload?.length) {
- return null;
- }
-
- const nestLabel = payload.length === 1 && indicator !== "dot";
-
- return (
- <div
- ref={ref}
- className={cn(
- "border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
- className,
- )}
- >
- {nestLabel ? null : tooltipLabel}
- <div className="grid gap-1.5">
- {payload.map((item, index) => {
- const key = `${nameKey || item.name || item.dataKey || "value"}`;
- const itemConfig = getPayloadConfigFromPayload(config, item, key);
- const indicatorColor = color || item.payload.fill || item.color;
-
- return (
- <div
- key={item.dataKey}
- className={cn(
- "[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5",
- indicator === "dot" && "items-center",
- )}
- >
- {formatter && item?.value !== undefined && item.name ? (
- formatter(item.value, item.name, item, index, item.payload)
- ) : (
- <>
- {itemConfig?.icon ? (
- <itemConfig.icon />
- ) : (
- !hideIndicator && (
- <div
- className={cn(
- "shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
- {
- "size-2.5": indicator === "dot",
- "w-1": indicator === "line",
- "w-0 border-[1.5px] border-dashed bg-transparent":
- indicator === "dashed",
- "my-0.5": nestLabel && indicator === "dashed",
- },
- )}
- style={
- {
- "--color-bg": indicatorColor,
- "--color-border": indicatorColor,
- } as React.CSSProperties
- }
- />
- )
- )}
- <div
- className={cn(
- "flex flex-1 justify-between leading-none",
- nestLabel ? "items-end" : "items-center",
- )}
- >
- <div className="grid gap-1.5">
- {nestLabel ? tooltipLabel : null}
- <span className="text-muted-foreground">
- {itemConfig?.label || item.name}
- </span>
- </div>
- {item.value && (
- <span className="text-foreground font-mono font-medium tabular-nums">
- {item.value.toLocaleString()}
- </span>
- )}
- </div>
- </>
- )}
- </div>
- );
- })}
- </div>
- </div>
- );
- },
-);
-ChartTooltipContent.displayName = "ChartTooltip";
-
-const ChartLegend = RechartsPrimitive.Legend;
-
-const ChartLegendContent = React.forwardRef<
- HTMLDivElement,
- React.ComponentProps<"div"> &
- Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
- hideIcon?: boolean;
- nameKey?: string;
- }
->(
- (
- { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
- ref,
- ) => {
- const { config } = useChart();
-
- if (!payload?.length) {
- return null;
- }
-
- return (
- <div
- ref={ref}
- className={cn(
- "flex items-center justify-center gap-4",
- verticalAlign === "top" ? "pb-3" : "pt-3",
- className,
- )}
- >
- {payload.map((item) => {
- const key = `${nameKey || item.dataKey || "value"}`;
- const itemConfig = getPayloadConfigFromPayload(config, item, key);
-
- return (
- <div
- key={item.value}
- className={cn(
- "[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:size-3",
- )}
- >
- {itemConfig?.icon && !hideIcon ? (
- <itemConfig.icon />
- ) : (
- <div
- className="size-2 shrink-0 rounded-[2px]"
- style={{
- backgroundColor: item.color,
- }}
- />
- )}
- {itemConfig?.label}
- </div>
- );
- })}
- </div>
- );
- },
-);
-ChartLegendContent.displayName = "ChartLegend";
-
-// Helper to extract item config from a payload.
-function getPayloadConfigFromPayload(
- config: ChartConfig,
- payload: unknown,
- key: string,
-) {
- if (typeof payload !== "object" || payload === null) {
- return;
- }
-
- const payloadPayload =
- "payload" in payload &&
- typeof payload.payload === "object" &&
- payload.payload !== null
- ? payload.payload
- : undefined;
-
- let configLabelKey: string = key;
-
- if (
- key in payload &&
- typeof payload[key as keyof typeof payload] === "string"
- ) {
- configLabelKey = payload[key as keyof typeof payload] as string;
- } else if (
- payloadPayload &&
- key in payloadPayload &&
- typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
- ) {
- configLabelKey = payloadPayload[
- key as keyof typeof payloadPayload
- ] as string;
- }
-
- return configLabelKey in config
- ? config[configLabelKey]
- : config[key as keyof typeof config];
-}
-
-export {
- ChartContainer,
- ChartTooltip,
- ChartTooltipContent,
- ChartLegend,
- ChartLegendContent,
- ChartStyle,
-};
diff --git a/apps/web/src/components/ui/color-picker.tsx b/apps/web/src/components/ui/color-picker.tsx
index 0ea1a14d..d58b6144 100644
--- a/apps/web/src/components/ui/color-picker.tsx
+++ b/apps/web/src/components/ui/color-picker.tsx
@@ -1,133 +1,80 @@
import { forwardRef, useEffect, useRef, useState } from "react";
-import { createPortal } from "react-dom";
import { cn } from "@/utils/ui";
import { Input } from "./input";
+import {
+ Popover,
+ PopoverClose,
+ PopoverContent,
+ PopoverTrigger,
+} from "./popover";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "./select";
+import { Button } from "./button";
+import { Cancel01Icon, ColorPickerIcon } from "@hugeicons/core-free-icons";
+import { HugeiconsIcon } from "@hugeicons/react";
+import {
+ type ColorFormat,
+ appendAlpha,
+ extractColorFromText,
+ formatColorValue,
+ hexToHsv,
+ hsvToHex,
+ parseColorInput,
+ parseHexAlpha,
+} from "@/utils/color";
interface ColorPickerProps {
value?: string;
onChange?: (value: string) => void;
onChangeEnd?: (value: string) => void;
className?: string;
- containerRef?: React.RefObject<HTMLDivElement | null>;
}
-const hexToHsv = (hex: string) => {
- const r = parseInt(hex.slice(0, 2), 16) / 255;
- const g = parseInt(hex.slice(2, 4), 16) / 255;
- const b = parseInt(hex.slice(4, 6), 16) / 255;
-
- const max = Math.max(r, g, b);
- const min = Math.min(r, g, b);
- const diff = max - min;
-
- let h = 0;
- const s = max === 0 ? 0 : diff / max;
- const v = max;
-
- if (diff !== 0) {
- switch (max) {
- case r:
- h = ((g - b) / diff) % 6;
- break;
- case g:
- h = (b - r) / diff + 2;
- break;
- case b:
- h = (r - g) / diff + 4;
- break;
- }
- }
-
- h = (h * 60 + 360) % 360;
- if (Number.isNaN(h)) h = 0;
-
- return [h, s, v];
-};
-
-const hsvToHex = (h: number, s: number, v: number) => {
- const c = v * s;
- const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
- const m = v - c;
-
- let r = 0,
- g = 0,
- b = 0;
-
- if (h >= 0 && h < 60) {
- r = c;
- g = x;
- b = 0;
- } else if (h >= 60 && h < 120) {
- r = x;
- g = c;
- b = 0;
- } else if (h >= 120 && h < 180) {
- r = 0;
- g = c;
- b = x;
- } else if (h >= 180 && h < 240) {
- r = 0;
- g = x;
- b = c;
- } else if (h >= 240 && h < 300) {
- r = x;
- g = 0;
- b = c;
- } else if (h >= 300 && h < 360) {
- r = c;
- g = 0;
- b = x;
- }
-
- r = Math.round((r + m) * 255);
- g = Math.round((g + m) * 255);
- b = Math.round((b + m) * 255);
-
- return [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("");
-};
-
const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
- ({ className, value = "FFFFFF", onChange, onChangeEnd, containerRef, ...props }, ref) => {
- const [isOpen, setIsOpen] = useState(false);
- const [isDragging, setIsDragging] = useState<"saturation" | "hue" | null>(
- null,
- );
- const [pickerPosition, setPickerPosition] = useState({
- right: 0,
- bottom: 0,
- });
+ ({ className, value = "FFFFFF", onChange, onChangeEnd, ...props }, ref) => {
+ const [isDragging, setIsDragging] = useState<
+ "saturation" | "hue" | "opacity" | null
+ >(null);
const [internalHue, setInternalHue] = useState(0);
const [inputValue, setInputValue] = useState(value);
+ const [colorFormat, setColorFormat] = useState<ColorFormat>("hex");
- const pickerRef = useRef<HTMLDivElement>(null);
const saturationRef = useRef<HTMLButtonElement>(null);
const hueRef = useRef<HTMLButtonElement>(null);
- const triggerRef = useRef<HTMLButtonElement>(null);
+ const opacityRef = useRef<HTMLButtonElement>(null);
const latestDragColorRef = useRef<string | null>(null);
- const [h, s, v] = hexToHsv(value);
- const displayHue = s > 0 ? h : internalHue;
+ const isEyeDropperSupported =
+ typeof window !== "undefined" && "EyeDropper" in window;
- useEffect(() => {
- setInputValue(value);
- }, [value]);
+ const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value });
+ const [h, s, v] = hexToHsv({ hex: rgbValue });
- useEffect(() => {
- const handleClickOutside = (event: MouseEvent) => {
- if (
- pickerRef.current &&
- !pickerRef.current.contains(event.target as Node)
- ) {
- setIsOpen(false);
- }
- };
-
- if (isOpen) {
- document.addEventListener("mousedown", handleClickOutside);
- return () =>
- document.removeEventListener("mousedown", handleClickOutside);
+ const handleEyeDropper = async () => {
+ if (!isEyeDropperSupported || !EyeDropper) return;
+ try {
+ const dropper = new EyeDropper();
+ const result = await dropper.open();
+ const hex = result.sRGBHex.replace("#", "").toLowerCase();
+ const finalHex = appendAlpha({ rgbHex: hex, alpha });
+ onChange?.(finalHex);
+ onChangeEnd?.(finalHex);
+ } catch {
+ // user cancelled the picker
}
- }, [isOpen]);
+ };
+ const hueDiff = Math.abs(h - internalHue);
+ const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1;
+ const displayHue = s === 0 || isSameHueWrapped ? internalHue : h;
+
+ useEffect(() => {
+ setInputValue(formatColorValue({ hex: value, format: colorFormat }));
+ }, [value, colorFormat]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
@@ -143,9 +90,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
0,
Math.min(1, (e.clientY - rect.top) / rect.height),
);
- const newS = x;
- const newV = 1 - y;
- const newHex = hsvToHex(displayHue, newS, newV);
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
+ alpha,
+ });
latestDragColorRef.current = newHex;
onChange?.(newHex);
}
@@ -159,11 +107,25 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
const newH = x * 360;
setInternalHue(newH);
if (s > 0) {
- const newHex = hsvToHex(newH, s, v);
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: newH, s, v }),
+ alpha,
+ });
latestDragColorRef.current = newHex;
onChange?.(newHex);
}
}
+
+ if (isDragging === "opacity" && opacityRef.current) {
+ const rect = opacityRef.current.getBoundingClientRect();
+ const x = Math.max(
+ 0,
+ Math.min(1, (e.clientX - rect.left) / rect.width),
+ );
+ const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ }
};
const handleMouseUp = () => {
@@ -182,7 +144,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
document.removeEventListener("mouseup", handleMouseUp);
};
}
- }, [isDragging, displayHue, s, v, onChange]);
+ }, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]);
const handleSaturationMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
@@ -192,9 +154,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
const rect = saturationElement.getBoundingClientRect();
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
- const newS = x;
- const newV = 1 - y;
- const newHex = hsvToHex(displayHue, newS, newV);
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
+ alpha,
+ });
latestDragColorRef.current = newHex;
onChange?.(newHex);
};
@@ -209,28 +172,83 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
const newH = x * 360;
setInternalHue(newH);
if (s > 0) {
- const newHex = hsvToHex(newH, s, v);
+ const newHex = appendAlpha({
+ rgbHex: hsvToHex({ h: newH, s, v }),
+ alpha,
+ });
latestDragColorRef.current = newHex;
onChange?.(newHex);
}
};
+ const handleOpacityMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault();
+ const opacityElement = opacityRef.current;
+ if (!opacityElement) return;
+ setIsDragging("opacity");
+ const rect = opacityElement.getBoundingClientRect();
+ const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
+ const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
+ latestDragColorRef.current = newHex;
+ onChange?.(newHex);
+ };
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
- const hex = e.target.value.replace("#", "");
- setInputValue(hex);
+ setInputValue(
+ colorFormat === "hex"
+ ? e.target.value.replace("#", "")
+ : e.target.value,
+ );
+ };
+
+ const commitInputValue = () => {
+ const parsed = parseColorInput({
+ input: inputValue,
+ format: colorFormat,
+ });
+ if (parsed) {
+ const nextHex = appendAlpha({ rgbHex: parsed, alpha });
+ onChange?.(nextHex);
+ onChangeEnd?.(nextHex);
+ return;
+ }
+
+ const extracted = extractColorFromText({ text: inputValue });
+ if (extracted) {
+ const hasExplicitAlpha = extracted.length > 6;
+ const finalHex = hasExplicitAlpha
+ ? extracted
+ : appendAlpha({ rgbHex: extracted, alpha });
+ onChange?.(finalHex);
+ onChangeEnd?.(finalHex);
+ }
};
const handleInputBlur = () => {
- onChange?.(inputValue);
+ commitInputValue();
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
- onChange?.(inputValue);
+ commitInputValue();
e.currentTarget.blur();
}
};
+ const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
+ const pastedText = event.clipboardData.getData("text");
+ const extractedHex = extractColorFromText({ text: pastedText });
+ if (!extractedHex) return;
+
+ event.preventDefault();
+ const hasExplicitAlpha = extractedHex.length > 6;
+ const finalHex = hasExplicitAlpha
+ ? extractedHex
+ : appendAlpha({ rgbHex: extractedHex, alpha });
+ onChange?.(finalHex);
+ onChangeEnd?.(finalHex);
+ };
+
const saturationStyle = {
background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`,
};
@@ -240,90 +258,178 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
"linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
};
+ const checkerboardStyle = {
+ backgroundImage: `
+ linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
+ linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
+ linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%),
+ linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%)
+ `,
+ backgroundSize: "8px 8px",
+ backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
+ backgroundColor: "#fff",
+ };
+
return (
- <div className="relative flex-1">
+ <Popover>
<div
ref={ref}
className={cn(
- "bg-accent flex h-8 items-center gap-2 rounded-md px-[0.45rem]",
+ "bg-accent flex h-8 flex-1 items-center gap-2 rounded-md px-[0.45rem]",
className,
)}
{...props}
>
- <button
- ref={triggerRef}
- className="size-4.5 cursor-pointer border rounded-sm hover:ring-2 hover:ring-white/20"
- style={{ backgroundColor: `#${value}` }}
- type="button"
- onClick={() => {
- if (!isOpen && triggerRef.current && containerRef?.current) {
- const containerRect =
- containerRef.current.getBoundingClientRect();
- setPickerPosition({
- right: window.innerWidth - containerRect.left - 8,
- bottom: window.innerHeight - containerRect.bottom,
- });
- }
- setIsOpen(!isOpen);
- }}
- />
+ <PopoverTrigger asChild>
+ <button
+ className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20"
+ style={{ backgroundColor: `#${value}` }}
+ type="button"
+ />
+ </PopoverTrigger>
<div className="flex flex-1 items-center">
<Input
- className="!border-0 bg-transparent p-0 !ring-0 !ring-offset-0 uppercase"
+ className={cn(
+ "!border-0 bg-transparent p-0 !ring-0 !ring-offset-0",
+ colorFormat === "hex" && "uppercase",
+ )}
size="sm"
containerClassName="w-full"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
onKeyDown={handleInputKeyDown}
+ onPaste={handlePaste}
/>
</div>
</div>
+ <PopoverContent
+ className="w-64 px-0 select-none flex flex-col gap-3 py-2"
+ side="left"
+ sideOffset={8}
+ onOpenAutoFocus={(event) => {
+ event.preventDefault();
+ }}
+ onCloseAutoFocus={(event) => {
+ event.preventDefault();
+ }}
+ onInteractOutside={(event) => {
+ if (isDragging) event.preventDefault();
+ }}
+ >
+ <header className="border-b flex justify-between items-center pb-2 px-2">
+ <Select defaultValue="custom">
+ <SelectTrigger variant="outline">
+ <SelectValue placeholder="Select a mode" />
+ </SelectTrigger>
+ <SelectContent position="popper">
+ <SelectItem value="custom">Custom</SelectItem>
+ <SelectItem value="saved">Saved</SelectItem>
+ </SelectContent>
+ </Select>
+ <div>
+ {isEyeDropperSupported && (
+ <Button
+ variant="ghost"
+ size="icon"
+ type="button"
+ onClick={handleEyeDropper}
+ >
+ <HugeiconsIcon icon={ColorPickerIcon} />
+ </Button>
+ )}
+ <PopoverClose asChild>
+ <Button variant="ghost" size="icon" type="button">
+ <HugeiconsIcon icon={Cancel01Icon} />
+ </Button>
+ </PopoverClose>
+ </div>
+ </header>
+ <div className="px-2 flex flex-col gap-3">
+ <button
+ ref={saturationRef}
+ className="relative h-44 aspect-square w-full appearance-none border-0 bg-transparent p-0"
+ style={saturationStyle}
+ type="button"
+ onMouseDown={handleSaturationMouseDown}
+ >
+ <ColorCircle
+ size="sm"
+ position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }}
+ color={`#${value}`}
+ />
+ </button>
- {isOpen &&
- createPortal(
- <div
- ref={pickerRef}
- className="bg-popover border-border fixed z-50 rounded-lg border p-4 shadow-lg select-none"
- style={{
- right: pickerPosition.right,
- bottom: pickerPosition.bottom,
- }}
+ <button
+ ref={hueRef}
+ className="relative h-4 w-full rounded-lg appearance-none border-0 bg-transparent p-0"
+ style={hueStyle}
+ type="button"
+ onMouseDown={handleHueMouseDown}
>
- <button
- ref={saturationRef}
- className="relative mb-3 h-32 w-48 cursor-crosshair appearance-none border-0 bg-transparent p-0"
- style={saturationStyle}
- type="button"
- onMouseDown={handleSaturationMouseDown}
- >
- <ColorCircle
- size="sm"
- position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }}
- color={`#${value}`}
- />
- </button>
+ <ColorCircle
+ size="md"
+ position={{
+ left: `calc(0.5rem + (100% - 1rem) * ${displayHue / 360})`,
+ top: "50%",
+ }}
+ />
+ </button>
+
+ <button
+ ref={opacityRef}
+ className="relative h-4 w-full overflow-hidden rounded-lg appearance-none border-0 p-0"
+ style={checkerboardStyle}
+ type="button"
+ onMouseDown={handleOpacityMouseDown}
+ >
+ <div
+ className="absolute inset-0 rounded-lg"
+ style={{
+ background: `linear-gradient(to right, transparent, #${rgbValue})`,
+ }}
+ />
+ <ColorCircle
+ size="md"
+ position={{
+ left: `calc(0.5rem + (100% - 1rem) * ${alpha})`,
+ top: "50%",
+ }}
+ />
+ </button>
- <button
- ref={hueRef}
- className="relative h-4 w-48 cursor-pointer rounded-lg appearance-none border-0 bg-transparent p-0"
- style={hueStyle}
- type="button"
- onMouseDown={handleHueMouseDown}
+ <div className="flex items-center gap-2">
+ <Select
+ value={colorFormat}
+ onValueChange={(value) => setColorFormat(value as ColorFormat)}
>
- <ColorCircle
- size="md"
- position={{
- left: `${(displayHue / 360) * 100}%`,
- top: "50%",
- }}
- color={`#${value}`}
- />
- </button>
- </div>,
- document.body,
- )}
- </div>
+ <SelectTrigger variant="outline" className="min-w-18 max-w-18">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="hex">HEX</SelectItem>
+ <SelectItem value="rgb">RGB</SelectItem>
+ <SelectItem value="hsl">HSL</SelectItem>
+ <SelectItem value="hsv">HSV</SelectItem>
+ </SelectContent>
+ </Select>
+
+ <Input
+ className={cn(
+ "h-7 rounded-sm p-2.5",
+ colorFormat === "hex" && "uppercase",
+ )}
+ type="text"
+ value={inputValue}
+ onChange={handleInputChange}
+ onBlur={handleInputBlur}
+ onKeyDown={handleInputKeyDown}
+ onPaste={handlePaste}
+ />
+ </div>
+ </div>
+ </PopoverContent>
+ </Popover>
);
},
);
@@ -336,7 +442,7 @@ const ColorCircle = ({
}: {
size: "sm" | "md";
position: { left: string; top: string };
- color: string;
+ color?: string;
}) => (
<div
className={`pointer-events-none absolute rounded-full border-3 border-white shadow-lg ${
diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx
deleted file mode 100644
index f5d96125..00000000
--- a/apps/web/src/components/ui/command.tsx
+++ /dev/null
@@ -1,152 +0,0 @@
-"use client";
-
-import * as React from "react";
-import type { DialogProps } from "@radix-ui/react-dialog";
-import { Command as CommandPrimitive } from "cmdk";
-import { Search } from "lucide-react";
-import { cn } from "@/utils/ui";
-import { Dialog, DialogContent } from "./dialog";
-
-const Command = React.forwardRef<
- React.ElementRef<typeof CommandPrimitive>,
- React.ComponentPropsWithoutRef<typeof CommandPrimitive>
->(({ className, ...props }, ref) => (
- <CommandPrimitive
- ref={ref}
- className={cn(
- "bg-popover text-popover-foreground flex size-full flex-col overflow-hidden rounded-md",
- className,
- )}
- {...props}
- />
-));
-Command.displayName = CommandPrimitive.displayName;
-
-const CommandDialog = ({ children, ...props }: DialogProps) => {
- return (
- <Dialog {...props}>
- <DialogContent className="overflow-hidden p-0">
- <Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5">
- {children}
- </Command>
- </DialogContent>
- </Dialog>
- );
-};
-
-const CommandInput = React.forwardRef<
- React.ElementRef<typeof CommandPrimitive.Input>,
- React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
->(({ className, ...props }, ref) => (
- <div className="flex items-center border-b px-3" cmdk-input-wrapper="">
- <Search className="mr-2 size-4 shrink-0 opacity-50" />
- <CommandPrimitive.Input
- ref={ref}
- className={cn(
- "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
- className,
- )}
- {...props}
- />
- </div>
-));
-
-CommandInput.displayName = CommandPrimitive.Input.displayName;
-
-const CommandList = React.forwardRef<
- React.ElementRef<typeof CommandPrimitive.List>,
- React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
->(({ className, ...props }, ref) => (
- <CommandPrimitive.List
- ref={ref}
- className={cn("max-h-[300px] overflow-x-hidden overflow-y-auto", className)}
- {...props}
- />
-));
-
-CommandList.displayName = CommandPrimitive.List.displayName;
-
-const CommandEmpty = React.forwardRef<
- React.ElementRef<typeof CommandPrimitive.Empty>,
- React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
->((props, ref) => (
- <CommandPrimitive.Empty
- ref={ref}
- className="py-6 text-center text-sm"
- {...props}
- />
-));
-
-CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
-
-const CommandGroup = React.forwardRef<
- React.ElementRef<typeof CommandPrimitive.Group>,
- React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
->(({ className, ...props }, ref) => (
- <CommandPrimitive.Group
- ref={ref}
- className={cn(
- "text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
- className,
- )}
- {...props}
- />
-));
-
-CommandGroup.displayName = CommandPrimitive.Group.displayName;
-
-const CommandSeparator = React.forwardRef<
- React.ElementRef<typeof CommandPrimitive.Separator>,
- React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
->(({ className, ...props }, ref) => (
- <CommandPrimitive.Separator
- ref={ref}
- className={cn("bg-border -mx-1 h-px", className)}
- {...props}
- />
-));
-CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
-
-const CommandItem = React.forwardRef<
- React.ElementRef<typeof CommandPrimitive.Item>,
- React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
->(({ className, ...props }, ref) => (
- <CommandPrimitive.Item
- ref={ref}
- className={cn(
- "data-[selected=true]:bg-popover-hover data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
- className,
- )}
- {...props}
- />
-));
-
-CommandItem.displayName = CommandPrimitive.Item.displayName;
-
-const CommandShortcut = ({
- className,
- ...props
-}: React.HTMLAttributes<HTMLSpanElement>) => {
- return (
- <span
- className={cn(
- "text-muted-foreground ml-auto text-xs tracking-widest",
- className,
- )}
- {...props}
- />
- );
-};
-CommandShortcut.displayName = "CommandShortcut";
-
-export {
- Command,
- CommandDialog,
- CommandInput,
- CommandList,
- CommandEmpty,
- CommandGroup,
- CommandItem,
- CommandShortcut,
- CommandSeparator,
-};
diff --git a/apps/web/src/components/ui/context-menu.tsx b/apps/web/src/components/ui/context-menu.tsx
index 4af92764..a49d8a88 100644
--- a/apps/web/src/components/ui/context-menu.tsx
+++ b/apps/web/src/components/ui/context-menu.tsx
@@ -5,7 +5,11 @@ import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
import { HugeiconsIcon } from "@hugeicons/react";
-import { Tick02Icon, ArrowRightIcon, CircleIcon } from "@hugeicons/core-free-icons"
+import {
+ Tick02Icon,
+ ArrowRightIcon,
+ CircleIcon,
+} from "@hugeicons/core-free-icons";
const ContextMenu = ContextMenuPrimitive.Root;
@@ -20,12 +24,14 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const contextMenuItemVariants = cva(
- "relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 last:pb-1 text-base outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
+ "relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
- default: "focus:bg-accent/35 focus:text-accent-foreground [&_svg]:text-muted-foreground",
- destructive: "text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive",
+ default:
+ "focus:bg-accent focus:text-accent-foreground [&_svg]:text-muted-foreground",
+ destructive:
+ "text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive",
},
},
defaultVariants: {
@@ -41,22 +47,32 @@ const ContextMenuSubTrigger = React.forwardRef<
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
icon?: React.ReactNode;
}
->(({ className, inset, children, variant = "default", icon, ...props }, ref) => (
- <ContextMenuPrimitive.SubTrigger
- ref={ref}
- className={cn(
- contextMenuItemVariants({ variant }),
- "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
- inset && "pl-8",
- className,
- )}
- {...props}
- >
- {icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
- {children}
- <HugeiconsIcon icon={ArrowRightIcon} className="ml-auto text-muted-foreground/80" />
- </ContextMenuPrimitive.SubTrigger>
-));
+>(
+ (
+ { className, inset, children, variant = "default", icon, ...props },
+ ref,
+ ) => (
+ <ContextMenuPrimitive.SubTrigger
+ ref={ref}
+ className={cn(
+ contextMenuItemVariants({ variant }),
+ "data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {icon && (
+ <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
+ )}
+ {children}
+ <HugeiconsIcon
+ icon={ArrowRightIcon}
+ className="ml-auto text-muted-foreground/80"
+ />
+ </ContextMenuPrimitive.SubTrigger>
+ ),
+);
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = React.forwardRef<
@@ -76,13 +92,15 @@ ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
const ContextMenuContent = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
->(({ className, ...props }, ref) => (
- <ContextMenuPrimitive.Portal>
+ React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
+ container?: HTMLElement | null;
+ }
+>(({ className, container, ...props }, ref) => (
+ <ContextMenuPrimitive.Portal container={container ?? undefined}>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
- "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5",
+ "bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-1.5",
className,
)}
{...props}
@@ -99,25 +117,46 @@ const ContextMenuItem = React.forwardRef<
icon?: React.ReactNode;
textRight?: string;
}
->(({ className, inset, variant = "default", icon, children, textRight, ...props }, ref) => (
- <ContextMenuPrimitive.Item
- ref={ref}
- className={cn(
- contextMenuItemVariants({ variant }),
- inset && "pl-8",
+>(
+ (
+ {
className,
- )}
- {...props}
- >
- {icon && <span className="[&_svg]:size-4 [&_svg]:shrink-0">{icon}</span>}
- {children}
- {textRight && (
- <span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5">
- {textRight}
- </span>
- )}
- </ContextMenuPrimitive.Item>
-));
+ inset,
+ variant = "default",
+ icon,
+ children,
+ textRight,
+ ...props
+ },
+ ref,
+ ) => {
+ const shouldInsetContent = inset || Boolean(icon);
+
+ return (
+ <ContextMenuPrimitive.Item
+ ref={ref}
+ className={cn(
+ contextMenuItemVariants({ variant }),
+ shouldInsetContent && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {icon && (
+ <span className="absolute left-3 flex size-3.5 items-center justify-center text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0">
+ {icon}
+ </span>
+ )}
+ {children}
+ {textRight && (
+ <span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5">
+ {textRight}
+ </span>
+ )}
+ </ContextMenuPrimitive.Item>
+ );
+ },
+);
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = React.forwardRef<
@@ -126,22 +165,33 @@ const ContextMenuCheckboxItem = React.forwardRef<
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
icon?: React.ReactNode;
}
->(({ className, children, checked, variant = "default", icon, ...props }, ref) => (
- <ContextMenuPrimitive.CheckboxItem
- ref={ref}
- className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)}
- checked={checked}
- {...props}
- >
- <span className="absolute left-2 flex size-3.5 items-center justify-center">
- <ContextMenuPrimitive.ItemIndicator>
- <HugeiconsIcon icon={Tick02Icon} className="size-4" />
- </ContextMenuPrimitive.ItemIndicator>
- </span>
- {icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
- {children}
- </ContextMenuPrimitive.CheckboxItem>
-));
+>(
+ (
+ { className, children, checked, variant = "default", icon, ...props },
+ ref,
+ ) => (
+ <ContextMenuPrimitive.CheckboxItem
+ ref={ref}
+ className={cn(
+ contextMenuItemVariants({ variant }),
+ "pr-2 pl-8",
+ className,
+ )}
+ checked={checked}
+ {...props}
+ >
+ <span className="absolute left-3 flex size-3.5 items-center justify-center">
+ <ContextMenuPrimitive.ItemIndicator>
+ <HugeiconsIcon icon={Tick02Icon} className="size-4" />
+ </ContextMenuPrimitive.ItemIndicator>
+ </span>
+ {icon && (
+ <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
+ )}
+ {children}
+ </ContextMenuPrimitive.CheckboxItem>
+ ),
+);
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
@@ -162,7 +212,9 @@ const ContextMenuRadioItem = React.forwardRef<
<HugeiconsIcon icon={CircleIcon} className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
- {icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
+ {icon && (
+ <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
+ )}
{children}
</ContextMenuPrimitive.RadioItem>
));
@@ -184,7 +236,9 @@ const ContextMenuLabel = React.forwardRef<
)}
{...props}
>
- {icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
+ {icon && (
+ <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
+ )}
{children}
</ContextMenuPrimitive.Label>
));
@@ -208,7 +262,10 @@ const ContextMenuShortcut = ({
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
- className={cn("ml-auto text-xs tracking-widest text-muted-foreground opacity-60", className)}
+ className={cn(
+ "ml-auto text-xs tracking-widest text-muted-foreground opacity-60",
+ className,
+ )}
{...props}
/>
);
diff --git a/apps/web/src/components/ui/drawer.tsx b/apps/web/src/components/ui/drawer.tsx
deleted file mode 100644
index d5752d62..00000000
--- a/apps/web/src/components/ui/drawer.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-"use client";
-
-import * as React from "react";
-import { Drawer as DrawerPrimitive } from "vaul";
-
-import { cn } from "@/utils/ui";
-
-const Drawer = ({
- shouldScaleBackground = true,
- ...props
-}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
- <DrawerPrimitive.Root
- shouldScaleBackground={shouldScaleBackground}
- {...props}
- />
-);
-Drawer.displayName = "Drawer";
-
-const DrawerTrigger = DrawerPrimitive.Trigger;
-
-const DrawerPortal = DrawerPrimitive.Portal;
-
-const DrawerClose = DrawerPrimitive.Close;
-
-const DrawerOverlay = React.forwardRef<
- React.ElementRef<typeof DrawerPrimitive.Overlay>,
- React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
->(({ className, ...props }, ref) => (
- <DrawerPrimitive.Overlay
- ref={ref}
- className={cn("fixed inset-0 z-50 bg-black/80", className)}
- {...props}
- />
-));
-DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
-
-const DrawerContent = React.forwardRef<
- React.ElementRef<typeof DrawerPrimitive.Content>,
- React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
->(({ className, children, ...props }, ref) => (
- <DrawerPortal>
- <DrawerOverlay />
- <DrawerPrimitive.Content
- ref={ref}
- className={cn(
- "bg-background fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border",
- className,
- )}
- {...props}
- >
- <div className="bg-muted mx-auto mt-4 h-2 w-[100px] rounded-full" />
- {children}
- </DrawerPrimitive.Content>
- </DrawerPortal>
-));
-DrawerContent.displayName = "DrawerContent";
-
-const DrawerHeader = ({
- className,
- ...props
-}: React.HTMLAttributes<HTMLDivElement>) => (
- <div
- className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
- {...props}
- />
-);
-DrawerHeader.displayName = "DrawerHeader";
-
-const DrawerFooter = ({
- className,
- ...props
-}: React.HTMLAttributes<HTMLDivElement>) => (
- <div
- className={cn("mt-auto flex flex-col gap-2 p-4", className)}
- {...props}
- />
-);
-DrawerFooter.displayName = "DrawerFooter";
-
-const DrawerTitle = React.forwardRef<
- React.ElementRef<typeof DrawerPrimitive.Title>,
- React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
->(({ className, ...props }, ref) => (
- <DrawerPrimitive.Title
- ref={ref}
- className={cn(
- "text-lg leading-none font-semibold tracking-tight",
- className,
- )}
- {...props}
- />
-));
-DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
-
-const DrawerDescription = React.forwardRef<
- React.ElementRef<typeof DrawerPrimitive.Description>,
- React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
->(({ className, ...props }, ref) => (
- <DrawerPrimitive.Description
- ref={ref}
- className={cn("text-muted-foreground text-sm", className)}
- {...props}
- />
-));
-DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
-
-export {
- Drawer,
- DrawerPortal,
- DrawerOverlay,
- DrawerTrigger,
- DrawerClose,
- DrawerContent,
- DrawerHeader,
- DrawerFooter,
- DrawerTitle,
- DrawerDescription,
-};
diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx
index d8a9f594..2d6541b8 100644
--- a/apps/web/src/components/ui/dropdown-menu.tsx
+++ b/apps/web/src/components/ui/dropdown-menu.tsx
@@ -20,7 +20,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const dropdownMenuItemVariants = cva(
- "relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
+ "relative flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm text-foreground outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
@@ -66,7 +66,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
- "bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
+ "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
className,
)}
{...props}
@@ -88,7 +88,7 @@ const DropdownMenuContent = React.forwardRef<
e.preventDefault();
}}
className={cn(
- "bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-2 shadow-lg",
+ "group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-1.5 shadow-lg",
className,
)}
{...props}
@@ -101,19 +101,61 @@ const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
+ icon?: React.ReactNode;
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
}
->(({ className, inset, variant = "default", ...props }, ref) => (
- <DropdownMenuPrimitive.Item
- ref={ref}
- className={cn(
- dropdownMenuItemVariants({ variant }),
- inset && "pl-8",
+>(
+ (
+ {
className,
- )}
- {...props}
- />
-));
+ inset,
+ icon,
+ variant = "default",
+ children,
+ asChild,
+ ...props
+ },
+ ref,
+ ) => {
+ const iconSlot = (
+ <span className="hidden size-4 shrink-0 items-center justify-center group-has-[[data-has-icon]]/menu:flex">
+ {icon}
+ </span>
+ );
+
+ const renderedChildren =
+ asChild && React.isValidElement(children) ? (
+ React.cloneElement(
+ children as React.ReactElement<{ children?: React.ReactNode }>,
+ {},
+ iconSlot,
+ (children as React.ReactElement<{ children?: React.ReactNode }>).props
+ .children,
+ )
+ ) : (
+ <>
+ {iconSlot}
+ {children}
+ </>
+ );
+
+ return (
+ <DropdownMenuPrimitive.Item
+ ref={ref}
+ asChild={asChild}
+ data-has-icon={icon ? "" : undefined}
+ className={cn(
+ dropdownMenuItemVariants({ variant }),
+ inset && "pl-8",
+ className,
+ )}
+ {...props}
+ >
+ {renderedChildren}
+ </DropdownMenuPrimitive.Item>
+ );
+ },
+);
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
diff --git a/apps/web/src/components/ui/font-picker.tsx b/apps/web/src/components/ui/font-picker.tsx
index 4b8e364a..d5d56255 100644
--- a/apps/web/src/components/ui/font-picker.tsx
+++ b/apps/web/src/components/ui/font-picker.tsx
@@ -1,16 +1,51 @@
+"use client";
+
+import {
+ useState,
+ useMemo,
+ useRef,
+ useEffect,
+ useCallback,
+ type CSSProperties,
+} from "react";
+import { List, type RowComponentProps } from "react-window";
import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants";
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import {
+ getCachedFontAtlas,
+ loadFullFont,
+ prefetchFontAtlas,
+ clearFontAtlasCache,
+} from "@/lib/fonts/google-fonts";
+import type { FontAtlas, FontAtlasEntry } from "@/types/fonts";
import { cn } from "@/utils/ui";
+import { ChevronDown, Search, Upload } from "lucide-react";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { TextFontIcon } from "@hugeicons/core-free-icons";
+
+const FONT_TABS = [
+ { key: "all", label: "All fonts" },
+ { key: "favorites", label: "Favorites" },
+ { key: "my-fonts", label: "My fonts" },
+] as const;
+
+type FontTab = (typeof FONT_TABS)[number]["key"];
+
+const ROW_HEIGHT = 40;
+const SPRITE_ROW_HEIGHT = 40;
+const PREVIEW_SCALE = 0.8;
+const LIST_WIDTH = 288;
+const MAX_LIST_HEIGHT = 288;
+const OVERSCAN = 15;
interface FontPickerProps {
- defaultValue?: FontFamily;
- onValueChange?: (value: FontFamily) => void;
+ defaultValue?: string;
+ onValueChange?: (value: string) => void;
className?: string;
}
@@ -19,24 +54,256 @@ export function FontPicker({
onValueChange,
className,
}: FontPickerProps) {
+ const [open, setOpen] = useState(false);
+ const [search, setSearch] = useState("");
+ const [activeTab, setActiveTab] = useState<FontTab>("all");
+ const [atlas, setAtlas] = useState<FontAtlas | null>(() =>
+ getCachedFontAtlas(),
+ );
+ const [status, setStatus] = useState<"idle" | "loading" | "error">(() =>
+ getCachedFontAtlas() ? "idle" : "loading",
+ );
+ const searchInputRef = useRef<HTMLInputElement>(null);
+
+ const fontNames = useMemo(() => {
+ if (!atlas) return [];
+ return Object.keys(atlas.fonts).sort();
+ }, [atlas]);
+
+ const filteredFonts = useMemo(() => {
+ if (!search) return fontNames;
+ const query = search.toLowerCase();
+ return fontNames.filter((name) => name.toLowerCase().includes(query));
+ }, [fontNames, search]);
+
+ const listHeight = Math.min(
+ MAX_LIST_HEIGHT,
+ filteredFonts.length * ROW_HEIGHT,
+ );
+
+ const handleSelect = useCallback(
+ async ({ family }: { family: string }) => {
+ try {
+ await loadFullFont({ family });
+ onValueChange?.(family);
+ } catch {
+ onValueChange?.(family);
+ }
+ setOpen(false);
+ },
+ [onValueChange],
+ );
+
+ // Load atlas on first open if cache is empty (fallback when prefetch hasn't completed)
+ useEffect(() => {
+ if (!open || atlas) return;
+
+ setStatus("loading");
+ prefetchFontAtlas().then((data) => {
+ if (data) {
+ setAtlas(data);
+ setStatus("idle");
+ } else {
+ setStatus("error");
+ }
+ });
+ }, [open, atlas]);
+
+ useEffect(() => {
+ if (!open) {
+ setSearch("");
+ setActiveTab("all");
+ }
+ }, [open]);
+
+ const handleRetry = useCallback(() => {
+ clearFontAtlasCache();
+ setStatus("loading");
+ prefetchFontAtlas().then((data) => {
+ if (data) {
+ setAtlas(data);
+ setStatus("idle");
+ } else {
+ setStatus("error");
+ }
+ });
+ }, []);
+
return (
- <Select defaultValue={defaultValue} onValueChange={onValueChange}>
- <SelectTrigger
- className={cn("w-full", className)}
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger
+ className={cn(
+ "border-border bg-accent flex h-7 w-full cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap focus-visible:border-primary focus-visible:ring-0 focus:outline-hidden",
+ className,
+ )}
>
- <SelectValue placeholder="Select a font" />
- </SelectTrigger>
- <SelectContent>
- {FONT_OPTIONS.map((font) => (
- <SelectItem
- key={font.value}
- value={font.value}
- style={{ fontFamily: font.value }}
+ <div className="flex min-w-0 items-center gap-1.5">
+ <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
+ <HugeiconsIcon icon={TextFontIcon} />
+ </span>
+ <span className="truncate" style={{ fontFamily: defaultValue }}>
+ {defaultValue ?? "Select a font"}
+ </span>
+ </div>
+ <ChevronDown className="size-3 shrink-0 opacity-50" />
+ </PopoverTrigger>
+ <PopoverContent
+ className="w-72 p-0 overflow-hidden"
+ align="start"
+ side="left"
+ onOpenAutoFocus={(event) => {
+ event.preventDefault();
+ searchInputRef.current?.focus();
+ }}
+ onCloseAutoFocus={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
+ >
+ <div className="relative px-3 py-1.5">
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 shrink-0 opacity-50" />
+ <Input
+ ref={searchInputRef}
+ placeholder="Search fonts..."
+ value={search}
+ onChange={(event) => setSearch(event.target.value)}
+ size="xs"
+ className="w-full pl-5 bg-transparent !border-none !shadow-none"
+ />
+ </div>
+ <div className="flex border-b px-3">
+ {FONT_TABS.map((tab) => (
+ <button
+ key={tab.key}
+ type="button"
+ className={cn(
+ "px-3 py-1.5 text-xs border-b-2 -mb-px",
+ activeTab === tab.key
+ ? "border-foreground text-foreground"
+ : "border-transparent text-muted-foreground hover:text-foreground",
+ )}
+ onClick={() => setActiveTab(tab.key)}
+ >
+ {tab.label}
+ </button>
+ ))}
+ </div>
+ {status === "loading" && (
+ <div className="py-8 text-center text-sm text-muted-foreground">
+ Loading fonts...
+ </div>
+ )}
+ {status === "error" && (
+ <div className="flex flex-col items-center gap-3 py-8 px-4">
+ <p className="text-sm text-muted-foreground text-center">
+ Failed to load font previews.
+ </p>
+ <Button variant="outline" size="sm" onClick={handleRetry}>
+ Retry
+ </Button>
+ </div>
+ )}
+ {status === "idle" &&
+ fontNames.length > 0 &&
+ filteredFonts.length === 0 && (
+ <div className="py-6 text-center text-sm text-muted-foreground">
+ No fonts found.
+ </div>
+ )}
+ {status === "idle" && atlas && filteredFonts.length > 0 && (
+ <List
+ rowCount={filteredFonts.length}
+ rowHeight={ROW_HEIGHT}
+ overscanCount={OVERSCAN}
+ rowComponent={FontRow}
+ rowProps={{
+ atlas,
+ filteredFonts,
+ selectedFont: defaultValue,
+ onFontSelect: handleSelect,
+ }}
+ style={{ height: listHeight, width: LIST_WIDTH }}
+ />
+ )}
+ <div className="border-t p-1">
+ <Button
+ variant="ghost"
+ size="sm"
+ className="w-full justify-start text-muted-foreground h-8 font-normal"
+ onClick={() => {
+ // TODO: Implement local font loading
+ console.log("Load local fonts clicked");
+ }}
>
- {font.label}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <Upload className="!size-3.5" />
+ Load local fonts
+ </Button>
+ </div>
+ </PopoverContent>
+ </Popover>
+ );
+}
+
+function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) {
+ return (
+ <div
+ className="shrink-0"
+ style={{
+ width: entry.w,
+ height: SPRITE_ROW_HEIGHT,
+ backgroundColor: "currentColor",
+ WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
+ WebkitMaskPosition: `-${entry.x}px -${entry.y}px`,
+ WebkitMaskRepeat: "no-repeat",
+ maskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
+ maskPosition: `-${entry.x}px -${entry.y}px`,
+ maskRepeat: "no-repeat",
+ transform: `scale(${PREVIEW_SCALE})`,
+ transformOrigin: "left center",
+ }}
+ />
+ );
+}
+
+type FontRowProps = {
+ atlas: FontAtlas;
+ filteredFonts: string[];
+ selectedFont: string | undefined;
+ onFontSelect: (params: { family: string }) => void;
+};
+
+function FontRow({
+ index,
+ style,
+ atlas,
+ filteredFonts,
+ selectedFont,
+ onFontSelect,
+}: RowComponentProps<FontRowProps>) {
+ const fontName = filteredFonts[index];
+ const entry = atlas.fonts[fontName];
+ const isSelected = fontName === selectedFont;
+
+ return (
+ <button
+ type="button"
+ style={style as CSSProperties}
+ className={cn(
+ "flex w-full cursor-pointer items-center gap-2 px-3 outline-hidden hover:bg-popover-hover",
+ isSelected && "bg-popover-hover",
+ )}
+ onClick={() => onFontSelect({ family: fontName })}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onFontSelect({ family: fontName });
+ }
+ }}
+ aria-label={fontName}
+ >
+ <div className="min-w-0 overflow-hidden">
+ <FontSpritePreview entry={entry} />
+ </div>
+ </button>
);
}
diff --git a/apps/web/src/components/ui/input-otp.tsx b/apps/web/src/components/ui/input-otp.tsx
deleted file mode 100644
index ec71f96a..00000000
--- a/apps/web/src/components/ui/input-otp.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-"use client";
-
-import * as React from "react";
-import { OTPInput, OTPInputContext } from "input-otp";
-import { Minus } from "lucide-react";
-
-import { cn } from "@/utils/ui";
-
-const InputOTP = React.forwardRef<
- React.ElementRef<typeof OTPInput>,
- React.ComponentPropsWithoutRef<typeof OTPInput>
->(({ className, containerClassName, ...props }, ref) => (
- <OTPInput
- ref={ref}
- containerClassName={cn(
- "flex items-center justify-between gap-2 w-full has-disabled:opacity-50",
- containerClassName,
- )}
- className={cn("disabled:cursor-not-allowed", className)}
- {...props}
- />
-));
-InputOTP.displayName = "InputOTP";
-
-const InputOTPGroup = React.forwardRef<
- React.ElementRef<"div">,
- React.ComponentPropsWithoutRef<"div">
->(({ className, ...props }, ref) => (
- <div
- ref={ref}
- className={cn("flex flex-1 items-center gap-2", className)}
- {...props}
- />
-));
-InputOTPGroup.displayName = "InputOTPGroup";
-
-const InputOTPSlot = React.forwardRef<
- React.ElementRef<"div">,
- React.ComponentPropsWithoutRef<"div"> & { index: number }
->(({ index, className, ...props }, ref) => {
- const inputOTPContext = React.useContext(OTPInputContext);
- const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
-
- return (
- <div
- ref={ref}
- className={cn(
- "border-input relative flex aspect-square min-w-[36px] flex-1 items-center justify-center border text-lg shadow-xs first:rounded-l-md first:border-l last:rounded-r-md",
- isActive && "ring-ring z-10 ring-1",
- className,
- )}
- {...props}
- >
- {char}
- {hasFakeCaret && (
- <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
- <div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
- </div>
- )}
- </div>
- );
-});
-InputOTPSlot.displayName = "InputOTPSlot";
-
-const InputOTPSeparator = React.forwardRef<
- React.ElementRef<"div">,
- React.ComponentPropsWithoutRef<"div">
->(({ ...props }, ref) => (
- <div ref={ref} role="separator" {...props}>
- <Minus />
- </div>
-));
-InputOTPSeparator.displayName = "InputOTPSeparator";
-
-export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
diff --git a/apps/web/src/components/ui/input-with-back.tsx b/apps/web/src/components/ui/input-with-back.tsx
index f8265779..1c22fd2e 100644
--- a/apps/web/src/components/ui/input-with-back.tsx
+++ b/apps/web/src/components/ui/input-with-back.tsx
@@ -1,88 +1,85 @@
-"use client";
-
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { ArrowLeft, Search } from "lucide-react";
-import { motion } from "motion/react";
-import { useState, useEffect } from "react";
-
-interface InputWithBackProps {
- isExpanded: boolean;
- setIsExpanded: (isExpanded: boolean) => void;
- placeholder?: string;
- value?: string;
- onChange?: (value: string) => void;
- disableAnimation?: boolean;
-}
-
-export function InputWithBack({
- isExpanded,
- setIsExpanded,
- placeholder = "Search anything",
- value,
- onChange,
- disableAnimation = false,
-}: InputWithBackProps) {
- const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
- const [buttonOffset, setButtonOffset] = useState(-60);
-
- const smoothTransition = {
- duration: disableAnimation ? 0 : 0.35,
- ease: [0.25, 0.1, 0.25, 1] as const,
- };
-
- useEffect(() => {
- if (containerRef) {
- const rect = containerRef.getBoundingClientRect();
- setButtonOffset(-rect.left - 48);
- }
- }, [containerRef]);
-
- return (
- <div ref={setContainerRef} className="relative w-full">
- <motion.div
- className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75"
- initial={{
- x: isExpanded ? 0 : buttonOffset,
- opacity: isExpanded ? 1 : 0.5,
- }}
- animate={{
- x: isExpanded ? 0 : buttonOffset,
- opacity: isExpanded ? 1 : 0.5,
- }}
- transition={smoothTransition}
- onClick={() => setIsExpanded(!isExpanded)}
- >
- <Button
- variant="outline"
- className="bg-accent !size-9 rounded-full"
- >
- <ArrowLeft />
- </Button>
- </motion.div>
- <div
- className="relative flex-1"
- style={{ marginLeft: "0px", paddingLeft: "0px" }}
- >
- <motion.div
- className="relative"
- initial={{
- marginLeft: isExpanded ? 50 : 0,
- }}
- animate={{
- marginLeft: isExpanded ? 50 : 0,
- }}
- transition={smoothTransition}
- >
- <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
- <Input
- placeholder={placeholder}
- className="bg-accent w-full pl-9"
- value={value}
- onChange={(e) => onChange?.(e.target.value)}
- />
- </motion.div>
- </div>
- </div>
- );
-}
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { ArrowLeft, Search } from "lucide-react";
+import { motion } from "motion/react";
+import { useState, useEffect } from "react";
+
+interface InputWithBackProps {
+ isExpanded: boolean;
+ setIsExpanded: (isExpanded: boolean) => void;
+ placeholder?: string;
+ value?: string;
+ onChange?: (value: string) => void;
+ disableAnimation?: boolean;
+}
+
+export function InputWithBack({
+ isExpanded,
+ setIsExpanded,
+ placeholder = "Search anything",
+ value,
+ onChange,
+ disableAnimation = false,
+}: InputWithBackProps) {
+ const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
+ const [buttonOffset, setButtonOffset] = useState(-60);
+
+ const smoothTransition = {
+ duration: disableAnimation ? 0 : 0.35,
+ ease: [0.25, 0.1, 0.25, 1] as const,
+ };
+
+ useEffect(() => {
+ if (containerRef) {
+ const rect = containerRef.getBoundingClientRect();
+ setButtonOffset(-rect.left - 48);
+ }
+ }, [containerRef]);
+
+ return (
+ <div ref={setContainerRef} className="relative w-full">
+ <motion.div
+ className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75"
+ initial={{
+ x: isExpanded ? 0 : buttonOffset,
+ opacity: isExpanded ? 1 : 0.5,
+ }}
+ animate={{
+ x: isExpanded ? 0 : buttonOffset,
+ opacity: isExpanded ? 1 : 0.5,
+ }}
+ transition={smoothTransition}
+ onClick={() => setIsExpanded(!isExpanded)}
+ >
+ <Button variant="outline" className="bg-accent !size-9 rounded-full">
+ <ArrowLeft />
+ </Button>
+ </motion.div>
+ <div
+ className="relative flex-1"
+ style={{ marginLeft: "0px", paddingLeft: "0px" }}
+ >
+ <motion.div
+ className="relative"
+ initial={{
+ marginLeft: isExpanded ? 50 : 0,
+ }}
+ animate={{
+ marginLeft: isExpanded ? 50 : 0,
+ }}
+ transition={smoothTransition}
+ >
+ <Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
+ <Input
+ placeholder={placeholder}
+ className="bg-accent w-full pl-9"
+ value={value}
+ onChange={(e) => onChange?.(e.target.value)}
+ />
+ </motion.div>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx
index 9be4bca2..762cc675 100644
--- a/apps/web/src/components/ui/input.tsx
+++ b/apps/web/src/components/ui/input.tsx
@@ -8,17 +8,18 @@ import { forwardRef, type ComponentProps } from "react";
import { useState } from "react";
const inputVariants = cva(
- "file:text-foreground placeholder:text-muted-foreground border-border bg-background flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-4 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ "file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
- default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary focus-visible:ring-primary/10",
- destructive: "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10",
+ default: "selection:bg-primary selection:text-primary-foreground",
+ destructive:
+ "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10",
},
size: {
- default:
- "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
- sm: "h-8 px-3 text-xs file:h-6 file:text-xs",
+ default: "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
+ xs: "h-7 px-3 text-xs file:h-6 file:text-xs",
+ sm: "h-8 px-3 text-sm file:h-6 file:text-xs",
lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm",
},
},
@@ -102,7 +103,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
/>
{showClear && (
<Button
- type="button"
variant="text"
size="icon"
onMouseDown={(e) => {
@@ -117,7 +117,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
)}
{showPasswordToggle && (
<Button
- type="button"
variant="text"
size="icon"
onClick={() => onShowPasswordChange?.(!showPassword)}
diff --git a/apps/web/src/components/ui/number-field.tsx b/apps/web/src/components/ui/number-field.tsx
new file mode 100644
index 00000000..35cce81b
--- /dev/null
+++ b/apps/web/src/components/ui/number-field.tsx
@@ -0,0 +1,169 @@
+"use client";
+
+import { cn } from "@/utils/ui";
+import { useRef, useState, type ComponentProps } from "react";
+import { useFocusLock } from "@/hooks/use-focus-lock";
+import { Button } from "@/components/ui/button";
+import { HugeiconsIcon } from "@hugeicons/react";
+import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons";
+
+const DRAG_SENSITIVITIES = {
+ default: 1,
+ slow: 0.5,
+} as const;
+
+type DragSensitivity = "default" | "slow";
+
+interface NumberFieldProps
+ extends Omit<ComponentProps<"input">, "size" | "type"> {
+ icon?: React.ReactNode;
+ dragSensitivity?: DragSensitivity;
+ onScrub?: (value: number) => void;
+ onScrubEnd?: () => void;
+ allowExpressions?: boolean;
+ onReset?: () => void;
+ isDefault?: boolean;
+}
+
+function NumberField({
+ className,
+ icon,
+ disabled,
+ dragSensitivity = "default",
+ onScrub,
+ onScrubEnd,
+ value,
+ allowExpressions = true,
+ onKeyDown,
+ onFocus,
+ onBlur,
+ onMouseDown,
+ onReset,
+ isDefault = false,
+ ref,
+ ...props
+}: NumberFieldProps & { ref?: React.Ref<HTMLInputElement> }) {
+ const iconRef = useRef<HTMLSpanElement>(null);
+ const inputRef = useRef<HTMLInputElement>(null);
+ const startValueRef = useRef(0);
+ const cumulativeDeltaRef = useRef(0);
+ const [isInputFocused, setIsInputFocused] = useState(false);
+
+ const { containerRef: wrapperRef } = useFocusLock<HTMLDivElement>({
+ isActive: isInputFocused,
+ onDismiss: () => inputRef.current?.blur(),
+ cursor: "text",
+ allowSelector: "input, textarea, [contenteditable]",
+ });
+
+ const handleIconPointerDown = (event: React.PointerEvent) => {
+ if (!onScrub || disabled || event.button !== 0) return;
+ const parsed = parseFloat(String(value ?? "0"));
+ startValueRef.current = Number.isNaN(parsed) ? 0 : parsed;
+ cumulativeDeltaRef.current = 0;
+ let hasReceivedFirstMove = false;
+ iconRef.current?.requestPointerLock();
+
+ const handlePointerMove = (moveEvent: PointerEvent) => {
+ // first movementX after pointer lock often contains a bogus warp delta
+ if (!hasReceivedFirstMove) {
+ hasReceivedFirstMove = true;
+ return;
+ }
+ cumulativeDeltaRef.current += moveEvent.movementX;
+ const newValue =
+ startValueRef.current +
+ cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity];
+ onScrub(newValue);
+ };
+
+ const handlePointerUp = () => {
+ document.removeEventListener("pointermove", handlePointerMove);
+ document.removeEventListener("pointerup", handlePointerUp);
+ document.exitPointerLock();
+ onScrubEnd?.();
+ };
+
+ document.addEventListener("pointermove", handlePointerMove);
+ document.addEventListener("pointerup", handlePointerUp);
+ };
+
+ const canScrub = Boolean(icon && onScrub);
+
+ return (
+ <div
+ ref={wrapperRef}
+ className={cn(
+ "border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
+ disabled && "pointer-events-none cursor-not-allowed opacity-50",
+ className,
+ )}
+ >
+ {icon && (
+ <span
+ ref={iconRef}
+ className={cn(
+ "text-muted-foreground [&_svg]:!size-3.5 shrink-0 select-none pl-2.5 text-sm leading-none",
+ canScrub && "cursor-ew-resize",
+ )}
+ onPointerDown={canScrub ? handleIconPointerDown : undefined}
+ >
+ {icon}
+ </span>
+ )}
+ <input
+ type={allowExpressions ? "text" : "number"}
+ inputMode={allowExpressions ? "decimal" : undefined}
+ ref={inputRef}
+ disabled={disabled}
+ value={value}
+ className={cn(
+ "min-w-0 flex-1 text-sm leading-none bg-transparent outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
+ icon ? "px-1.5" : "pl-2.5",
+ onReset ? "pr-0" : "pr-2.5",
+ )}
+ onMouseDown={(event) => {
+ const inputElement = event.currentTarget;
+ const shouldPreventNativeCaretPlacement =
+ event.button === 0 && document.activeElement !== inputElement;
+ if (shouldPreventNativeCaretPlacement) {
+ event.preventDefault();
+ inputElement.focus();
+ inputElement.select();
+ }
+ onMouseDown?.(event);
+ }}
+ onFocus={(event) => {
+ setIsInputFocused(true);
+ event.currentTarget.select();
+ onFocus?.(event);
+ }}
+ onKeyDown={(event) => {
+ const shouldBlurInput =
+ event.key === "Enter" || event.key === "Escape";
+ if (shouldBlurInput) event.currentTarget.blur();
+ onKeyDown?.(event);
+ }}
+ onBlur={(event) => {
+ setIsInputFocused(false);
+ onBlur?.(event);
+ }}
+ {...props}
+ />
+ {onReset && !isDefault && (
+ <div className="shrink-0 pr-2 flex items-center">
+ <Button
+ variant="text"
+ size="text"
+ aria-label="Reset to default"
+ onClick={onReset}
+ >
+ <HugeiconsIcon icon={ArrowTurnBackwardIcon} className="!size-3.5" />
+ </Button>
+ </div>
+ )}
+ </div>
+ );
+}
+
+export { NumberField };
diff --git a/apps/web/src/components/ui/pagination.tsx b/apps/web/src/components/ui/pagination.tsx
deleted file mode 100644
index 96eec969..00000000
--- a/apps/web/src/components/ui/pagination.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-import * as React from "react";
-import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
-
-import { cn } from "@/utils/ui";
-import { type ButtonProps, buttonVariants } from "./button";
-
-const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
- <nav
- role="navigation"
- aria-label="pagination"
- className={cn("mx-auto flex w-full justify-center", className)}
- {...props}
- />
-);
-Pagination.displayName = "Pagination";
-
-const PaginationContent = React.forwardRef<
- HTMLUListElement,
- React.ComponentProps<"ul">
->(({ className, ...props }, ref) => (
- <ul
- ref={ref}
- className={cn("flex flex-row items-center gap-1", className)}
- {...props}
- />
-));
-PaginationContent.displayName = "PaginationContent";
-
-const PaginationItem = React.forwardRef<
- HTMLLIElement,
- React.ComponentProps<"li">
->(({ className, ...props }, ref) => (
- <li ref={ref} className={cn("", className)} {...props} />
-));
-PaginationItem.displayName = "PaginationItem";
-
-type PaginationLinkProps = {
- isActive?: boolean;
-} & Pick<ButtonProps, "size"> &
- React.ComponentProps<"a">;
-
-const PaginationLink = ({
- className,
- isActive,
- size = "icon",
- ...props
-}: PaginationLinkProps) => (
- <a
- aria-current={isActive ? "page" : undefined}
- className={cn(
- buttonVariants({
- variant: isActive ? "outline" : "text",
- size,
- }),
- className,
- )}
- {...props}
- />
-);
-PaginationLink.displayName = "PaginationLink";
-
-const PaginationPrevious = ({
- className,
- ...props
-}: React.ComponentProps<typeof PaginationLink>) => (
- <PaginationLink
- aria-label="Go to previous page"
- size="default"
- className={cn("gap-1 pl-2.5", className)}
- {...props}
- >
- <ChevronLeft className="size-4" />
- <span>Previous</span>
- </PaginationLink>
-);
-PaginationPrevious.displayName = "PaginationPrevious";
-
-const PaginationNext = ({
- className,
- ...props
-}: React.ComponentProps<typeof PaginationLink>) => (
- <PaginationLink
- aria-label="Go to next page"
- size="default"
- className={cn("gap-1 pr-2.5", className)}
- {...props}
- >
- <span>Next</span>
- <ChevronRight className="size-4" />
- </PaginationLink>
-);
-PaginationNext.displayName = "PaginationNext";
-
-const PaginationEllipsis = ({
- className,
- ...props
-}: React.ComponentProps<"span">) => (
- <span
- aria-hidden
- className={cn("flex size-9 items-center justify-center", className)}
- {...props}
- >
- <MoreHorizontal className="size-4" />
- <span className="sr-only">More pages</span>
- </span>
-);
-PaginationEllipsis.displayName = "PaginationEllipsis";
-
-export {
- Pagination,
- PaginationContent,
- PaginationLink,
- PaginationItem,
- PaginationPrevious,
- PaginationNext,
- PaginationEllipsis,
-};
diff --git a/apps/web/src/components/ui/phone-input.tsx b/apps/web/src/components/ui/phone-input.tsx
deleted file mode 100644
index 48b002ca..00000000
--- a/apps/web/src/components/ui/phone-input.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import * as React from "react";
-import { CheckIcon, ChevronsUpDown } from "lucide-react";
-import * as RPNInput from "react-phone-number-input";
-import flags from "react-phone-number-input/flags";
-
-import { Button } from "./button";
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "./command";
-import { Input } from "./input";
-import { Popover, PopoverContent, PopoverTrigger } from "./popover";
-import { ScrollArea } from "./scroll-area";
-import { cn } from "@/utils/ui";
-
-type PhoneInputProps = Omit<
- React.ComponentProps<"input">,
- "onChange" | "value" | "ref"
-> &
- Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
- onChange?: (value: RPNInput.Value) => void;
- };
-
-const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
- React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
- ({ className, onChange, defaultCountry = "US", ...props }, ref) => {
- return (
- <RPNInput.default
- ref={ref}
- className={cn("flex", className)}
- flagComponent={FlagComponent}
- countrySelectComponent={CountrySelect}
- inputComponent={InputComponent}
- defaultCountry={defaultCountry}
- international
- addInternationalOption
- smartCaret={false}
- /**
- * Handles the onChange event.
- *
- * react-phone-number-input might trigger the onChange event as undefined
- * when a valid phone number is not entered. To prevent this,
- * the value is coerced to an empty string.
- *
- * @param {E164Number | undefined} value - The entered value
- */
- onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
- {...props}
- />
- );
- },
- );
-PhoneInput.displayName = "PhoneInput";
-
-const InputComponent = React.forwardRef<
- HTMLInputElement,
- React.ComponentProps<"input">
->(({ className, size: _size, ...props }, ref) => (
- <Input
- className={cn("rounded-s-none rounded-e-lg", className)}
- {...props}
- ref={ref}
- />
-));
-InputComponent.displayName = "InputComponent";
-
-type CountryEntry = { label: string; value: RPNInput.Country | undefined };
-
-type CountrySelectProps = {
- disabled?: boolean;
- value: RPNInput.Country;
- options: CountryEntry[];
- onChange: (country: RPNInput.Country) => void;
-};
-
-const CountrySelect = ({
- disabled,
- value: selectedCountry,
- options: countryList,
- onChange,
-}: CountrySelectProps) => {
- return (
- <Popover>
- <PopoverTrigger asChild>
- <Button
- type="button"
- variant="outline"
- className="flex gap-1 rounded-s-lg rounded-e-none border-r-0 px-3 focus:z-10"
- disabled={disabled}
- >
- <FlagComponent
- country={selectedCountry}
- countryName={selectedCountry}
- />
- <ChevronsUpDown
- className={cn(
- "-mr-2 opacity-50",
- disabled ? "hidden" : "opacity-100",
- )}
- />
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-[300px] p-0">
- <Command>
- <CommandInput placeholder="Search country..." />
- <CommandList>
- <ScrollArea className="h-72">
- <CommandEmpty>No country found.</CommandEmpty>
- <CommandGroup>
- {countryList.map(({ value, label }) =>
- value ? (
- <CountrySelectOption
- key={value}
- country={value}
- countryName={label}
- selectedCountry={selectedCountry}
- onChange={onChange}
- />
- ) : null,
- )}
- </CommandGroup>
- </ScrollArea>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- );
-};
-
-interface CountrySelectOptionProps extends RPNInput.FlagProps {
- selectedCountry: RPNInput.Country;
- onChange: (country: RPNInput.Country) => void;
-}
-
-const CountrySelectOption = ({
- country,
- countryName,
- selectedCountry,
- onChange,
-}: CountrySelectOptionProps) => {
- return (
- <CommandItem className="gap-2" onSelect={() => onChange(country)}>
- <FlagComponent country={country} countryName={countryName} />
- <span className="flex-1 text-sm">{countryName}</span>
- <span className="text-foreground/50 text-sm">{`+${RPNInput.getCountryCallingCode(country)}`}</span>
- <CheckIcon
- className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`}
- />
- </CommandItem>
- );
-};
-
-const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
- const Flag = flags[country];
-
- return (
- <span className="bg-foreground/20 flex h-4 w-6 overflow-hidden rounded-sm [&_svg]:size-full">
- {Flag && <Flag title={countryName} />}
- </span>
- );
-};
-
-export { PhoneInput };
diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx
index 36ae5a69..8fd511a3 100644
--- a/apps/web/src/components/ui/popover.tsx
+++ b/apps/web/src/components/ui/popover.tsx
@@ -10,6 +10,8 @@ const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
+const PopoverClose = PopoverPrimitive.Close;
+
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
@@ -20,7 +22,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
- "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden",
+ "bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden",
className,
)}
{...props}
@@ -29,4 +31,4 @@ const PopoverContent = React.forwardRef<
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
-export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose };
diff --git a/apps/web/src/components/ui/prose.tsx b/apps/web/src/components/ui/prose.tsx
index 4e526b7e..88d017fe 100644
--- a/apps/web/src/components/ui/prose.tsx
+++ b/apps/web/src/components/ui/prose.tsx
@@ -1,4 +1,7 @@
import { cn } from "@/utils/ui";
+import rehypeParse from "rehype-parse";
+import { unified } from "unified";
+import { createElement } from "react";
import type React from "react";
type ProseProps = React.HTMLAttributes<HTMLElement> & {
@@ -6,6 +9,92 @@ type ProseProps = React.HTMLAttributes<HTMLElement> & {
html: string;
};
+type HastTextNode = {
+ type: "text";
+ value: string;
+};
+
+type HastElementNode = {
+ type: "element";
+ tagName: string;
+ properties?: Record<string, unknown>;
+ children?: HastNode[];
+};
+
+type HastRootNode = {
+ type: "root";
+ children?: HastNode[];
+};
+
+type HastNode = HastTextNode | HastElementNode | HastRootNode;
+
+function toReactProps({
+ properties,
+}: {
+ properties: HastElementNode["properties"];
+}): Record<string, unknown> {
+ if (!properties) {
+ return {};
+ }
+
+ const props: Record<string, unknown> = {};
+
+ for (const [propertyName, propertyValue] of Object.entries(properties)) {
+ if (propertyValue === null || propertyValue === undefined) {
+ continue;
+ }
+
+ if (propertyName.startsWith("on")) {
+ continue;
+ }
+
+ if (propertyName === "class") {
+ props.className = Array.isArray(propertyValue)
+ ? propertyValue.join(" ")
+ : propertyValue;
+ continue;
+ }
+
+ if (propertyName === "for") {
+ props.htmlFor = propertyValue;
+ continue;
+ }
+
+ props[propertyName] = propertyValue;
+ }
+
+ return props;
+}
+
+function renderHastNode({
+ node,
+ key,
+}: {
+ node: HastNode;
+ key: string;
+}): React.ReactNode {
+ if (node.type === "text") {
+ return node.value;
+ }
+
+ if (node.type !== "element") {
+ return null;
+ }
+
+ const children = (node.children ?? []).map((childNode, index) =>
+ renderHastNode({ node: childNode, key: `${key}-${index}` }),
+ );
+
+ return createElement(node.tagName, { ...toReactProps({ properties: node.properties }), key }, children);
+}
+
+function renderHtmlNodes({ html }: { html: string }): React.ReactNode {
+ const rootNode = unified().use(rehypeParse, { fragment: true }).parse(html) as HastRootNode;
+ return (rootNode.children ?? []).map((childNode, index) =>
+ renderHastNode({ node: childNode, key: `prose-node-${index}` }),
+ );
+}
+
function Prose({ children, html, className }: ProseProps) {
return (
<article
@@ -14,7 +103,7 @@ function Prose({ children, html, className }: ProseProps) {
className,
)}
>
- {html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : children}
+ {html ? renderHtmlNodes({ html }) : children}
</article>
);
}
diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx
index fd9c7ac5..311f6edb 100644
--- a/apps/web/src/components/ui/select.tsx
+++ b/apps/web/src/components/ui/select.tsx
@@ -2,10 +2,11 @@
import * as React from "react";
import { Select as SelectPrimitive } from "radix-ui";
-import { Check, ChevronDown, ChevronUp } from "lucide-react";
+import { Check } from "lucide-react";
+import { ArrowUpIcon, ArrowDownIcon } from "@hugeicons/core-free-icons";
import { cva, type VariantProps } from "class-variance-authority";
-
import { cn } from "@/utils/ui";
+import { HugeiconsIcon } from "@hugeicons/react";
const Select = SelectPrimitive.Root;
@@ -14,36 +15,63 @@ const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const selectItemVariants = cva(
- "relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
+ "relative flex cursor-pointer select-none items-center gap-1.5 rounded-sm px-2 py-1 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "",
+ destructive:
+ "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ },
+);
+
+const selectTriggerVariants = cva(
+ "border-border ring-offset-background placeholder:text-muted-foreground flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap transition-none focus:border-primary focus:ring-0 focus:ring-primary/10 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
{
variants: {
variant: {
+ default: "bg-accent",
+ outline: "bg-background hover:bg-accent/50",
+ },
+ size: {
default: "",
- destructive: "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
+ sm: "",
},
},
defaultVariants: {
variant: "default",
+ size: "default",
},
},
);
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
- React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
->(({ className, children, ...props }, ref) => (
+ React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
+ VariantProps<typeof selectTriggerVariants> & {
+ icon?: React.ReactNode;
+ }
+>(({ className, children, icon, variant, size, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
- className={cn(
- "bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
- "focus:border-primary focus:ring-4 focus:ring-primary/10 border-transparent transition-none",
- className,
- )}
+ className={cn(selectTriggerVariants({ variant, size }), className)}
{...props}
>
- {children}
+ <div className="flex items-center gap-1.5">
+ {icon && (
+ <span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
+ {icon}
+ </span>
+ )}
+ {children}
+ </div>
<SelectPrimitive.Icon asChild>
- <ChevronDown className="size-3 opacity-50" />
+ <HugeiconsIcon icon={ArrowDownIcon} className="size-4" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
@@ -61,7 +89,7 @@ const SelectScrollUpButton = React.forwardRef<
)}
{...props}
>
- <ChevronUp className="size-4" />
+ <HugeiconsIcon icon={ArrowUpIcon} className="size-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
@@ -78,7 +106,7 @@ const SelectScrollDownButton = React.forwardRef<
)}
{...props}
>
- <ChevronDown className="size-4" />
+ <HugeiconsIcon icon={ArrowDownIcon} className="size-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
@@ -92,7 +120,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
- "bg-popover text-popover-foreground z-50 max-h-96 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
+ "bg-popover text-popover-foreground z-50 max-h-(--radix-select-content-available-height) min-w-32 overflow-hidden rounded-md border p-1 shadow-lg",
className,
)}
position={position}
@@ -124,7 +152,7 @@ const SelectLabel = React.forwardRef<
<SelectPrimitive.Label
ref={ref}
className={cn(
- "px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
+ "px-2 pb-1 pt-0.5 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
className,
)}
{...props}
@@ -140,12 +168,12 @@ const SelectItem = React.forwardRef<
>(({ className, children, variant = "default", ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
- className={cn(selectItemVariants({ variant }), "pr-8 pl-2", className)}
+ className={cn(selectItemVariants({ variant }), "pl-6 pr-2", className)}
{...props}
>
- <span className="absolute right-2 flex size-3.5 items-center justify-center">
+ <span className="absolute left-1.5 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
- <Check className="size-4" />
+ <Check className="size-3.5" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
@@ -159,7 +187,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
- className={cn("bg-border mx-1 my-2 h-px", className)}
+ className={cn("bg-border mx-1 my-1 h-px", className)}
{...props}
/>
));
diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx
deleted file mode 100644
index a41e25c7..00000000
--- a/apps/web/src/components/ui/sidebar.tsx
+++ /dev/null
@@ -1,725 +0,0 @@
-"use client";
-
-import { Slot } from "@radix-ui/react-slot";
-import { cva, type VariantProps } from "class-variance-authority";
-import { PanelLeftIcon } from "lucide-react";
-import * as React from "react";
-import { Button } from "@/components/ui/button";
-import { Input } from "@/components/ui/input";
-import { Separator } from "@/components/ui/separator";
-import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet";
-import { Skeleton } from "@/components/ui/skeleton";
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { useIsMobile } from "@/hooks/use-mobile";
-import { cn } from "@/utils/ui";
-
-const SIDEBAR_COOKIE_NAME = "sidebar_state";
-const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
-const SIDEBAR_WIDTH = "16rem";
-const SIDEBAR_WIDTH_MOBILE = "18rem";
-const SIDEBAR_WIDTH_ICON = "3rem";
-const SIDEBAR_KEYBOARD_SHORTCUT = "b";
-
-type SidebarContextProps = {
- state: "expanded" | "collapsed";
- open: boolean;
- setOpen: (open: boolean) => void;
- openMobile: boolean;
- setOpenMobile: (open: boolean) => void;
- isMobile: boolean;
- toggleSidebar: () => void;
-};
-
-const SidebarContext = React.createContext<SidebarContextProps | null>(null);
-
-function useSidebar() {
- const context = React.useContext(SidebarContext);
- if (!context) {
- throw new Error("useSidebar must be used within a SidebarProvider.");
- }
-
- return context;
-}
-
-function SidebarProvider({
- defaultOpen = true,
- open: openProp,
- onOpenChange: setOpenProp,
- className,
- style,
- children,
- ...props
-}: React.ComponentProps<"div"> & {
- defaultOpen?: boolean;
- open?: boolean;
- onOpenChange?: (open: boolean) => void;
-}) {
- const isMobile = useIsMobile();
- const [openMobile, setOpenMobile] = React.useState(false);
-
- // This is the internal state of the sidebar.
- // We use openProp and setOpenProp for control from outside the component.
- const [_open, _setOpen] = React.useState(defaultOpen);
- const open = openProp ?? _open;
- const setOpen = React.useCallback(
- (value: boolean | ((value: boolean) => boolean)) => {
- const openState = typeof value === "function" ? value(open) : value;
- if (setOpenProp) {
- setOpenProp(openState);
- } else {
- _setOpen(openState);
- }
-
- // This sets the cookie to keep the sidebar state.
- document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
- },
- [setOpenProp, open],
- );
-
- // Helper to toggle the sidebar.
- const toggleSidebar = React.useCallback(() => {
- return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
- }, [isMobile, setOpen, setOpenMobile]);
-
- // Adds a keyboard shortcut to toggle the sidebar.
- React.useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent) => {
- if (
- event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
- (event.metaKey || event.ctrlKey)
- ) {
- event.preventDefault();
- toggleSidebar();
- }
- };
-
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [toggleSidebar]);
-
- // We add a state so that we can do data-state="expanded" or "collapsed".
- // This makes it easier to style the sidebar with Tailwind classes.
- const state = open ? "expanded" : "collapsed";
-
- const contextValue = React.useMemo<SidebarContextProps>(
- () => ({
- state,
- open,
- setOpen,
- isMobile,
- openMobile,
- setOpenMobile,
- toggleSidebar,
- }),
- [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
- );
-
- return (
- <SidebarContext.Provider value={contextValue}>
- <TooltipProvider delayDuration={0}>
- <div
- data-slot="sidebar-wrapper"
- style={
- {
- "--sidebar-width": SIDEBAR_WIDTH,
- "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
- ...style,
- } as React.CSSProperties
- }
- className={cn(
- "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
- className,
- )}
- {...props}
- >
- {children}
- </div>
- </TooltipProvider>
- </SidebarContext.Provider>
- );
-}
-
-function Sidebar({
- side = "left",
- variant = "sidebar",
- collapsible = "offcanvas",
- className,
- children,
- ...props
-}: React.ComponentProps<"div"> & {
- side?: "left" | "right";
- variant?: "sidebar" | "floating" | "inset";
- collapsible?: "offcanvas" | "icon" | "none";
-}) {
- const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
-
- if (collapsible === "none") {
- return (
- <div
- data-slot="sidebar"
- className={cn(
- "bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
- className,
- )}
- {...props}
- >
- {children}
- </div>
- );
- }
-
- if (isMobile) {
- return (
- <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
- <SheetContent
- data-sidebar="sidebar"
- data-slot="sidebar"
- data-mobile="true"
- className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
- style={
- {
- "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
- } as React.CSSProperties
- }
- side={side}
- >
- <SheetHeader className="sr-only">
- <SheetTitle>Sidebar</SheetTitle>
- <SheetDescription>Displays the mobile sidebar.</SheetDescription>
- </SheetHeader>
- <div className="flex size-full flex-col">{children}</div>
- </SheetContent>
- </Sheet>
- );
- }
-
- return (
- <div
- className="group peer text-sidebar-foreground hidden md:block"
- data-state={state}
- data-collapsible={state === "collapsed" ? collapsible : ""}
- data-variant={variant}
- data-side={side}
- data-slot="sidebar"
- >
- {/* This is what handles the sidebar gap on desktop */}
- <div
- data-slot="sidebar-gap"
- className={cn(
- "relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
- "group-data-[collapsible=offcanvas]:w-0",
- "group-data-[side=right]:rotate-180",
- variant === "floating" || variant === "inset"
- ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
- : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
- )}
- />
- <div
- data-slot="sidebar-container"
- className={cn(
- "fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
- side === "left"
- ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
- : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
- // Adjust the padding for floating and inset variants.
- variant === "floating" || variant === "inset"
- ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
- : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
- className,
- )}
- {...props}
- >
- <div
- data-sidebar="sidebar"
- data-slot="sidebar-inner"
- className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex size-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
- >
- {children}
- </div>
- </div>
- </div>
- );
-}
-
-function SidebarTrigger({
- className,
- onClick,
- ...props
-}: React.ComponentProps<typeof Button>) {
- const { toggleSidebar } = useSidebar();
-
- return (
- <Button
- data-sidebar="trigger"
- data-slot="sidebar-trigger"
- variant="text"
- size="icon"
- className={cn("size-7", className)}
- onClick={(event) => {
- onClick?.(event);
- toggleSidebar();
- }}
- {...props}
- >
- <PanelLeftIcon />
- <span className="sr-only">Toggle Sidebar</span>
- </Button>
- );
-}
-
-function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
- const { toggleSidebar } = useSidebar();
-
- return (
- <button
- data-sidebar="rail"
- data-slot="sidebar-rail"
- aria-label="Toggle Sidebar"
- tabIndex={-1}
- onClick={toggleSidebar}
- title="Toggle Sidebar"
- className={cn(
- "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
- "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
- "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
- "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
- "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
- "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
- className,
- )}
- {...props}
- />
- );
-}
-
-function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
- return (
- <main
- data-slot="sidebar-inset"
- className={cn(
- "bg-background relative flex w-full flex-1 flex-col",
- "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
- className,
- )}
- {...props}
- />
- );
-}
-
-function SidebarInput({
- className,
- ...props
-}: React.ComponentProps<typeof Input>) {
- return (
- <Input
- data-slot="sidebar-input"
- data-sidebar="input"
- className={cn("bg-background h-8 w-full shadow-none", className)}
- {...props}
- />
- );
-}
-
-function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
- return (
- <div
- data-slot="sidebar-header"
- data-sidebar="header"
- className={cn("flex flex-col gap-2 p-2", className)}
- {...props}
- />
- );
-}
-
-function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
- return (
- <div
- data-slot="sidebar-footer"
- data-sidebar="footer"
- className={cn("flex flex-col gap-2 p-2", className)}
- {...props}
- />
- );
-}
-
-function SidebarSeparator({
- className,
- ...props
-}: React.ComponentProps<typeof Separator>) {
- return (
- <Separator
- data-slot="sidebar-separator"
- data-sidebar="separator"
- className={cn("bg-sidebar-border mx-2 w-auto", className)}
- {...props}
- />
- );
-}
-
-function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
- return (
- <div
- data-slot="sidebar-content"
- data-sidebar="content"
- className={cn(
- "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
- className,
- )}
- {...props}
- />
- );
-}
-
-function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
- return (
- <div
- data-slot="sidebar-group"
- data-sidebar="group"
- className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
- {...props}
- />
- );
-}
-
-function SidebarGroupLabel({
- className,
- asChild = false,
- ...props
-}: React.ComponentProps<"div"> & { asChild?: boolean }) {
- const Comp = asChild ? Slot : "div";
-
- return (
- <Comp
- data-slot="sidebar-group-label"
- data-sidebar="group-label"
- className={cn(
- "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
- "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
- className,
- )}
- {...props}
- />
- );
-}
-
-function SidebarGroupAction({
- className,
- asChild = false,
- ...props
-}: React.ComponentProps<"button"> & { asChild?: boolean }) {
- const Comp = asChild ? Slot : "button";
-
- return (
- <Comp
- data-slot="sidebar-group-action"
- data-sidebar="group-action"
- className={cn(
- "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:absolute after:-inset-2 md:after:hidden",
- "group-data-[collapsible=icon]:hidden",
- className,
- )}
- {...props}
- />
- );
-}
-
-function SidebarGroupContent({
- className,
- ...props
-}: React.ComponentProps<"div">) {
- return (
- <div
- data-slot="sidebar-group-content"
- data-sidebar="group-content"
- className={cn("w-full text-sm", className)}
- {...props}
- />
- );
-}
-
-function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
- return (
- <ul
- data-slot="sidebar-menu"
- data-sidebar="menu"
- className={cn("flex w-full min-w-0 flex-col gap-1", className)}
- {...props}
- />
- );
-}
-
-function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
- return (
- <li
- data-slot="sidebar-menu-item"
- data-sidebar="menu-item"
- className={cn("group/menu-item relative", className)}
- {...props}
- />
- );
-}
-
-const sidebarMenuButtonVariants = cva(
- "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
- {
- variants: {
- variant: {
- default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
- outline:
- "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
- },
- size: {
- default: "h-8 text-sm",
- sm: "h-7 text-xs",
- lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- },
-);
-
-function SidebarMenuButton({
- asChild = false,
- isActive = false,
- variant = "default",
- size = "default",
- tooltip,
- className,
- ...props
-}: React.ComponentProps<"button"> & {
- asChild?: boolean;
- isActive?: boolean;
- tooltip?: string | React.ComponentProps<typeof TooltipContent>;
-} & VariantProps<typeof sidebarMenuButtonVariants>) {
- const Comp = asChild ? Slot : "button";
- const { isMobile, state } = useSidebar();
-
- const button = (
- <Comp
- data-slot="sidebar-menu-button"
- data-sidebar="menu-button"
- data-size={size}
- data-active={isActive}
- className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
- {...props}
- />
- );
-
- if (!tooltip) {
- return button;
- }
-
- if (typeof tooltip === "string") {
- tooltip = {
- children: tooltip,
- };
- }
-
- return (
- <Tooltip>
- <TooltipTrigger asChild>{button}</TooltipTrigger>
- <TooltipContent
- side="right"
- align="center"
- hidden={state !== "collapsed" || isMobile}
- {...tooltip}
- />
- </Tooltip>
- );
-}
-
-function SidebarMenuAction({
- className,
- asChild = false,
- showOnHover = false,
- ...props
-}: React.ComponentProps<"button"> & {
- asChild?: boolean;
- showOnHover?: boolean;
-}) {
- const Comp = asChild ? Slot : "button";
-
- return (
- <Comp
- data-slot="sidebar-menu-action"
- data-sidebar="menu-action"
- className={cn(
- "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
- // Increases the hit area of the button on mobile.
- "after:absolute after:-inset-2 md:after:hidden",
- "peer-data-[size=sm]/menu-button:top-1",
- "peer-data-[size=default]/menu-button:top-1.5",
- "peer-data-[size=lg]/menu-button:top-2.5",
- "group-data-[collapsible=icon]:hidden",
- showOnHover &&
- "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
- className,
- )}
- {...props}
- />
- );
-}
-
-function SidebarMenuBadge({
- className,
- ...props
-}: React.ComponentProps<"div">) {
- return (
- <div
- data-slot="sidebar-menu-badge"
- data-sidebar="menu-badge"
- className={cn(
- "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
- "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
- "peer-data-[size=sm]/menu-button:top-1",
- "peer-data-[size=default]/menu-button:top-1.5",
- "peer-data-[size=lg]/menu-button:top-2.5",
- "group-data-[collapsible=icon]:hidden",
- className,
- )}
- {...props}
- />
- );
-}
-
-function SidebarMenuSkeleton({
- className,
- showIcon = false,
- ...props
-}: React.ComponentProps<"div"> & {
- showIcon?: boolean;
-}) {
- // Random width between 50 to 90%.
- const width = React.useMemo(() => {
- return `${Math.floor(Math.random() * 40) + 50}%`;
- }, []);
-
- return (
- <div
- data-slot="sidebar-menu-skeleton"
- data-sidebar="menu-skeleton"
- className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
- {...props}
- >
- {showIcon && (
- <Skeleton
- className="size-4 rounded-md"
- data-sidebar="menu-skeleton-icon"
- />
- )}
- <Skeleton
- className="h-4 max-w-(--skeleton-width) flex-1"
- data-sidebar="menu-skeleton-text"
- style={
- {
- "--skeleton-width": width,
- } as React.CSSProperties
- }
- />
- </div>
- );
-}
-
-function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
- return (
- <ul
- data-slot="sidebar-menu-sub"
- data-sidebar="menu-sub"
- className={cn(
- "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
- "group-data-[collapsible=icon]:hidden",
- className,
- )}
- {...props}
- />
- );
-}
-
-function SidebarMenuSubItem({
- className,
- ...props
-}: React.ComponentProps<"li">) {
- return (
- <li
- data-slot="sidebar-menu-sub-item"
- data-sidebar="menu-sub-item"
- className={cn("group/menu-sub-item relative", className)}
- {...props}
- />
- );
-}
-
-function SidebarMenuSubButton({
- asChild = false,
- size = "md",
- isActive = false,
- className,
- ...props
-}: React.ComponentProps<"a"> & {
- asChild?: boolean;
- size?: "sm" | "md";
- isActive?: boolean;
-}) {
- const Comp = asChild ? Slot : "a";
-
- return (
- <Comp
- data-slot="sidebar-menu-sub-button"
- data-sidebar="menu-sub-button"
- data-size={size}
- data-active={isActive}
- className={cn(
- "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
- "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
- size === "sm" && "text-xs",
- size === "md" && "text-sm",
- "group-data-[collapsible=icon]:hidden",
- className,
- )}
- {...props}
- />
- );
-}
-
-export {
- Sidebar,
- SidebarContent,
- SidebarFooter,
- SidebarGroup,
- SidebarGroupAction,
- SidebarGroupContent,
- SidebarGroupLabel,
- SidebarHeader,
- SidebarInput,
- SidebarInset,
- SidebarMenu,
- SidebarMenuAction,
- SidebarMenuBadge,
- SidebarMenuButton,
- SidebarMenuItem,
- SidebarMenuSkeleton,
- SidebarMenuSub,
- SidebarMenuSubButton,
- SidebarMenuSubItem,
- SidebarProvider,
- SidebarRail,
- SidebarSeparator,
- SidebarTrigger,
- useSidebar,
-};
diff --git a/apps/web/src/components/ui/spinner.tsx b/apps/web/src/components/ui/spinner.tsx
index a46792f7..5546d539 100644
--- a/apps/web/src/components/ui/spinner.tsx
+++ b/apps/web/src/components/ui/spinner.tsx
@@ -3,15 +3,15 @@ import { Loading03Icon } from "@hugeicons/core-free-icons";
import { cn } from "@/utils/ui";
function Spinner({ className, ...props }: Omit<HugeiconsIconProps, "icon">) {
- return (
- <HugeiconsIcon
- icon={Loading03Icon}
- role="status"
- aria-label="Loading"
- className={cn("size-4 animate-spin", className)}
- {...props}
- />
- )
+ return (
+ <HugeiconsIcon
+ icon={Loading03Icon}
+ role="status"
+ aria-label="Loading"
+ className={cn("size-4 animate-spin", className)}
+ {...props}
+ />
+ );
}
-export { Spinner }
+export { Spinner };
diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx
index aa8b331a..dfc4cdfe 100644
--- a/apps/web/src/components/ui/split-button.tsx
+++ b/apps/web/src/components/ui/split-button.tsx
@@ -1,90 +1,90 @@
-import { Button, type ButtonProps } from "@/components/ui/button";
-import { Separator } from "@/components/ui/separator";
-import { type ReactNode, forwardRef } from "react";
-import { cn } from "@/utils/ui";
-
-interface SplitButtonProps {
- children: ReactNode;
- className?: string;
-}
-
-interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> {
- children: ReactNode;
-}
-
-const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
- ({ children, className, ...props }, ref) => {
- return (
- <div
- ref={ref}
- className={cn(
- "border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border",
- className,
- )}
- {...props}
- >
- {children}
- </div>
- );
- },
-);
-SplitButton.displayName = "SplitButton";
-
-const SplitButtonSide = forwardRef<
- HTMLButtonElement,
- SplitButtonSideProps & { paddingClass: string }
->(({ children, className, paddingClass, onClick, ...props }, ref) => {
- return (
- <Button
- ref={ref}
- variant="text"
- className={cn(
- "bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
- onClick
- ? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
- : "cursor-default select-text",
- paddingClass,
- className,
- )}
- onClick={onClick}
- {...props}
- >
- {typeof children === "string" ? (
- <span className="cursor-text font-normal">{children}</span>
- ) : (
- children
- )}
- </Button>
- );
-});
-SplitButtonSide.displayName = "SplitButtonSide";
-
-const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
- ({ ...props }, ref) => {
- return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />;
- },
-);
-SplitButtonLeft.displayName = "SplitButtonLeft";
-
-const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
- ({ ...props }, ref) => {
- return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />;
- },
-);
-SplitButtonRight.displayName = "SplitButtonRight";
-
-const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>(
- ({ className, ...props }, ref) => {
- return (
- <Separator
- ref={ref}
- orientation="vertical"
- className={cn("bg-foreground/15 h-full", className)}
- {...props}
- />
- );
- },
-);
-SplitButtonSeparator.displayName = "SplitButtonSeparator";
-
-export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };
+import { Button, type ButtonProps } from "@/components/ui/button";
+import { Separator } from "@/components/ui/separator";
+import { type ReactNode, forwardRef } from "react";
+import { cn } from "@/utils/ui";
+
+interface SplitButtonProps {
+ children: ReactNode;
+ className?: string;
+}
+
+interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> {
+ children: ReactNode;
+}
+
+const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
+ ({ children, className, ...props }, ref) => {
+ return (
+ <div
+ ref={ref}
+ className={cn(
+ "border-input bg-accent inline-flex h-7 overflow-hidden rounded-lg border",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+ </div>
+ );
+ },
+);
+SplitButton.displayName = "SplitButton";
+
+const SplitButtonSide = forwardRef<
+ HTMLButtonElement,
+ SplitButtonSideProps & { paddingClass: string }
+>(({ children, className, paddingClass, onClick, ...props }, ref) => {
+ return (
+ <Button
+ ref={ref}
+ variant="text"
+ className={cn(
+ "bg-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
+ onClick
+ ? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
+ : "cursor-default select-text",
+ paddingClass,
+ className,
+ )}
+ onClick={onClick}
+ {...props}
+ >
+ {typeof children === "string" ? (
+ <span className="cursor-text font-normal">{children}</span>
+ ) : (
+ children
+ )}
+ </Button>
+ );
+});
+SplitButtonSide.displayName = "SplitButtonSide";
+
+const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
+ ({ ...props }, ref) => {
+ return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />;
+ },
+);
+SplitButtonLeft.displayName = "SplitButtonLeft";
+
+const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
+ ({ ...props }, ref) => {
+ return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />;
+ },
+);
+SplitButtonRight.displayName = "SplitButtonRight";
+
+const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>(
+ ({ className, ...props }, ref) => {
+ return (
+ <Separator
+ ref={ref}
+ orientation="vertical"
+ className={cn("bg-foreground/15 h-full", className)}
+ {...props}
+ />
+ );
+ },
+);
+SplitButtonSeparator.displayName = "SplitButtonSeparator";
+
+export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };
diff --git a/apps/web/src/components/ui/textarea.tsx b/apps/web/src/components/ui/textarea.tsx
index ca60a809..2ae59697 100644
--- a/apps/web/src/components/ui/textarea.tsx
+++ b/apps/web/src/components/ui/textarea.tsx
@@ -9,8 +9,8 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
- "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent/50 flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
- "focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/10",
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
+ "focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-primary/10",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx
index 769ac360..067042e6 100644
--- a/apps/web/src/components/ui/tooltip.tsx
+++ b/apps/web/src/components/ui/tooltip.tsx
@@ -60,6 +60,7 @@ const TooltipContent = React.forwardRef<
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="absolute top-1/2 left-[-6px] -translate-y-1/2"
+ aria-hidden="true"
>
<path
d="M6 0L0 5L6 10V0Z"
diff --git a/apps/web/src/constants/editor-constants.ts b/apps/web/src/constants/editor-constants.ts
index 213ea875..00e8f19b 100644
--- a/apps/web/src/constants/editor-constants.ts
+++ b/apps/web/src/constants/editor-constants.ts
@@ -1,9 +1,3 @@
-import type { TPlatformLayout } from "@/types/editor";
-
-export const PLATFORM_LAYOUTS: Record<TPlatformLayout, string> = {
- tiktok: "TikTok",
-};
-
export const PANEL_CONFIG = {
panels: {
tools: 25,
diff --git a/apps/web/src/constants/font-constants.ts b/apps/web/src/constants/font-constants.ts
index 46f84848..d115d09b 100644
--- a/apps/web/src/constants/font-constants.ts
+++ b/apps/web/src/constants/font-constants.ts
@@ -1,79 +1,13 @@
-export interface FontOption {
- value: string;
- label: string;
- category: "system" | "google" | "custom";
- weights?: number[];
- hasClassName?: boolean;
-}
-
-export const FONT_OPTIONS: FontOption[] = [
- // System fonts (always available)
- { value: "Arial", label: "Arial", category: "system", hasClassName: false },
- {
- value: "Helvetica",
- label: "Helvetica",
- category: "system",
- hasClassName: false,
- },
- {
- value: "Times New Roman",
- label: "Times New Roman",
- category: "system",
- hasClassName: false,
- },
- {
- value: "Georgia",
- label: "Georgia",
- category: "system",
- hasClassName: false,
- },
-
- // Google Fonts (loaded in layout.tsx)
- {
- value: "Inter",
- label: "Inter",
- category: "google",
- weights: [400, 700],
- hasClassName: true,
- },
- {
- value: "Roboto",
- label: "Roboto",
- category: "google",
- weights: [400, 700],
- hasClassName: true,
- },
- {
- value: "Open Sans",
- label: "Open Sans",
- category: "google",
- hasClassName: true,
- },
- {
- value: "Playfair Display",
- label: "Playfair Display",
- category: "google",
- hasClassName: true,
- },
- {
- value: "Comic Neue",
- label: "Comic Neue",
- category: "google",
- hasClassName: false,
- },
-] as const;
-
export const DEFAULT_FONT = "Arial";
-// Type-safe font family union
-export type FontFamily = (typeof FONT_OPTIONS)[number]["value"];
-
-// Helper functions
-export const getFontByValue = (value: string): FontOption | undefined =>
- FONT_OPTIONS.find((font) => font.value === value);
-
-export const getGoogleFonts = (): FontOption[] =>
- FONT_OPTIONS.filter((font) => font.category === "google");
-
-export const getSystemFonts = (): FontOption[] =>
- FONT_OPTIONS.filter((font) => font.category === "system");
+export const SYSTEM_FONTS = new Set([
+ "Arial",
+ "Helvetica",
+ "Times New Roman",
+ "Courier New",
+ "Verdana",
+ "Georgia",
+ "monospace",
+ "sans-serif",
+ "serif",
+]);
diff --git a/apps/web/src/constants/site-constants.ts b/apps/web/src/constants/site-constants.ts
index ac6cd2cd..10981727 100644
--- a/apps/web/src/constants/site-constants.ts
+++ b/apps/web/src/constants/site-constants.ts
@@ -1,4 +1,4 @@
-import { OcDataBuddyIcon, OcMarbleIcon, } from "@opencut/ui/icons";
+import { OcDataBuddyIcon, OcMarbleIcon } from "@opencut/ui/icons";
export const SITE_URL = "https://opencut.app";
diff --git a/apps/web/src/constants/sticker-constants.ts b/apps/web/src/constants/sticker-constants.ts
new file mode 100644
index 00000000..506e552d
--- /dev/null
+++ b/apps/web/src/constants/sticker-constants.ts
@@ -0,0 +1,8 @@
+export const STICKER_CATEGORIES = {
+ all: "All",
+ logos: "Logos",
+ icons: "Icons",
+ emoji: "Emoji",
+ flags: "Flags",
+ shapes: "Shapes",
+};
diff --git a/apps/web/src/constants/stickers-constants.ts b/apps/web/src/constants/stickers-constants.ts
deleted file mode 100644
index 3c98d4f7..00000000
--- a/apps/web/src/constants/stickers-constants.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-export const STICKER_CATEGORIES = [
- "all",
- "general",
- "brands",
- "emoji",
-] as const;
-
-export const STICKER_CATEGORY_CONFIG: Record<
- (typeof STICKER_CATEGORIES)[number],
- string | undefined
-> = {
- all: undefined,
- general: "General",
- brands: "Brands / Social",
- emoji: "Emoji",
-};
diff --git a/apps/web/src/constants/text-constants.ts b/apps/web/src/constants/text-constants.ts
index 83dd251f..f0b2143d 100644
--- a/apps/web/src/constants/text-constants.ts
+++ b/apps/web/src/constants/text-constants.ts
@@ -1,38 +1,40 @@
-import type { TextElement } from "@/types/timeline";
-import { TIMELINE_CONSTANTS } from "./timeline-constants";
-
-export const MIN_FONT_SIZE = 5;
-export const MAX_FONT_SIZE = 300;
-
-/**
- * higher value: smaller font size
- * lower value: larger font size
- */
-export const FONT_SIZE_SCALE_REFERENCE = 90;
-
-export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
- type: "text",
- name: "Text",
- content: "Default text",
- fontSize: 15,
- fontFamily: "Arial",
- color: "#ffffff",
- backgroundColor: "transparent",
- textAlign: "center",
- fontWeight: "normal",
- fontStyle: "normal",
- textDecoration: "none",
- duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
- startTime: 0,
- trimStart: 0,
- trimEnd: 0,
- transform: {
- scale: 1,
- position: {
- x: 0,
- y: 0,
- },
- rotate: 0,
- },
- opacity: 1,
-};
+import type { TextElement } from "@/types/timeline";
+import {
+ DEFAULT_OPACITY,
+ DEFAULT_TRANSFORM,
+ TIMELINE_CONSTANTS,
+} from "./timeline-constants";
+
+export const MIN_FONT_SIZE = 5;
+export const MAX_FONT_SIZE = 300;
+
+/**
+ * higher value: smaller font size
+ * lower value: larger font size
+ */
+export const FONT_SIZE_SCALE_REFERENCE = 90;
+
+export const DEFAULT_LETTER_SPACING = 0;
+export const DEFAULT_LINE_HEIGHT = 1.2;
+
+export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
+ type: "text",
+ name: "Text",
+ content: "Default text",
+ fontSize: 15,
+ fontFamily: "Arial",
+ color: "#ffffff",
+ backgroundColor: "#000000",
+ textAlign: "center",
+ fontWeight: "normal",
+ fontStyle: "normal",
+ textDecoration: "none",
+ letterSpacing: DEFAULT_LETTER_SPACING,
+ lineHeight: DEFAULT_LINE_HEIGHT,
+ duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: DEFAULT_TRANSFORM,
+ opacity: DEFAULT_OPACITY,
+};
diff --git a/apps/web/src/constants/timeline-constants.tsx b/apps/web/src/constants/timeline-constants.tsx
index d6af3c3a..5ac0913a 100644
--- a/apps/web/src/constants/timeline-constants.tsx
+++ b/apps/web/src/constants/timeline-constants.tsx
@@ -1,5 +1,6 @@
import type { TTimelineViewState } from "@/types/project";
-import type { TrackType } from "@/types/timeline";
+import type { BlendMode } from "@/types/rendering";
+import type { TrackType, Transform } from "@/types/timeline";
import {
Happy01Icon,
MusicNote03Icon,
@@ -8,6 +9,16 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { OcVideoIcon } from "@opencut/ui/icons";
+export const DEFAULT_TRANSFORM: Transform = {
+ scale: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+};
+
+export const DEFAULT_OPACITY = 1;
+export const DEFAULT_BLEND_MODE: BlendMode = "normal";
+export const DEFAULT_BOOKMARK_COLOR = "#009dff";
+
export const TRACK_COLORS: Record<TrackType, { background: string }> = {
video: {
background: "transparent",
@@ -32,6 +43,8 @@ export const TRACK_HEIGHTS: Record<TrackType, number> = {
export const TRACK_GAP = 4;
+export const DRAG_THRESHOLD_PX = 5;
+
export const TIMELINE_CONSTANTS = {
PIXELS_PER_SECOND: 50,
DEFAULT_ELEMENT_DURATION: 5,
diff --git a/apps/web/src/core/managers/audio-manager.ts b/apps/web/src/core/managers/audio-manager.ts
index 3a83be48..457e49d6 100644
--- a/apps/web/src/core/managers/audio-manager.ts
+++ b/apps/web/src/core/managers/audio-manager.ts
@@ -125,7 +125,8 @@ export class AudioManager {
private getPlaybackTime(): number {
if (!this.audioContext) return this.playbackStartTime;
- const elapsed = this.audioContext.currentTime - this.playbackStartContextTime;
+ const elapsed =
+ this.audioContext.currentTime - this.playbackStartContextTime;
return this.playbackStartTime + elapsed;
}
@@ -176,7 +177,11 @@ export class AudioManager {
if (clip.startTime > windowEnd) continue;
this.activeClipIds.add(clip.id);
- void this.runClipIterator({ clip, startTime: currentTime, sessionId: this.playbackSessionId });
+ void this.runClipIterator({
+ clip,
+ startTime: currentTime,
+ sessionId: this.playbackSessionId,
+ });
}
}
@@ -239,8 +244,7 @@ export class AudioManager {
node.connect(this.masterGain ?? audioContext.destination);
const startTimestamp =
- this.playbackStartContextTime +
- (timelineTime - this.playbackStartTime);
+ this.playbackStartContextTime + (timelineTime - this.playbackStartTime);
if (startTimestamp >= audioContext.currentTime) {
node.start(startTimestamp);
diff --git a/apps/web/src/core/managers/commands.ts b/apps/web/src/core/managers/commands.ts
index d996a30e..46ee643a 100644
--- a/apps/web/src/core/managers/commands.ts
+++ b/apps/web/src/core/managers/commands.ts
@@ -11,6 +11,11 @@ export class CommandManager {
return command;
}
+ push({ command }: { command: Command }): void {
+ this.history.push(command);
+ this.redoStack = [];
+ }
+
undo(): void {
if (this.history.length === 0) return;
const command = this.history.pop();
diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts
index c7497ef0..2608b8c0 100644
--- a/apps/web/src/core/managers/project-manager.ts
+++ b/apps/web/src/core/managers/project-manager.ts
@@ -27,6 +27,8 @@ import {
type MigrationProgress,
} from "@/services/storage/migrations";
import { DEFAULT_TIMELINE_VIEW_STATE } from "@/constants/timeline-constants";
+import { loadFonts } from "@/lib/fonts/google-fonts";
+import { collectFontFamilies } from "@/lib/timeline/element-utils";
export interface MigrationState {
isMigrating: boolean;
@@ -146,6 +148,9 @@ export class ProjectManager {
await this.editor.media.loadProjectMedia({ projectId: id });
+ const allTracks = (project.scenes ?? []).flatMap((scene) => scene.tracks);
+ await loadFonts({ families: collectFontFamilies({ tracks: allTracks }) });
+
if (!project.metadata.thumbnail) {
const didUpdateThumbnail = await this.updateThumbnailFromTimeline();
if (didUpdateThumbnail) {
diff --git a/apps/web/src/core/managers/scenes-manager.ts b/apps/web/src/core/managers/scenes-manager.ts
index 8c761f9b..c844f448 100644
--- a/apps/web/src/core/managers/scenes-manager.ts
+++ b/apps/web/src/core/managers/scenes-manager.ts
@@ -7,14 +7,20 @@ import {
canDeleteScene,
findCurrentScene,
} from "@/lib/scenes";
-import { getFrameTime, isBookmarkAtTime } from "@/lib/timeline/bookmarks";
+import {
+ getBookmarkAtTime,
+ getFrameTime,
+ isBookmarkAtTime,
+} from "@/lib/timeline/bookmarks";
import { ensureMainTrack } from "@/lib/timeline/track-utils";
import {
CreateSceneCommand,
DeleteSceneCommand,
+ MoveBookmarkCommand,
RemoveBookmarkCommand,
RenameSceneCommand,
ToggleBookmarkCommand,
+ UpdateBookmarkCommand,
} from "@/lib/commands/scene";
export class ScenesManager {
@@ -125,6 +131,45 @@ export class ScenesManager {
this.editor.command.execute({ command });
}
+ async updateBookmark({
+ time,
+ updates,
+ }: {
+ time: number;
+ updates: Partial<{ note: string; color: string; duration: number }>;
+ }): Promise<void> {
+ const command = new UpdateBookmarkCommand(time, updates);
+ this.editor.command.execute({ command });
+ }
+
+ async moveBookmark({
+ fromTime,
+ toTime,
+ }: {
+ fromTime: number;
+ toTime: number;
+ }): Promise<void> {
+ const command = new MoveBookmarkCommand(fromTime, toTime);
+ this.editor.command.execute({ command });
+ }
+
+ getBookmarkAtTime({ time }: { time: number }) {
+ const activeScene = this.active;
+ const activeProject = this.editor.project.getActive();
+
+ if (!activeScene || !activeProject) return null;
+
+ const frameTime = getFrameTime({
+ time,
+ fps: activeProject.settings.fps,
+ });
+
+ return getBookmarkAtTime({
+ bookmarks: activeScene.bookmarks,
+ frameTime,
+ });
+ }
+
async loadProjectScenes({ projectId }: { projectId: string }): Promise<void> {
try {
const result = await storageService.loadProject({ id: projectId });
diff --git a/apps/web/src/core/managers/timeline-manager.ts b/apps/web/src/core/managers/timeline-manager.ts
index ad9f7792..c8ac5a79 100644
--- a/apps/web/src/core/managers/timeline-manager.ts
+++ b/apps/web/src/core/managers/timeline-manager.ts
@@ -23,12 +23,14 @@ import {
PasteCommand,
UpdateElementStartTimeCommand,
MoveElementCommand,
+ TracksSnapshotCommand,
} from "@/lib/commands/timeline";
-import { BatchCommand } from "@/lib/commands";
+import { BatchCommand, PreviewTracker } from "@/lib/commands";
import type { InsertElementParams } from "@/lib/commands/timeline/element/insert-element";
export class TimelineManager {
private listeners = new Set<() => void>();
+ private previewTracker = new PreviewTracker<TimelineTrack[]>();
constructor(private editor: EditorCore) {}
@@ -214,7 +216,8 @@ export class TimelineManager {
({ trackId, elementId, updates: elementUpdates }) =>
new UpdateElementCommand(trackId, elementId, elementUpdates),
);
- const command = commands.length === 1 ? commands[0] : new BatchCommand(commands);
+ const command =
+ commands.length === 1 ? commands[0] : new BatchCommand(commands);
if (pushHistory) {
this.editor.command.execute({ command });
} else {
@@ -222,6 +225,52 @@ export class TimelineManager {
}
}
+ isPreviewActive(): boolean {
+ return this.previewTracker.isActive();
+ }
+
+ previewElements({
+ updates,
+ }: {
+ updates: Array<{
+ trackId: string;
+ elementId: string;
+ updates: Partial<Record<string, unknown>>;
+ }>;
+ }): void {
+ const tracks = this.getTracks();
+ this.previewTracker.begin({ state: tracks });
+
+ let updatedTracks = tracks;
+ for (const { trackId, elementId, updates: elementUpdates } of updates) {
+ updatedTracks = updatedTracks.map((track) => {
+ if (track.id !== trackId) return track;
+ const newElements = track.elements.map((element) =>
+ element.id === elementId
+ ? { ...element, ...elementUpdates }
+ : element,
+ );
+ return { ...track, elements: newElements } as TimelineTrack;
+ });
+ }
+ this.updateTracks(updatedTracks);
+ }
+
+ commitPreview(): void {
+ const snapshot = this.previewTracker.end();
+ if (snapshot === null) return;
+ const currentTracks = this.getTracks();
+ const command = new TracksSnapshotCommand(snapshot, currentTracks);
+ this.editor.command.push({ command });
+ }
+
+ discardPreview(): void {
+ const snapshot = this.previewTracker.end();
+ if (snapshot !== null) {
+ this.updateTracks(snapshot);
+ }
+ }
+
duplicateElements({
elements,
}: {
diff --git a/apps/web/src/hooks/timeline/element/use-element-interaction.ts b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
index c92d6722..79f1272c 100644
--- a/apps/web/src/hooks/timeline/element/use-element-interaction.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-interaction.ts
@@ -7,10 +7,15 @@ import {
type RefObject,
} from "react";
import { useEditor } from "@/hooks/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ DRAG_THRESHOLD_PX,
+ TIMELINE_CONSTANTS,
+} from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
+import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
import { generateUUID } from "@/utils/id";
import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
import type {
@@ -21,8 +26,6 @@ import type {
} from "@/types/timeline";
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
-const DRAG_THRESHOLD_PX = 5;
-
interface UseElementInteractionProps {
zoomLevel: number;
timelineRef: RefObject<HTMLDivElement | null>;
@@ -54,24 +57,6 @@ interface PendingDragState {
clickOffsetTime: number;
}
-function getMouseTimeFromClientX({
- clientX,
- containerRect,
- zoomLevel,
- scrollLeft,
-}: {
- clientX: number;
- containerRect: DOMRect;
- zoomLevel: number;
- scrollLeft: number;
-}): number {
- const mouseX = clientX - containerRect.left + scrollLeft;
- return Math.max(
- 0,
- mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
- );
-}
-
function getClickOffsetTime({
clientX,
elementRect,
@@ -175,6 +160,7 @@ export function useElementInteraction({
onSnapPointChange,
}: UseElementInteractionProps) {
const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
const tracks = editor.timeline.getTracks();
const { snapElementEdge } = useTimelineSnapping();
const {
@@ -230,7 +216,8 @@ export function useElementInteraction({
frameSnappedTime: number;
movingElement: TimelineElement | null | undefined;
}) => {
- if (!snappingEnabled || !movingElement) {
+ const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
+ if (!shouldSnap || !movingElement) {
return { snappedTime: frameSnappedTime, snapPoint: null };
}
@@ -268,7 +255,14 @@ export function useElementInteraction({
snapPoint: snapResult.snapPoint,
};
},
- [snappingEnabled, editor.playback, snapElementEdge, tracks, zoomLevel],
+ [
+ snappingEnabled,
+ editor.playback,
+ snapElementEdge,
+ tracks,
+ zoomLevel,
+ isShiftHeldRef,
+ ],
);
useEffect(() => {
diff --git a/apps/web/src/hooks/timeline/element/use-element-resize.ts b/apps/web/src/hooks/timeline/element/use-element-resize.ts
index 803d9788..14693fff 100644
--- a/apps/web/src/hooks/timeline/element/use-element-resize.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-resize.ts
@@ -1,7 +1,9 @@
import { useState, useEffect, useRef, useCallback } from "react";
-import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
import { snapTimeToFrame } from "@/lib/time";
-import { EditorCore } from "@/core";
+import type { TimelineElement, TimelineTrack } from "@/types/timeline";
+import { useEditor } from "@/hooks/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
import {
useTimelineSnapping,
type SnapPoint,
@@ -33,8 +35,9 @@ export function useTimelineElementResize({
onSnapPointChange,
onResizeStateChange,
}: UseTimelineElementResizeProps) {
- const editor = EditorCore.getInstance();
+ const editor = useEditor();
const activeProject = editor.project.getActive();
+ const isShiftHeldRef = useShiftKey();
const snappingEnabled = useTimelineStore((state) => state.snappingEnabled);
const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
@@ -49,21 +52,21 @@ export function useTimelineElementResize({
const currentDurationRef = useRef(element.duration);
const handleResizeStart = ({
- e,
+ event,
elementId,
side,
}: {
- e: React.MouseEvent;
+ event: React.MouseEvent;
elementId: string;
side: "left" | "right";
}) => {
- e.stopPropagation();
- e.preventDefault();
+ event.stopPropagation();
+ event.preventDefault();
setResizing({
elementId,
side,
- startX: e.clientX,
+ startX: event.clientX,
initialTrimStart: element.trimStart,
initialTrimEnd: element.trimEnd,
initialStartTime: element.startTime,
@@ -89,104 +92,69 @@ export function useTimelineElementResize({
return false;
}, [element.type]);
- const updateTrimFromMouseMove = useCallback(({ clientX }: { clientX: number }) => {
- if (!resizing) return;
+ const updateTrimFromMouseMove = useCallback(
+ ({ clientX }: { clientX: number }) => {
+ if (!resizing) return;
- const deltaX = clientX - resizing.startX;
- let deltaTime = deltaX / (50 * zoomLevel);
- let resizeSnapPoint: SnapPoint | null = null;
+ const deltaX = clientX - resizing.startX;
+ let deltaTime =
+ deltaX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel);
+ let resizeSnapPoint: SnapPoint | null = null;
- const projectFps = activeProject.settings.fps;
- const minDurationSeconds = 1 / projectFps;
- const canSnap = snappingEnabled;
- if (canSnap) {
- const tracks = editor.timeline.getTracks();
- const playheadTime = editor.playback.getCurrentTime();
- const snapPoints = findSnapPoints({
- tracks,
- playheadTime,
- excludeElementId: element.id,
- });
- if (resizing.side === "left") {
- const targetStartTime = resizing.initialStartTime + deltaTime;
- const snapResult = snapToNearestPoint({
- targetTime: targetStartTime,
- snapPoints,
- zoomLevel,
- });
- resizeSnapPoint = snapResult.snapPoint;
- if (snapResult.snapPoint) {
- deltaTime = snapResult.snappedTime - resizing.initialStartTime;
- }
- } else {
- const baseEndTime =
- resizing.initialStartTime + resizing.initialDuration;
- const targetEndTime = baseEndTime + deltaTime;
- const snapResult = snapToNearestPoint({
- targetTime: targetEndTime,
- snapPoints,
- zoomLevel,
+ const projectFps = activeProject.settings.fps;
+ const minDurationSeconds = 1 / projectFps;
+ const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
+ if (shouldSnap) {
+ const tracks = editor.timeline.getTracks();
+ const playheadTime = editor.playback.getCurrentTime();
+ const snapPoints = findSnapPoints({
+ tracks,
+ playheadTime,
+ excludeElementId: element.id,
});
- resizeSnapPoint = snapResult.snapPoint;
- if (snapResult.snapPoint) {
- deltaTime = snapResult.snappedTime - baseEndTime;
+ if (resizing.side === "left") {
+ const targetStartTime = resizing.initialStartTime + deltaTime;
+ const snapResult = snapToNearestPoint({
+ targetTime: targetStartTime,
+ snapPoints,
+ zoomLevel,
+ });
+ resizeSnapPoint = snapResult.snapPoint;
+ if (snapResult.snapPoint) {
+ deltaTime = snapResult.snappedTime - resizing.initialStartTime;
+ }
+ } else {
+ const baseEndTime =
+ resizing.initialStartTime + resizing.initialDuration;
+ const targetEndTime = baseEndTime + deltaTime;
+ const snapResult = snapToNearestPoint({
+ targetTime: targetEndTime,
+ snapPoints,
+ zoomLevel,
+ });
+ resizeSnapPoint = snapResult.snapPoint;
+ if (snapResult.snapPoint) {
+ deltaTime = snapResult.snappedTime - baseEndTime;
+ }
}
}
- }
- onSnapPointChange?.(resizeSnapPoint);
+ onSnapPointChange?.(resizeSnapPoint);
- if (resizing.side === "left") {
- const sourceDuration =
- resizing.initialTrimStart +
- resizing.initialDuration +
- resizing.initialTrimEnd;
- const maxAllowed =
- sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
- const calculated = resizing.initialTrimStart + deltaTime;
-
- if (calculated >= 0 && calculated <= maxAllowed) {
- const newTrimStart = snapTimeToFrame({
- time: Math.min(maxAllowed, calculated),
- fps: projectFps,
- });
- const trimDelta = newTrimStart - resizing.initialTrimStart;
- const newStartTime = snapTimeToFrame({
- time: resizing.initialStartTime + trimDelta,
- fps: projectFps,
- });
- const newDuration = snapTimeToFrame({
- time: resizing.initialDuration - trimDelta,
- fps: projectFps,
- });
+ if (resizing.side === "left") {
+ const sourceDuration =
+ resizing.initialTrimStart +
+ resizing.initialDuration +
+ resizing.initialTrimEnd;
+ const maxAllowed =
+ sourceDuration - resizing.initialTrimEnd - minDurationSeconds;
+ const calculated = resizing.initialTrimStart + deltaTime;
- setCurrentTrimStart(newTrimStart);
- setCurrentStartTime(newStartTime);
- setCurrentDuration(newDuration);
- currentTrimStartRef.current = newTrimStart;
- currentStartTimeRef.current = newStartTime;
- currentDurationRef.current = newDuration;
- } else if (calculated < 0) {
- if (canExtendElementDuration()) {
- const extensionAmount = Math.abs(calculated);
- const maxExtension = resizing.initialStartTime;
- const actualExtension = Math.min(extensionAmount, maxExtension);
- const newStartTime = snapTimeToFrame({
- time: resizing.initialStartTime - actualExtension,
+ if (calculated >= 0 && calculated <= maxAllowed) {
+ const newTrimStart = snapTimeToFrame({
+ time: Math.min(maxAllowed, calculated),
fps: projectFps,
});
- const newDuration = snapTimeToFrame({
- time: resizing.initialDuration + actualExtension,
- fps: projectFps,
- });
-
- setCurrentTrimStart(0);
- setCurrentStartTime(newStartTime);
- setCurrentDuration(newDuration);
- currentTrimStartRef.current = 0;
- currentStartTimeRef.current = newStartTime;
- currentDurationRef.current = newDuration;
- } else {
- const trimDelta = 0 - resizing.initialTrimStart;
+ const trimDelta = newTrimStart - resizing.initialTrimStart;
const newStartTime = snapTimeToFrame({
time: resizing.initialStartTime + trimDelta,
fps: projectFps,
@@ -196,68 +164,119 @@ export function useTimelineElementResize({
fps: projectFps,
});
- setCurrentTrimStart(0);
+ setCurrentTrimStart(newTrimStart);
setCurrentStartTime(newStartTime);
setCurrentDuration(newDuration);
- currentTrimStartRef.current = 0;
+ currentTrimStartRef.current = newTrimStart;
currentStartTimeRef.current = newStartTime;
currentDurationRef.current = newDuration;
+ } else if (calculated < 0) {
+ if (canExtendElementDuration()) {
+ const extensionAmount = Math.abs(calculated);
+ const maxExtension = resizing.initialStartTime;
+ const actualExtension = Math.min(extensionAmount, maxExtension);
+ const newStartTime = snapTimeToFrame({
+ time: resizing.initialStartTime - actualExtension,
+ fps: projectFps,
+ });
+ const newDuration = snapTimeToFrame({
+ time: resizing.initialDuration + actualExtension,
+ fps: projectFps,
+ });
+
+ setCurrentTrimStart(0);
+ setCurrentStartTime(newStartTime);
+ setCurrentDuration(newDuration);
+ currentTrimStartRef.current = 0;
+ currentStartTimeRef.current = newStartTime;
+ currentDurationRef.current = newDuration;
+ } else {
+ const trimDelta = 0 - resizing.initialTrimStart;
+ const newStartTime = snapTimeToFrame({
+ time: resizing.initialStartTime + trimDelta,
+ fps: projectFps,
+ });
+ const newDuration = snapTimeToFrame({
+ time: resizing.initialDuration - trimDelta,
+ fps: projectFps,
+ });
+
+ setCurrentTrimStart(0);
+ setCurrentStartTime(newStartTime);
+ setCurrentDuration(newDuration);
+ currentTrimStartRef.current = 0;
+ currentStartTimeRef.current = newStartTime;
+ currentDurationRef.current = newDuration;
+ }
}
- }
- } else {
- const sourceDuration =
- resizing.initialTrimStart +
- resizing.initialDuration +
- resizing.initialTrimEnd;
- const newTrimEnd = resizing.initialTrimEnd - deltaTime;
+ } else {
+ const sourceDuration =
+ resizing.initialTrimStart +
+ resizing.initialDuration +
+ resizing.initialTrimEnd;
+ const newTrimEnd = resizing.initialTrimEnd - deltaTime;
- if (newTrimEnd < 0) {
- if (canExtendElementDuration()) {
- const extensionNeeded = Math.abs(newTrimEnd);
- const baseDuration =
- resizing.initialDuration + resizing.initialTrimEnd;
- const newDuration = snapTimeToFrame({
- time: baseDuration + extensionNeeded,
- fps: projectFps,
- });
+ if (newTrimEnd < 0) {
+ if (canExtendElementDuration()) {
+ const extensionNeeded = Math.abs(newTrimEnd);
+ const baseDuration =
+ resizing.initialDuration + resizing.initialTrimEnd;
+ const newDuration = snapTimeToFrame({
+ time: baseDuration + extensionNeeded,
+ fps: projectFps,
+ });
- setCurrentDuration(newDuration);
- setCurrentTrimEnd(0);
- currentDurationRef.current = newDuration;
- currentTrimEndRef.current = 0;
+ setCurrentDuration(newDuration);
+ setCurrentTrimEnd(0);
+ currentDurationRef.current = newDuration;
+ currentTrimEndRef.current = 0;
+ } else {
+ const extensionToLimit = resizing.initialTrimEnd;
+ const newDuration = snapTimeToFrame({
+ time: resizing.initialDuration + extensionToLimit,
+ fps: projectFps,
+ });
+
+ setCurrentDuration(newDuration);
+ setCurrentTrimEnd(0);
+ currentDurationRef.current = newDuration;
+ currentTrimEndRef.current = 0;
+ }
} else {
- const extensionToLimit = resizing.initialTrimEnd;
+ const maxTrimEnd =
+ sourceDuration - resizing.initialTrimStart - minDurationSeconds;
+ const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
+ const finalTrimEnd = snapTimeToFrame({
+ time: clampedTrimEnd,
+ fps: projectFps,
+ });
+ const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
const newDuration = snapTimeToFrame({
- time: resizing.initialDuration + extensionToLimit,
+ time: resizing.initialDuration - trimDelta,
fps: projectFps,
});
+ setCurrentTrimEnd(finalTrimEnd);
setCurrentDuration(newDuration);
- setCurrentTrimEnd(0);
+ currentTrimEndRef.current = finalTrimEnd;
currentDurationRef.current = newDuration;
- currentTrimEndRef.current = 0;
}
- } else {
- const maxTrimEnd =
- sourceDuration - resizing.initialTrimStart - minDurationSeconds;
- const clampedTrimEnd = Math.min(maxTrimEnd, Math.max(0, newTrimEnd));
- const finalTrimEnd = snapTimeToFrame({
- time: clampedTrimEnd,
- fps: projectFps,
- });
- const trimDelta = finalTrimEnd - resizing.initialTrimEnd;
- const newDuration = snapTimeToFrame({
- time: resizing.initialDuration - trimDelta,
- fps: projectFps,
- });
-
- setCurrentTrimEnd(finalTrimEnd);
- setCurrentDuration(newDuration);
- currentTrimEndRef.current = finalTrimEnd;
- currentDurationRef.current = newDuration;
}
- }
- }, [resizing, zoomLevel, activeProject.settings.fps, snappingEnabled, editor, findSnapPoints, snapToNearestPoint, element.id, onSnapPointChange, canExtendElementDuration]);
+ },
+ [
+ resizing,
+ zoomLevel,
+ activeProject.settings.fps,
+ snappingEnabled,
+ editor,
+ findSnapPoints,
+ snapToNearestPoint,
+ element.id,
+ onSnapPointChange,
+ canExtendElementDuration,
+ isShiftHeldRef,
+ ],
+ );
const handleResizeEnd = useCallback(() => {
if (!resizing) return;
@@ -297,7 +316,14 @@ export function useTimelineElementResize({
setResizing(null);
onResizeStateChange?.({ isResizing: false });
onSnapPointChange?.(null);
- }, [resizing, editor.timeline, element.id, track.id, onResizeStateChange, onSnapPointChange]);
+ }, [
+ resizing,
+ editor.timeline,
+ element.id,
+ track.id,
+ onResizeStateChange,
+ onSnapPointChange,
+ ]);
useEffect(() => {
if (!resizing) return;
diff --git a/apps/web/src/hooks/timeline/element/use-element-selection.ts b/apps/web/src/hooks/timeline/element/use-element-selection.ts
index 38694c8b..c1e5b53f 100644
--- a/apps/web/src/hooks/timeline/element/use-element-selection.ts
+++ b/apps/web/src/hooks/timeline/element/use-element-selection.ts
@@ -21,7 +21,9 @@ export function useElementSelection() {
const selectElement = useCallback(
({ trackId, elementId }: ElementRef) => {
- editor.selection.setSelectedElements({ elements: [{ trackId, elementId }] });
+ editor.selection.setSelectedElements({
+ elements: [{ trackId, elementId }],
+ });
},
[editor],
);
diff --git a/apps/web/src/hooks/timeline/use-bookmark-drag.ts b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
new file mode 100644
index 00000000..faf28e40
--- /dev/null
+++ b/apps/web/src/hooks/timeline/use-bookmark-drag.ts
@@ -0,0 +1,289 @@
+import {
+ useState,
+ useCallback,
+ useEffect,
+ useRef,
+ type RefObject,
+} from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { DRAG_THRESHOLD_PX } from "@/constants/timeline-constants";
+import { snapTimeToFrame } from "@/lib/time";
+import { getMouseTimeFromClientX } from "@/lib/timeline/drag-utils";
+import { useTimelineSnapping } from "@/hooks/timeline/use-timeline-snapping";
+import type { Bookmark } from "@/types/timeline";
+import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
+
+export interface BookmarkDragState {
+ isDragging: boolean;
+ bookmarkTime: number | null;
+ currentTime: number;
+}
+
+interface PendingBookmarkDrag {
+ bookmarkTime: number;
+ startMouseX: number;
+ startMouseY: number;
+}
+
+interface UseBookmarkDragProps {
+ zoomLevel: number;
+ scrollRef: RefObject<HTMLElement | null>;
+ snappingEnabled: boolean;
+ onSnapPointChange?: (snapPoint: SnapPoint | null) => void;
+}
+
+export function useBookmarkDrag({
+ zoomLevel,
+ scrollRef,
+ snappingEnabled,
+ onSnapPointChange,
+}: UseBookmarkDragProps) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const tracks = editor.timeline.getTracks();
+ const activeScene = editor.scenes.getActiveScene();
+ const bookmarks = activeScene?.bookmarks ?? [];
+ const playheadTime = editor.playback.getCurrentTime();
+ const duration = editor.timeline.getTotalDuration();
+
+ const { findSnapPoints, snapToNearestPoint } = useTimelineSnapping();
+
+ const [dragState, setDragState] = useState<BookmarkDragState>({
+ isDragging: false,
+ bookmarkTime: null,
+ currentTime: 0,
+ });
+ const [isPendingDrag, setIsPendingDrag] = useState(false);
+ const pendingDragRef = useRef<PendingBookmarkDrag | null>(null);
+ const lastMouseXRef = useRef(0);
+
+ const startDrag = useCallback(
+ ({
+ bookmarkTime,
+ initialCurrentTime,
+ }: {
+ bookmarkTime: number;
+ initialCurrentTime: number;
+ }) => {
+ setDragState({
+ isDragging: true,
+ bookmarkTime,
+ currentTime: initialCurrentTime,
+ });
+ },
+ [],
+ );
+
+ const endDrag = useCallback(() => {
+ setDragState({
+ isDragging: false,
+ bookmarkTime: null,
+ currentTime: 0,
+ });
+ }, []);
+
+ const getSnapResult = useCallback(
+ ({
+ rawTime,
+ excludeBookmarkTime,
+ }: {
+ rawTime: number;
+ excludeBookmarkTime: number;
+ }): { snappedTime: number; snapPoint: SnapPoint | null } => {
+ const shouldSnap = snappingEnabled && !isShiftHeldRef.current;
+ if (!shouldSnap) {
+ return { snappedTime: rawTime, snapPoint: null };
+ }
+
+ const snapPoints = findSnapPoints({
+ tracks,
+ playheadTime,
+ bookmarks,
+ excludeBookmarkTime,
+ });
+ const result = snapToNearestPoint({
+ targetTime: rawTime,
+ snapPoints,
+ zoomLevel,
+ });
+ return {
+ snappedTime: result.snappedTime,
+ snapPoint: result.snapPoint,
+ };
+ },
+ [
+ snappingEnabled,
+ findSnapPoints,
+ snapToNearestPoint,
+ tracks,
+ playheadTime,
+ bookmarks,
+ zoomLevel,
+ isShiftHeldRef,
+ ],
+ );
+
+ useEffect(() => {
+ if (!dragState.isDragging && !isPendingDrag) return;
+
+ const handleMouseMove = (event: MouseEvent) => {
+ lastMouseXRef.current = event.clientX;
+
+ const scrollContainer = scrollRef.current;
+ if (!scrollContainer) return;
+
+ if (isPendingDrag && pendingDragRef.current) {
+ const { startMouseX, startMouseY, bookmarkTime } =
+ pendingDragRef.current;
+ const deltaX = Math.abs(event.clientX - startMouseX);
+ const deltaY = Math.abs(event.clientY - startMouseY);
+
+ if (deltaX <= DRAG_THRESHOLD_PX && deltaY <= DRAG_THRESHOLD_PX) {
+ return;
+ }
+
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ const scrollLeft = scrollContainer.scrollLeft;
+ const mouseTime = getMouseTimeFromClientX({
+ clientX: event.clientX,
+ containerRect: scrollContainer.getBoundingClientRect(),
+ zoomLevel,
+ scrollLeft,
+ });
+ const frameSnappedTime = snapTimeToFrame({
+ time: Math.max(0, Math.min(mouseTime, duration)),
+ fps: activeProject.settings.fps,
+ });
+ const { snappedTime: initialTime } = getSnapResult({
+ rawTime: frameSnappedTime,
+ excludeBookmarkTime: bookmarkTime,
+ });
+
+ startDrag({
+ bookmarkTime,
+ initialCurrentTime: initialTime,
+ });
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ return;
+ }
+
+ if (!dragState.isDragging || dragState.bookmarkTime === null) return;
+
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ const scrollLeft = scrollContainer.scrollLeft;
+ const mouseTime = getMouseTimeFromClientX({
+ clientX: event.clientX,
+ containerRect: scrollContainer.getBoundingClientRect(),
+ zoomLevel,
+ scrollLeft,
+ });
+ const clampedTime = Math.max(0, Math.min(mouseTime, duration));
+ const frameSnappedTime = snapTimeToFrame({
+ time: clampedTime,
+ fps: activeProject.settings.fps,
+ });
+ const snapResult = getSnapResult({
+ rawTime: frameSnappedTime,
+ excludeBookmarkTime: dragState.bookmarkTime,
+ });
+
+ setDragState((previousDragState) => ({
+ ...previousDragState,
+ currentTime: snapResult.snappedTime,
+ }));
+ onSnapPointChange?.(snapResult.snapPoint);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ return () => document.removeEventListener("mousemove", handleMouseMove);
+ }, [
+ dragState.isDragging,
+ dragState.bookmarkTime,
+ zoomLevel,
+ duration,
+ editor.project,
+ scrollRef,
+ isPendingDrag,
+ startDrag,
+ getSnapResult,
+ onSnapPointChange,
+ ]);
+
+ useEffect(() => {
+ if (!dragState.isDragging) return;
+
+ const handleMouseUp = () => {
+ if (dragState.bookmarkTime === null) {
+ endDrag();
+ onSnapPointChange?.(null);
+ return;
+ }
+
+ const clampedTime = Math.max(
+ 0,
+ Math.min(dragState.currentTime, duration),
+ );
+
+ editor.scenes.moveBookmark({
+ fromTime: dragState.bookmarkTime,
+ toTime: clampedTime,
+ });
+
+ endDrag();
+ onSnapPointChange?.(null);
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [
+ dragState.isDragging,
+ dragState.bookmarkTime,
+ dragState.currentTime,
+ duration,
+ endDrag,
+ onSnapPointChange,
+ editor.scenes,
+ ]);
+
+ useEffect(() => {
+ if (!isPendingDrag) return;
+
+ const handleMouseUp = () => {
+ pendingDragRef.current = null;
+ setIsPendingDrag(false);
+ onSnapPointChange?.(null);
+ };
+
+ document.addEventListener("mouseup", handleMouseUp);
+ return () => document.removeEventListener("mouseup", handleMouseUp);
+ }, [isPendingDrag, onSnapPointChange]);
+
+ const handleBookmarkMouseDown = useCallback(
+ ({ event, bookmark }: { event: React.MouseEvent; bookmark: Bookmark }) => {
+ if (event.button !== 0) return;
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ pendingDragRef.current = {
+ bookmarkTime: bookmark.time,
+ startMouseX: event.clientX,
+ startMouseY: event.clientY,
+ };
+ setIsPendingDrag(true);
+ },
+ [],
+ );
+
+ return {
+ dragState,
+ handleBookmarkMouseDown,
+ lastMouseXRef,
+ };
+}
diff --git a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
index 01af9362..632d2c78 100644
--- a/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-drag-drop.ts
@@ -7,10 +7,12 @@ import { snapTimeToFrame } from "@/lib/time";
import {
buildTextElement,
buildStickerElement,
- buildUploadAudioElement,
- buildVideoElement,
- buildImageElement,
+ buildElementFromMedia,
} from "@/lib/timeline/element-utils";
+import type { Command } from "@/lib/commands/base-command";
+import { AddMediaAssetCommand } from "@/lib/commands/media";
+import { AddTrackCommand, InsertElementCommand } from "@/lib/commands/timeline";
+import { BatchCommand } from "@/lib/commands";
import { computeDropTarget } from "@/lib/timeline/drop-utils";
import { getDragData, hasDragData } from "@/lib/drag-data";
import type { TrackType, DropTarget, ElementType } from "@/types/timeline";
@@ -231,7 +233,8 @@ export function useTimelineDragDrop({
}
const element = buildStickerElement({
- iconName: dragData.iconName,
+ stickerId: dragData.stickerId,
+ name: dragData.name,
startTime: target.xPosition,
});
@@ -265,38 +268,18 @@ export function useTimelineDragDrop({
const duration =
mediaAsset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const element = buildElementFromMedia({
+ mediaId: mediaAsset.id,
+ mediaType: mediaAsset.type,
+ name: mediaAsset.name,
+ duration,
+ startTime: target.xPosition,
+ });
- if (dragData.mediaType === "audio") {
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element: buildUploadAudioElement({
- mediaId: mediaAsset.id,
- name: mediaAsset.name,
- duration,
- startTime: target.xPosition,
- }),
- });
- } else if (dragData.mediaType === "video") {
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element: buildVideoElement({
- mediaId: mediaAsset.id,
- name: mediaAsset.name,
- duration,
- startTime: target.xPosition,
- }),
- });
- } else {
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element: buildImageElement({
- mediaId: mediaAsset.id,
- name: mediaAsset.name,
- duration,
- startTime: target.xPosition,
- }),
- });
- }
+ editor.timeline.insertElement({
+ placement: { mode: "explicit", trackId },
+ element,
+ });
},
[editor.timeline, mediaAssets, tracks],
);
@@ -314,80 +297,67 @@ export function useTimelineDragDrop({
if (!activeProject) return;
const processedAssets = await processMediaAssets({ files });
+ const projectId = activeProject.metadata.id;
for (const asset of processedAssets) {
- await editor.media.addMediaAsset({
- projectId: activeProject.metadata.id,
- asset,
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const currentTracks = editor.timeline.getTracks();
+ const dropTarget = computeDropTarget({
+ elementType: asset.type,
+ mouseX,
+ mouseY,
+ tracks: currentTracks,
+ playheadTime: currentTime,
+ isExternalDrop: true,
+ elementDuration: duration,
+ pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
+ zoomLevel,
});
- const added = editor.media
- .getAssets()
- .find((m) => m.name === asset.name && m.url === asset.url);
+ const trackType: TrackType = asset.type === "audio" ? "audio" : "video";
+ const addMediaCmd = new AddMediaAssetCommand(projectId, asset);
+ const assetId = addMediaCmd.getAssetId();
- if (added) {
- const duration =
- added.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
- const currentTracks = editor.timeline.getTracks();
- const dropTarget = computeDropTarget({
- elementType: added.type,
- mouseX,
- mouseY,
- tracks: currentTracks,
- playheadTime: currentTime,
- isExternalDrop: true,
- elementDuration: duration,
- pixelsPerSecond: TIMELINE_CONSTANTS.PIXELS_PER_SECOND,
- zoomLevel,
- });
+ const commands: Command[] = [addMediaCmd];
- const trackType: TrackType =
- added.type === "audio" ? "audio" : "video";
- const trackId = dropTarget.isNewTrack
- ? editor.timeline.addTrack({
- type: trackType,
- index: dropTarget.trackIndex,
- })
- : currentTracks[dropTarget.trackIndex]?.id;
+ let trackId: string | undefined;
+ if (dropTarget.isNewTrack) {
+ const addTrackCmd = new AddTrackCommand(
+ trackType,
+ dropTarget.trackIndex,
+ );
+ trackId = addTrackCmd.getTrackId();
+ commands.unshift(addTrackCmd);
+ } else {
+ trackId = currentTracks[dropTarget.trackIndex]?.id;
+ }
- if (!trackId) return;
+ if (!trackId) return;
- if (added.type === "audio") {
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element: buildUploadAudioElement({
- mediaId: added.id,
- name: added.name,
- duration,
- startTime: dropTarget.xPosition,
- buffer: new AudioBuffer({ length: 1, sampleRate: 44100 }),
- }),
- });
- } else if (added.type === "video") {
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element: buildVideoElement({
- mediaId: added.id,
- name: added.name,
- duration,
- startTime: dropTarget.xPosition,
- }),
- });
- } else {
- editor.timeline.insertElement({
- placement: { mode: "explicit", trackId },
- element: buildImageElement({
- mediaId: added.id,
- name: added.name,
- duration,
- startTime: dropTarget.xPosition,
- }),
- });
- }
- }
+ const element = buildElementFromMedia({
+ mediaId: assetId,
+ mediaType: asset.type,
+ name: asset.name,
+ duration,
+ startTime: dropTarget.xPosition,
+ buffer:
+ asset.type === "audio"
+ ? new AudioBuffer({ length: 1, sampleRate: 44100 })
+ : undefined,
+ });
+
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "explicit", trackId },
+ });
+ commands.push(insertCmd);
+
+ const batchCmd = new BatchCommand(commands);
+ editor.command.execute({ command: batchCmd });
}
},
- [activeProject, editor.media, editor.timeline, currentTime, zoomLevel],
+ [activeProject, editor.command, editor.timeline, currentTime, zoomLevel],
);
const handleDrop = useCallback(
diff --git a/apps/web/src/hooks/timeline/use-timeline-playhead.ts b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
index addef433..44e12ab1 100644
--- a/apps/web/src/hooks/timeline/use-timeline-playhead.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-playhead.ts
@@ -2,6 +2,11 @@ import { getSnappedSeekTime } from "@/lib/time";
import { useState, useEffect, useCallback, useRef } from "react";
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
import { useEditor } from "../use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import {
+ useTimelineSnapping,
+ type SnapPoint,
+} from "@/hooks/timeline/use-timeline-snapping";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
interface UseTimelinePlayheadProps {
@@ -25,6 +30,11 @@ export function useTimelinePlayhead({
const duration = editor.timeline.getTotalDuration();
const isPlaying = editor.playback.getIsPlaying();
const isScrubbing = editor.playback.getIsScrubbing();
+ const isShiftHeldRef = useShiftKey();
+ const { snapToNearestPoint } = useTimelineSnapping({
+ enableElementSnapping: false,
+ enablePlayheadSnapping: false,
+ });
const seek = useCallback(
({ time }: { time: number }) => editor.playback.seek({ time }),
@@ -62,19 +72,45 @@ export function useTimelinePlayhead({
clampedMouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
),
);
+
const framesPerSecond = activeProject.settings.fps;
- const time = getSnappedSeekTime({
+ const frameTime = getSnappedSeekTime({
rawTime,
duration,
fps: framesPerSecond,
});
+ const bookmarks = editor.scenes.getActiveScene()?.bookmarks ?? [];
+ const bookmarkSnapPoints: SnapPoint[] = bookmarks.map((bookmark) => ({
+ time: bookmark.time,
+ type: "bookmark",
+ }));
+ const shouldSnapToBookmark =
+ !isShiftHeldRef.current && bookmarkSnapPoints.length > 0;
+ const snapResult = shouldSnapToBookmark
+ ? snapToNearestPoint({
+ targetTime: frameTime,
+ snapPoints: bookmarkSnapPoints,
+ zoomLevel,
+ })
+ : null;
+ const time = snapResult?.snapPoint ? snapResult.snappedTime : frameTime;
+
setScrubTime(time);
seek({ time });
lastMouseXRef.current = event.clientX;
},
- [duration, zoomLevel, seek, rulerRef, activeProject.settings.fps],
+ [
+ duration,
+ zoomLevel,
+ seek,
+ rulerRef,
+ activeProject.settings.fps,
+ isShiftHeldRef,
+ editor.scenes,
+ snapToNearestPoint,
+ ],
);
const handlePlayheadMouseDown = useCallback(
diff --git a/apps/web/src/hooks/timeline/use-timeline-snapping.ts b/apps/web/src/hooks/timeline/use-timeline-snapping.ts
index 4cd6f775..5de75448 100644
--- a/apps/web/src/hooks/timeline/use-timeline-snapping.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-snapping.ts
@@ -1,10 +1,11 @@
import { useCallback } from "react";
-import type { TimelineTrack } from "@/types/timeline";
+import type { Bookmark, TimelineTrack } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
export interface SnapPoint {
time: number;
- type: "element-start" | "element-end" | "playhead";
+ type: "element-start" | "element-end" | "playhead" | "bookmark";
elementId?: string;
trackId?: string;
}
@@ -19,22 +20,28 @@ export interface UseTimelineSnappingOptions {
snapThreshold?: number;
enableElementSnapping?: boolean;
enablePlayheadSnapping?: boolean;
+ enableBookmarkSnapping?: boolean;
}
export function useTimelineSnapping({
snapThreshold = 10,
enableElementSnapping = true,
enablePlayheadSnapping = true,
+ enableBookmarkSnapping = true,
}: UseTimelineSnappingOptions = {}) {
const findSnapPoints = useCallback(
({
tracks,
playheadTime,
excludeElementId,
+ bookmarks = [],
+ excludeBookmarkTime,
}: {
tracks: Array<TimelineTrack>;
playheadTime: number;
excludeElementId?: string;
+ bookmarks?: Array<Bookmark>;
+ excludeBookmarkTime?: number;
}): SnapPoint[] => {
const snapPoints: SnapPoint[] = [];
@@ -71,9 +78,25 @@ export function useTimelineSnapping({
});
}
+ if (enableBookmarkSnapping) {
+ for (const bookmark of bookmarks) {
+ if (
+ excludeBookmarkTime != null &&
+ Math.abs(bookmark.time - excludeBookmarkTime) <
+ BOOKMARK_TIME_EPSILON
+ ) {
+ continue;
+ }
+ snapPoints.push({
+ time: bookmark.time,
+ type: "bookmark",
+ });
+ }
+ }
+
return snapPoints;
},
- [enableElementSnapping, enablePlayheadSnapping],
+ [enableElementSnapping, enablePlayheadSnapping, enableBookmarkSnapping],
);
const snapToNearestPoint = useCallback(
@@ -118,6 +141,7 @@ export function useTimelineSnapping({
zoomLevel,
excludeElementId,
snapToStart = true,
+ bookmarks = [],
}: {
targetTime: number;
elementDuration: number;
@@ -126,11 +150,13 @@ export function useTimelineSnapping({
zoomLevel: number;
excludeElementId?: string;
snapToStart?: boolean;
+ bookmarks?: Array<Bookmark>;
}): SnapResult => {
const snapPoints = findSnapPoints({
tracks,
playheadTime,
excludeElementId,
+ bookmarks,
});
const effectiveTargetTime = snapToStart
diff --git a/apps/web/src/hooks/timeline/use-timeline-zoom.ts b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
index 10563a05..095c64dc 100644
--- a/apps/web/src/hooks/timeline/use-timeline-zoom.ts
+++ b/apps/web/src/hooks/timeline/use-timeline-zoom.ts
@@ -57,6 +57,8 @@ export function useTimelineZoom({
const previousZoomRef = useRef(zoomLevel);
const hasRestoredScrollRef = useRef(false);
const preZoomScrollLeftRef = useRef(0);
+ const prePlayheadAnchorScrollLeftRef = useRef(0);
+ const isInPlayheadAnchorModeRef = useRef(false);
const setZoomLevel = useCallback(
(updater: number | ((prev: number) => number)) => {
@@ -89,8 +91,6 @@ export function useTimelineZoom({
);
return nextZoom;
});
- // for horizontal scrolling (when shift is held or horizontal wheel movement),
- // let the event bubble up to allow ScrollArea to handle it
return;
}
},
@@ -143,6 +143,36 @@ export function useTimelineZoom({
const currentScrollLeft = preZoomScrollLeftRef.current;
const playheadTime = editor.playback.getCurrentTime();
const sliderPercent = zoomToSlider({ zoomLevel, minZoom });
+ const previousSliderPercent = zoomToSlider({
+ zoomLevel: previousZoom,
+ minZoom,
+ });
+ const isCrossingThresholdUp =
+ previousSliderPercent <
+ TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
+ sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
+ const isCrossingThresholdDown =
+ previousSliderPercent >=
+ TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD &&
+ sliderPercent < TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD;
+
+ const syncScroll = (scrollLeft: number) => {
+ scrollElement.scrollLeft = scrollLeft;
+ if (rulerScrollRef.current) {
+ rulerScrollRef.current.scrollLeft = scrollLeft;
+ }
+ };
+
+ const clampScrollLeft = (scrollLeft: number) => {
+ const maxScrollLeft =
+ scrollElement.scrollWidth - scrollElement.clientWidth;
+ return Math.max(0, Math.min(maxScrollLeft, scrollLeft));
+ };
+
+ if (isCrossingThresholdUp) {
+ prePlayheadAnchorScrollLeftRef.current = currentScrollLeft;
+ isInPlayheadAnchorModeRef.current = true;
+ }
if (sliderPercent >= TIMELINE_CONSTANTS.ZOOM_ANCHOR_PLAYHEAD_THRESHOLD) {
const playheadPixelsBefore =
@@ -153,17 +183,10 @@ export function useTimelineZoom({
const viewportOffset = playheadPixelsBefore - currentScrollLeft;
const newScrollLeft = playheadPixelsAfter - viewportOffset;
- const maxScrollLeft =
- scrollElement.scrollWidth - scrollElement.clientWidth;
- const clampedScrollLeft = Math.max(
- 0,
- Math.min(maxScrollLeft, newScrollLeft),
- );
-
- scrollElement.scrollLeft = clampedScrollLeft;
- if (rulerScrollRef.current) {
- rulerScrollRef.current.scrollLeft = clampedScrollLeft;
- }
+ syncScroll(clampScrollLeft(newScrollLeft));
+ } else if (isCrossingThresholdDown && isInPlayheadAnchorModeRef.current) {
+ syncScroll(clampScrollLeft(prePlayheadAnchorScrollLeftRef.current));
+ isInPlayheadAnchorModeRef.current = false;
}
previousZoomRef.current = zoomLevel;
@@ -217,7 +240,6 @@ export function useTimelineZoom({
const observer = new ResizeObserver(() => {
if (scrollElement.scrollWidth > 0) {
restoreScroll();
- hasRestoredScrollRef.current = true;
observer.disconnect();
}
});
diff --git a/apps/web/src/hooks/use-focus-lock.ts b/apps/web/src/hooks/use-focus-lock.ts
new file mode 100644
index 00000000..80baebdb
--- /dev/null
+++ b/apps/web/src/hooks/use-focus-lock.ts
@@ -0,0 +1,79 @@
+import { useEffect, useRef } from "react";
+
+type FocusLockCursor = "text" | "default" | "pointer" | "crosshair";
+
+const DATA_ATTR = "data-focus-locked";
+
+function buildFocusLockCSS({
+ cursor,
+ allowSelector,
+}: {
+ cursor: FocusLockCursor;
+ allowSelector?: string;
+}) {
+ const rules = [
+ `*, *::before, *::after { pointer-events: none !important; cursor: ${cursor} !important; }`,
+ `[${DATA_ATTR}], [${DATA_ATTR}] * { pointer-events: auto !important; cursor: auto !important; }`,
+ ];
+
+ if (allowSelector) {
+ rules.push(
+ `${allowSelector} { pointer-events: auto !important; cursor: auto !important; }`,
+ );
+ }
+
+ return rules.join("\n");
+}
+
+export function useFocusLock<T extends HTMLElement = HTMLElement>({
+ isActive,
+ onDismiss,
+ cursor = "default",
+ allowSelector,
+}: {
+ isActive: boolean;
+ onDismiss: () => void;
+ cursor?: FocusLockCursor;
+ allowSelector?: string;
+}) {
+ const containerRef = useRef<T>(null);
+ const onDismissRef = useRef(onDismiss);
+ onDismissRef.current = onDismiss;
+
+ useEffect(() => {
+ if (!isActive) return;
+ const container = containerRef.current;
+ if (!container) return;
+
+ container.setAttribute(DATA_ATTR, "");
+
+ const focusLockStyle = document.createElement("style");
+ focusLockStyle.textContent = buildFocusLockCSS({ cursor, allowSelector });
+ document.head.appendChild(focusLockStyle);
+
+ const handleOutsidePointerDown = (event: PointerEvent) => {
+ if (event.button !== 0) return;
+ if (container.contains(event.target as Node)) return;
+
+ const target = event.target as Element | null;
+ const isAllowedTarget = allowSelector && target?.closest(allowSelector);
+ if (isAllowedTarget) return;
+
+ onDismissRef.current();
+ };
+
+ document.addEventListener("pointerdown", handleOutsidePointerDown, true);
+
+ return () => {
+ document.removeEventListener(
+ "pointerdown",
+ handleOutsidePointerDown,
+ true,
+ );
+ container.removeAttribute(DATA_ATTR);
+ focusLockStyle.remove();
+ };
+ }, [isActive, cursor, allowSelector]);
+
+ return { containerRef };
+}
diff --git a/apps/web/src/hooks/use-infinite-scroll.ts b/apps/web/src/hooks/use-infinite-scroll.ts
index 4f823834..8d6e5fcc 100644
--- a/apps/web/src/hooks/use-infinite-scroll.ts
+++ b/apps/web/src/hooks/use-infinite-scroll.ts
@@ -1,35 +1,35 @@
-import { useRef, useCallback } from "react";
-
-interface UseInfiniteScrollOptions {
- onLoadMore: () => void;
- hasMore: boolean;
- isLoading: boolean;
- threshold?: number;
- enabled?: boolean;
-}
-
-export function useInfiniteScroll({
- onLoadMore,
- hasMore,
- isLoading,
- threshold = 200,
- enabled = true,
-}: UseInfiniteScrollOptions) {
- const scrollAreaRef = useRef<HTMLDivElement>(null);
-
- const handleScroll = useCallback(
- (event: React.UIEvent<HTMLDivElement>) => {
- if (!enabled) return;
-
- const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
- const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
-
- if (nearBottom && hasMore && !isLoading) {
- onLoadMore();
- }
- },
- [onLoadMore, hasMore, isLoading, threshold, enabled],
- );
-
- return { scrollAreaRef, handleScroll };
-}
+import { useRef, useCallback } from "react";
+
+interface UseInfiniteScrollOptions {
+ onLoadMore: () => void;
+ hasMore: boolean;
+ isLoading: boolean;
+ threshold?: number;
+ enabled?: boolean;
+}
+
+export function useInfiniteScroll({
+ onLoadMore,
+ hasMore,
+ isLoading,
+ threshold = 200,
+ enabled = true,
+}: UseInfiniteScrollOptions) {
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
+
+ const handleScroll = useCallback(
+ (event: React.UIEvent<HTMLDivElement>) => {
+ if (!enabled) return;
+
+ const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
+ const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
+
+ if (nearBottom && hasMore && !isLoading) {
+ onLoadMore();
+ }
+ },
+ [onLoadMore, hasMore, isLoading, threshold, enabled],
+ );
+
+ return { scrollAreaRef, handleScroll };
+}
diff --git a/apps/web/src/hooks/use-paste-media.ts b/apps/web/src/hooks/use-paste-media.ts
new file mode 100644
index 00000000..4533ab28
--- /dev/null
+++ b/apps/web/src/hooks/use-paste-media.ts
@@ -0,0 +1,99 @@
+import { useEffect } from "react";
+import { toast } from "sonner";
+import { useEditor } from "@/hooks/use-editor";
+import { processMediaAssets } from "@/lib/media/processing";
+import { buildElementFromMedia } from "@/lib/timeline/element-utils";
+import { AddMediaAssetCommand } from "@/lib/commands/media";
+import { InsertElementCommand } from "@/lib/commands/timeline";
+import { BatchCommand } from "@/lib/commands";
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import { isTypableDOMElement } from "@/utils/browser";
+import type { MediaType } from "@/types/assets";
+
+const MEDIA_MIME_PREFIXES: MediaType[] = ["image", "video", "audio"];
+
+function isMediaMimeType({ type }: { type: string }): boolean {
+ return MEDIA_MIME_PREFIXES.some((prefix) => type.startsWith(`${prefix}/`));
+}
+
+function extractMediaFilesFromClipboard({
+ clipboardData,
+}: {
+ clipboardData: DataTransfer | null;
+}): File[] {
+ if (!clipboardData?.items) return [];
+
+ const files: File[] = [];
+ for (const item of clipboardData.items) {
+ if (item.kind !== "file") continue;
+ if (!isMediaMimeType({ type: item.type })) continue;
+
+ const file = item.getAsFile();
+ if (file) files.push(file);
+ }
+ return files;
+}
+
+export function usePasteMedia() {
+ const editor = useEditor();
+
+ useEffect(() => {
+ const handlePaste = async (event: ClipboardEvent) => {
+ const activeElement = document.activeElement as HTMLElement;
+ if (activeElement && isTypableDOMElement({ element: activeElement })) {
+ return;
+ }
+
+ const files = extractMediaFilesFromClipboard({
+ clipboardData: event.clipboardData,
+ });
+ if (files.length === 0) return;
+
+ event.preventDefault();
+
+ const activeProject = editor.project.getActive();
+ if (!activeProject) return;
+
+ try {
+ const processedAssets = await processMediaAssets({ files });
+ const startTime = editor.playback.getCurrentTime();
+
+ for (const asset of processedAssets) {
+ const addMediaCmd = new AddMediaAssetCommand(
+ activeProject.metadata.id,
+ asset,
+ );
+ const assetId = addMediaCmd.getAssetId();
+ const duration =
+ asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
+ const trackType = asset.type === "audio" ? "audio" : "video";
+
+ const element = buildElementFromMedia({
+ mediaId: assetId,
+ mediaType: asset.type,
+ name: asset.name,
+ duration,
+ startTime,
+ buffer:
+ asset.type === "audio"
+ ? new AudioBuffer({ length: 1, sampleRate: 44100 })
+ : undefined,
+ });
+
+ const insertCmd = new InsertElementCommand({
+ element,
+ placement: { mode: "auto", trackType },
+ });
+ const batchCmd = new BatchCommand([addMediaCmd, insertCmd]);
+ editor.command.execute({ command: batchCmd });
+ }
+ } catch (error) {
+ console.error("Failed to paste media:", error);
+ toast.error("Failed to paste media");
+ }
+ };
+
+ window.addEventListener("paste", handlePaste);
+ return () => window.removeEventListener("paste", handlePaste);
+ }, [editor]);
+}
diff --git a/apps/web/src/hooks/use-preview-interaction.ts b/apps/web/src/hooks/use-preview-interaction.ts
index 31b33504..2bb33f21 100644
--- a/apps/web/src/hooks/use-preview-interaction.ts
+++ b/apps/web/src/hooks/use-preview-interaction.ts
@@ -1,12 +1,22 @@
-import { useCallback, useRef, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
import { useEditor } from "@/hooks/use-editor";
-import { useSyncExternalStore } from "react";
-import type { Transform, TimelineTrack } from "@/types/timeline";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import type { TextElement, Transform } from "@/types/timeline";
+import { getVisibleElementsWithBounds } from "@/lib/preview/element-bounds";
+import { hitTest } from "@/lib/preview/hit-test";
+import { screenToCanvas } from "@/lib/preview/preview-coords";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import { snapPosition, type SnapLine } from "@/lib/preview/preview-snap";
+
+const MIN_DRAG_DISTANCE = 0.5;
interface DragState {
startX: number;
startY: number;
- tracksSnapshot: TimelineTrack[];
+ bounds: {
+ width: number;
+ height: number;
+ };
elements: Array<{
trackId: string;
elementId: string;
@@ -20,59 +30,164 @@ export function usePreviewInteraction({
canvasRef: React.RefObject<HTMLCanvasElement | null>;
}) {
const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
const [isDragging, setIsDragging] = useState(false);
+ const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
+ const [editingText, setEditingText] = useState<{
+ trackId: string;
+ elementId: string;
+ element: TextElement;
+ originalOpacity: number;
+ } | null>(null);
const dragStateRef = useRef<DragState | null>(null);
+ const wasPlayingRef = useRef(editor.playback.getIsPlaying());
+ const editingTextRef = useRef(editingText);
+ editingTextRef.current = editingText;
- const selectedElements = useSyncExternalStore(
- (listener) => editor.selection.subscribe(listener),
- () => editor.selection.getSelectedElements(),
- );
+ const commitTextEdit = useCallback(() => {
+ const current = editingTextRef.current;
+ if (!current) return;
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId: current.trackId,
+ elementId: current.elementId,
+ updates: { opacity: current.originalOpacity },
+ },
+ ],
+ });
+ editor.timeline.commitPreview();
+ setEditingText(null);
+ }, [editor.timeline]);
- const getCanvasCoordinates = useCallback(
- ({ clientX, clientY }: { clientX: number; clientY: number }) => {
- if (!canvasRef.current) return { x: 0, y: 0 };
+ const cancelTextEdit = useCallback(() => {
+ editor.timeline.discardPreview();
+ setEditingText(null);
+ }, [editor.timeline]);
+
+ useEffect(() => {
+ const unsubscribe = editor.playback.subscribe(() => {
+ const isPlaying = editor.playback.getIsPlaying();
+ if (isPlaying && !wasPlayingRef.current && editingTextRef.current) {
+ commitTextEdit();
+ }
+ wasPlayingRef.current = isPlaying;
+ });
+ return unsubscribe;
+ }, [editor.playback, commitTextEdit]);
- const rect = canvasRef.current.getBoundingClientRect();
- const logicalWidth = canvasRef.current.width;
- const logicalHeight = canvasRef.current.height;
- const scaleX = logicalWidth / rect.width;
- const scaleY = logicalHeight / rect.height;
+ const handleDoubleClick = useCallback(
+ ({ clientX, clientY }: React.MouseEvent) => {
+ if (!canvasRef.current || editingText) return;
+
+ const tracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const mediaAssets = editor.media.getAssets();
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ const startPos = screenToCanvas({
+ clientX,
+ clientY,
+ canvas: canvasRef.current,
+ });
+
+ const elementsWithBounds = getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+ });
+
+ const hit = hitTest({
+ canvasX: startPos.x,
+ canvasY: startPos.y,
+ elementsWithBounds,
+ });
- const canvasX = (clientX - rect.left) * scaleX;
- const canvasY = (clientY - rect.top) * scaleY;
+ if (!hit || hit.element.type !== "text") return;
- return { x: canvasX, y: canvasY };
+ const textElement = hit.element as TextElement;
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId: hit.trackId,
+ elementId: hit.elementId,
+ updates: { opacity: 0 },
+ },
+ ],
+ });
+ setEditingText({
+ trackId: hit.trackId,
+ elementId: hit.elementId,
+ element: textElement,
+ originalOpacity: textElement.opacity,
+ });
},
- [canvasRef],
+ [canvasRef, editor, editingText],
);
const handlePointerDown = useCallback(
- (event: React.PointerEvent) => {
- if (selectedElements.length === 0) return;
+ ({
+ clientX,
+ clientY,
+ currentTarget,
+ pointerId,
+ button,
+ }: React.PointerEvent) => {
+ if (!canvasRef.current) return;
+ if (editingText) return;
+ if (button !== 0) return;
+
+ const tracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const mediaAssets = editor.media.getAssets();
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ const startPos = screenToCanvas({
+ clientX,
+ clientY,
+ canvas: canvasRef.current,
+ });
+
+ const elementsWithBounds = getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+ });
+
+ const hit = hitTest({
+ canvasX: startPos.x,
+ canvasY: startPos.y,
+ elementsWithBounds,
+ });
+
+ if (!hit) {
+ editor.selection.clearSelection();
+ return;
+ }
+
+ editor.selection.setSelectedElements({
+ elements: [{ trackId: hit.trackId, elementId: hit.elementId }],
+ });
const elementsWithTracks = editor.timeline.getElementsWithTracks({
- elements: selectedElements,
+ elements: [{ trackId: hit.trackId, elementId: hit.elementId }],
});
- const draggableElements = elementsWithTracks.filter(
- ({ element }) =>
- element.type === "video" ||
- element.type === "image" ||
- element.type === "text" ||
- element.type === "sticker",
+ const draggableElements = elementsWithTracks.filter(({ element }) =>
+ isVisualElement(element),
);
if (draggableElements.length === 0) return;
- const startPos = getCanvasCoordinates({
- clientX: event.clientX,
- clientY: event.clientY,
- });
-
dragStateRef.current = {
startX: startPos.x,
startY: startPos.y,
- tracksSnapshot: editor.timeline.getTracks(),
+ bounds: {
+ width: hit.bounds.width,
+ height: hit.bounds.height,
+ },
elements: draggableElements.map(({ track, element }) => ({
trackId: track.id,
elementId: element.id,
@@ -81,107 +196,118 @@ export function usePreviewInteraction({
};
setIsDragging(true);
- event.currentTarget.setPointerCapture(event.pointerId);
+ currentTarget.setPointerCapture(pointerId);
},
- [selectedElements, editor, getCanvasCoordinates],
+ [editor, canvasRef, editingText],
);
const handlePointerMove = useCallback(
- (event: React.PointerEvent) => {
- if (!dragStateRef.current || !isDragging) return;
+ ({ clientX, clientY }: React.PointerEvent) => {
+ if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
+
+ const canvasSize = editor.project.getActive().settings.canvasSize;
- const currentPos = getCanvasCoordinates({
- clientX: event.clientX,
- clientY: event.clientY,
+ const currentPos = screenToCanvas({
+ clientX,
+ clientY,
+ canvas: canvasRef.current,
});
const deltaX = currentPos.x - dragStateRef.current.startX;
const deltaY = currentPos.y - dragStateRef.current.startY;
+ const hasMovement =
+ Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
+ Math.abs(deltaY) > MIN_DRAG_DISTANCE;
+ if (!hasMovement) {
+ setSnapLines([]);
+ return;
+ }
- for (const { trackId, elementId, initialTransform } of dragStateRef
- .current.elements) {
- const newPosition = {
- x: initialTransform.position.x + deltaX,
- y: initialTransform.position.y + deltaY,
- };
+ const firstElement = dragStateRef.current.elements[0];
+ const proposedPosition = {
+ x: firstElement.initialTransform.position.x + deltaX,
+ y: firstElement.initialTransform.position.y + deltaY,
+ };
- editor.timeline.updateElements({
- updates: [
- {
- trackId,
- elementId,
- updates: {
- transform: {
- ...initialTransform,
- position: newPosition,
- },
+ const shouldSnap = !isShiftHeldRef.current;
+ const { snappedPosition, activeLines } = shouldSnap
+ ? snapPosition({
+ proposedPosition,
+ canvasSize,
+ elementSize: dragStateRef.current.bounds,
+ })
+ : {
+ snappedPosition: proposedPosition,
+ activeLines: [] as SnapLine[],
+ };
+
+ setSnapLines(activeLines);
+
+ const deltaSnappedX =
+ snappedPosition.x - firstElement.initialTransform.position.x;
+ const deltaSnappedY =
+ snappedPosition.y - firstElement.initialTransform.position.y;
+
+ const updates = dragStateRef.current.elements.map(
+ ({ trackId, elementId, initialTransform }) => ({
+ trackId,
+ elementId,
+ updates: {
+ transform: {
+ ...initialTransform,
+ position: {
+ x: initialTransform.position.x + deltaSnappedX,
+ y: initialTransform.position.y + deltaSnappedY,
},
},
- ],
- pushHistory: false,
- });
- }
+ },
+ }),
+ );
+
+ editor.timeline.previewElements({ updates });
},
- [isDragging, getCanvasCoordinates, editor],
+ [isDragging, canvasRef, editor, isShiftHeldRef],
);
const handlePointerUp = useCallback(
- (event: React.PointerEvent) => {
- if (!dragStateRef.current || !isDragging) return;
+ ({ clientX, clientY, currentTarget, pointerId }: React.PointerEvent) => {
+ if (!dragStateRef.current || !isDragging || !canvasRef.current) return;
- const currentPos = getCanvasCoordinates({
- clientX: event.clientX,
- clientY: event.clientY,
+ const currentPos = screenToCanvas({
+ clientX,
+ clientY,
+ canvas: canvasRef.current,
});
const deltaX = currentPos.x - dragStateRef.current.startX;
const deltaY = currentPos.y - dragStateRef.current.startY;
- const hasMovement = Math.abs(deltaX) > 0.5 || Math.abs(deltaY) > 0.5;
-
+ const hasMovement =
+ Math.abs(deltaX) > MIN_DRAG_DISTANCE ||
+ Math.abs(deltaY) > MIN_DRAG_DISTANCE;
if (!hasMovement) {
- dragStateRef.current = null;
- setIsDragging(false);
- event.currentTarget.releasePointerCapture(event.pointerId);
- return;
+ editor.timeline.discardPreview();
+ } else {
+ editor.timeline.commitPreview();
}
- // revert to pre-drag state so the command captures the correct undo snapshot
- editor.timeline.updateTracks(dragStateRef.current.tracksSnapshot);
-
- const updates = dragStateRef.current.elements.map(
- ({ trackId, elementId, initialTransform }) => {
- const newPosition = {
- x: initialTransform.position.x + deltaX,
- y: initialTransform.position.y + deltaY,
- };
-
- return {
- trackId,
- elementId,
- updates: {
- transform: {
- ...initialTransform,
- position: newPosition,
- },
- },
- };
- },
- );
-
- editor.timeline.updateElements({ updates });
-
dragStateRef.current = null;
setIsDragging(false);
- event.currentTarget.releasePointerCapture(event.pointerId);
+ setSnapLines([]);
+ currentTarget.releasePointerCapture(pointerId);
},
- [isDragging, getCanvasCoordinates, editor],
+ [isDragging, canvasRef, editor],
);
return {
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
+ onDoubleClick: handleDoubleClick,
+ snapLines,
+ editingText,
+ commitTextEdit,
+ cancelTextEdit,
};
}
diff --git a/apps/web/src/hooks/use-shift-key.ts b/apps/web/src/hooks/use-shift-key.ts
new file mode 100644
index 00000000..4f9fe67c
--- /dev/null
+++ b/apps/web/src/hooks/use-shift-key.ts
@@ -0,0 +1,35 @@
+import { useEffect, useRef, type RefObject } from "react";
+
+export function useShiftKey(): RefObject<boolean> {
+ const isShiftHeldRef = useRef(false);
+
+ useEffect(() => {
+ const handleKeyDown = ({ key }: KeyboardEvent) => {
+ if (key === "Shift") {
+ isShiftHeldRef.current = true;
+ }
+ };
+
+ const handleKeyUp = ({ key }: KeyboardEvent) => {
+ if (key === "Shift") {
+ isShiftHeldRef.current = false;
+ }
+ };
+
+ const handleBlur = () => {
+ isShiftHeldRef.current = false;
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ document.addEventListener("keyup", handleKeyUp);
+ window.addEventListener("blur", handleBlur);
+
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ document.removeEventListener("keyup", handleKeyUp);
+ window.removeEventListener("blur", handleBlur);
+ };
+ }, []);
+
+ return isShiftHeldRef;
+}
diff --git a/apps/web/src/hooks/use-sound-search.ts b/apps/web/src/hooks/use-sound-search.ts
index 263a01e2..f3c0aab4 100644
--- a/apps/web/src/hooks/use-sound-search.ts
+++ b/apps/web/src/hooks/use-sound-search.ts
@@ -1,154 +1,154 @@
-import { useEffect } from "react";
-import { useSoundsStore } from "@/stores/sounds-store";
-
-export function useSoundSearch({
- query,
- commercialOnly,
-}: {
- query: string;
- commercialOnly: boolean;
-}) {
- const {
- searchResults,
- isSearching,
- searchError,
- lastSearchQuery,
- currentPage,
- hasNextPage,
- isLoadingMore,
- totalCount,
- setSearchResults,
- setSearching,
- setSearchError,
- setLastSearchQuery,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- setLoadingMore,
- appendSearchResults,
- appendTopSounds,
- resetPagination,
- } = useSoundsStore();
-
- const loadMore = async () => {
- if (isLoadingMore || !hasNextPage) return;
-
- try {
- setLoadingMore({ loading: true });
- const nextPage = currentPage + 1;
-
- const searchParams = new URLSearchParams({
- page: nextPage.toString(),
- type: "effects",
- });
-
- if (query.trim()) {
- searchParams.set("q", query);
- }
-
- searchParams.set("commercial_only", commercialOnly.toString());
- const response = await fetch(
- `/api/sounds/search?${searchParams.toString()}`,
- );
-
- if (response.ok) {
- const data = await response.json();
-
- if (query.trim()) {
- appendSearchResults(data.results);
- } else {
- appendTopSounds(data.results);
- }
-
- setCurrentPage({ page: nextPage });
- setHasNextPage({ hasNext: !!data.next });
- setTotalCount(data.count);
- } else {
- setSearchError({ error: `Load more failed: ${response.status}` });
- }
- } catch (err) {
- setSearchError({
- error: err instanceof Error ? err.message : "Load more failed",
- });
- } finally {
- setLoadingMore({ loading: false });
- }
- };
-
- useEffect(() => {
- if (!query.trim()) {
- setSearchResults({ results: [] });
- setSearchError({ error: null });
- setLastSearchQuery({ query: "" });
- return;
- }
-
- if (query === lastSearchQuery && searchResults.length > 0) {
- return;
- }
-
- let ignore = false;
-
- const timeoutId = setTimeout(async () => {
- try {
- setSearching({ searching: true });
- setSearchError({ error: null });
- resetPagination();
-
- const response = await fetch(
- `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
- );
-
- if (!ignore) {
- if (response.ok) {
- const data = await response.json();
- setSearchResults({ results: data.results });
- setLastSearchQuery({ query: query });
- setHasNextPage({ hasNext: !!data.next });
- setTotalCount({ count: data.count });
- setCurrentPage({ page: 1 });
- } else {
- setSearchError({ error: `Search failed: ${response.status}` });
- }
- }
- } catch (err) {
- if (!ignore) {
- setSearchError({
- error: err instanceof Error ? err.message : "Search failed",
- });
- }
- } finally {
- if (!ignore) {
- setSearching({ searching: false });
- }
- }
- }, 300);
-
- return () => {
- clearTimeout(timeoutId);
- ignore = true;
- };
- }, [
- query,
- lastSearchQuery,
- searchResults.length,
- setSearchResults,
- setSearching,
- setSearchError,
- setLastSearchQuery,
- setCurrentPage,
- setHasNextPage,
- setTotalCount,
- resetPagination,
- ]);
-
- return {
- results: searchResults,
- isLoading: isSearching,
- error: searchError,
- loadMore,
- hasNextPage,
- isLoadingMore,
- totalCount,
- };
-}
+import { useEffect } from "react";
+import { useSoundsStore } from "@/stores/sounds-store";
+
+export function useSoundSearch({
+ query,
+ commercialOnly,
+}: {
+ query: string;
+ commercialOnly: boolean;
+}) {
+ const {
+ searchResults,
+ isSearching,
+ searchError,
+ lastSearchQuery,
+ currentPage,
+ hasNextPage,
+ isLoadingMore,
+ totalCount,
+ setSearchResults,
+ setSearching,
+ setSearchError,
+ setLastSearchQuery,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ setLoadingMore,
+ appendSearchResults,
+ appendTopSounds,
+ resetPagination,
+ } = useSoundsStore();
+
+ const loadMore = async () => {
+ if (isLoadingMore || !hasNextPage) return;
+
+ try {
+ setLoadingMore({ loading: true });
+ const nextPage = currentPage + 1;
+
+ const searchParams = new URLSearchParams({
+ page: nextPage.toString(),
+ type: "effects",
+ });
+
+ if (query.trim()) {
+ searchParams.set("q", query);
+ }
+
+ searchParams.set("commercial_only", commercialOnly.toString());
+ const response = await fetch(
+ `/api/sounds/search?${searchParams.toString()}`,
+ );
+
+ if (response.ok) {
+ const data = await response.json();
+
+ if (query.trim()) {
+ appendSearchResults(data.results);
+ } else {
+ appendTopSounds(data.results);
+ }
+
+ setCurrentPage({ page: nextPage });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount(data.count);
+ } else {
+ setSearchError({ error: `Load more failed: ${response.status}` });
+ }
+ } catch (err) {
+ setSearchError({
+ error: err instanceof Error ? err.message : "Load more failed",
+ });
+ } finally {
+ setLoadingMore({ loading: false });
+ }
+ };
+
+ useEffect(() => {
+ if (!query.trim()) {
+ setSearchResults({ results: [] });
+ setSearchError({ error: null });
+ setLastSearchQuery({ query: "" });
+ return;
+ }
+
+ if (query === lastSearchQuery && searchResults.length > 0) {
+ return;
+ }
+
+ let ignore = false;
+
+ const timeoutId = setTimeout(async () => {
+ try {
+ setSearching({ searching: true });
+ setSearchError({ error: null });
+ resetPagination();
+
+ const response = await fetch(
+ `/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
+ );
+
+ if (!ignore) {
+ if (response.ok) {
+ const data = await response.json();
+ setSearchResults({ results: data.results });
+ setLastSearchQuery({ query: query });
+ setHasNextPage({ hasNext: !!data.next });
+ setTotalCount({ count: data.count });
+ setCurrentPage({ page: 1 });
+ } else {
+ setSearchError({ error: `Search failed: ${response.status}` });
+ }
+ }
+ } catch (err) {
+ if (!ignore) {
+ setSearchError({
+ error: err instanceof Error ? err.message : "Search failed",
+ });
+ }
+ } finally {
+ if (!ignore) {
+ setSearching({ searching: false });
+ }
+ }
+ }, 300);
+
+ return () => {
+ clearTimeout(timeoutId);
+ ignore = true;
+ };
+ }, [
+ query,
+ lastSearchQuery,
+ searchResults.length,
+ setSearchResults,
+ setSearching,
+ setSearchError,
+ setLastSearchQuery,
+ setCurrentPage,
+ setHasNextPage,
+ setTotalCount,
+ resetPagination,
+ ]);
+
+ return {
+ results: searchResults,
+ isLoading: isSearching,
+ error: searchError,
+ loadMore,
+ hasNextPage,
+ isLoadingMore,
+ totalCount,
+ };
+}
diff --git a/apps/web/src/hooks/use-transform-handles.ts b/apps/web/src/hooks/use-transform-handles.ts
new file mode 100644
index 00000000..37ef6d5f
--- /dev/null
+++ b/apps/web/src/hooks/use-transform-handles.ts
@@ -0,0 +1,331 @@
+import { useCallback, useRef, useState } from "react";
+import { useEditor } from "@/hooks/use-editor";
+import { useShiftKey } from "@/hooks/use-shift-key";
+import { useSyncExternalStore } from "react";
+import {
+ getVisibleElementsWithBounds,
+ type ElementWithBounds,
+} from "@/lib/preview/element-bounds";
+import { screenToCanvas } from "@/lib/preview/preview-coords";
+import {
+ MIN_SCALE,
+ snapRotation,
+ snapScale,
+ type SnapLine,
+} from "@/lib/preview/preview-snap";
+import { isVisualElement } from "@/lib/timeline/element-utils";
+import type { Transform } from "@/types/timeline";
+
+type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
+type HandleType = Corner | "rotation";
+
+interface ScaleState {
+ trackId: string;
+ elementId: string;
+ initialTransform: Transform;
+ initialDistance: number;
+ initialBoundsCx: number;
+ initialBoundsCy: number;
+ baseWidth: number;
+ baseHeight: number;
+}
+
+interface RotationState {
+ trackId: string;
+ elementId: string;
+ initialTransform: Transform;
+ initialAngle: number;
+ initialBoundsCx: number;
+ initialBoundsCy: number;
+}
+
+function areSnapLinesEqual({
+ previousLines,
+ nextLines,
+}: {
+ previousLines: SnapLine[];
+ nextLines: SnapLine[];
+}): boolean {
+ if (previousLines.length !== nextLines.length) {
+ return false;
+ }
+ for (const [index, line] of previousLines.entries()) {
+ const nextLine = nextLines[index];
+ if (!nextLine) {
+ return false;
+ }
+ if (line.type !== nextLine.type || line.position !== nextLine.position) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function getCornerDistance({
+ bounds,
+ corner,
+}: {
+ bounds: {
+ cx: number;
+ cy: number;
+ width: number;
+ height: number;
+ rotation: number;
+ };
+ corner: Corner;
+}): number {
+ const halfW = bounds.width / 2;
+ const halfH = bounds.height / 2;
+ const angleRad = (bounds.rotation * Math.PI) / 180;
+ const cos = Math.cos(angleRad);
+ const sin = Math.sin(angleRad);
+
+ const localX =
+ corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
+ const localY =
+ corner === "top-left" || corner === "top-right" ? -halfH : halfH;
+
+ const rotatedX = localX * cos - localY * sin;
+ const rotatedY = localX * sin + localY * cos;
+ return Math.sqrt(rotatedX * rotatedX + rotatedY * rotatedY) || 1;
+}
+
+export function useTransformHandles({
+ canvasRef,
+}: {
+ canvasRef: React.RefObject<HTMLCanvasElement | null>;
+}) {
+ const editor = useEditor();
+ const isShiftHeldRef = useShiftKey();
+ const [activeHandle, setActiveHandle] = useState<HandleType | null>(null);
+ const [snapLines, setSnapLines] = useState<SnapLine[]>([]);
+ const snapLinesRef = useRef<SnapLine[]>([]);
+ const scaleStateRef = useRef<ScaleState | null>(null);
+ const rotationStateRef = useRef<RotationState | null>(null);
+
+ const selectedElements = useSyncExternalStore(
+ (listener) => editor.selection.subscribe(listener),
+ () => editor.selection.getSelectedElements(),
+ );
+
+ const tracks = editor.timeline.getTracks();
+ const currentTime = editor.playback.getCurrentTime();
+ const mediaAssets = editor.media.getAssets();
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+
+ const elementsWithBounds = getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+ });
+
+ const selectedWithBounds: ElementWithBounds | null =
+ selectedElements.length === 1
+ ? (elementsWithBounds.find(
+ (entry) =>
+ entry.trackId === selectedElements[0].trackId &&
+ entry.elementId === selectedElements[0].elementId,
+ ) ?? null)
+ : null;
+
+ const hasVisualSelection =
+ selectedWithBounds !== null && isVisualElement(selectedWithBounds.element);
+
+ const handleCornerPointerDown = useCallback(
+ ({ event, corner }: { event: React.PointerEvent; corner: Corner }) => {
+ if (!selectedWithBounds) return;
+ event.stopPropagation();
+
+ const { bounds, trackId, elementId, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return;
+
+ const initialDistance = getCornerDistance({ bounds, corner });
+ const baseWidth = bounds.width / element.transform.scale;
+ const baseHeight = bounds.height / element.transform.scale;
+
+ scaleStateRef.current = {
+ trackId,
+ elementId,
+ initialTransform: element.transform,
+ initialDistance,
+ initialBoundsCx: bounds.cx,
+ initialBoundsCy: bounds.cy,
+ baseWidth,
+ baseHeight,
+ };
+ setActiveHandle(corner);
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ },
+ [selectedWithBounds],
+ );
+
+ const handleRotationPointerDown = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (!selectedWithBounds || !canvasRef.current) return;
+ event.stopPropagation();
+
+ const { bounds, trackId, elementId, element } = selectedWithBounds;
+ if (!isVisualElement(element)) return;
+
+ const position = screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ canvas: canvasRef.current,
+ });
+ const dx = position.x - bounds.cx;
+ const dy = position.y - bounds.cy;
+ const initialAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
+
+ rotationStateRef.current = {
+ trackId,
+ elementId,
+ initialTransform: element.transform,
+ initialAngle,
+ initialBoundsCx: bounds.cx,
+ initialBoundsCy: bounds.cy,
+ };
+ setActiveHandle("rotation");
+ (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
+ },
+ [selectedWithBounds, canvasRef],
+ );
+
+ const handlePointerMove = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (!canvasRef.current) return;
+ if (!scaleStateRef.current && !rotationStateRef.current) return;
+
+ const position = screenToCanvas({
+ clientX: event.clientX,
+ clientY: event.clientY,
+ canvas: canvasRef.current,
+ });
+
+ if (
+ scaleStateRef.current &&
+ activeHandle &&
+ activeHandle !== "rotation"
+ ) {
+ const {
+ trackId,
+ elementId,
+ initialTransform,
+ initialDistance,
+ initialBoundsCx,
+ initialBoundsCy,
+ baseWidth,
+ baseHeight,
+ } = scaleStateRef.current;
+
+ const dx = position.x - initialBoundsCx;
+ const dy = position.y - initialBoundsCy;
+ const currentDistance = Math.sqrt(dx * dx + dy * dy) || 1;
+ const scaleFactor = currentDistance / initialDistance;
+ const proposedScale = Math.max(
+ MIN_SCALE,
+ initialTransform.scale * scaleFactor,
+ );
+
+ const canvasSize = editor.project.getActive().settings.canvasSize;
+ const shouldSnap = !isShiftHeldRef.current;
+ const { snappedScale, activeLines } = shouldSnap
+ ? snapScale({
+ proposedScale,
+ position: initialTransform.position,
+ baseWidth,
+ baseHeight,
+ canvasSize,
+ })
+ : { snappedScale: proposedScale, activeLines: [] as SnapLine[] };
+
+ const isSameLines = areSnapLinesEqual({
+ previousLines: snapLinesRef.current,
+ nextLines: activeLines,
+ });
+
+ if (!isSameLines) {
+ snapLinesRef.current = activeLines;
+ setSnapLines(activeLines);
+ }
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ transform: { ...initialTransform, scale: snappedScale },
+ },
+ },
+ ],
+ });
+ return;
+ }
+
+ if (rotationStateRef.current && activeHandle === "rotation") {
+ const {
+ trackId,
+ elementId,
+ initialTransform,
+ initialAngle,
+ initialBoundsCx,
+ initialBoundsCy,
+ } = rotationStateRef.current;
+
+ const dx = position.x - initialBoundsCx;
+ const dy = position.y - initialBoundsCy;
+ const currentAngle = (Math.atan2(dy, dx) * 180) / Math.PI;
+ let deltaAngle = currentAngle - initialAngle;
+ if (deltaAngle > 180) deltaAngle -= 360;
+ if (deltaAngle < -180) deltaAngle += 360;
+ const newRotate = initialTransform.rotate + deltaAngle;
+ const shouldSnapRotation = !isShiftHeldRef.current;
+ const { snappedRotation } = shouldSnapRotation
+ ? snapRotation({ proposedRotation: newRotate })
+ : { snappedRotation: newRotate };
+
+ editor.timeline.previewElements({
+ updates: [
+ {
+ trackId,
+ elementId,
+ updates: {
+ transform: { ...initialTransform, rotate: snappedRotation },
+ },
+ },
+ ],
+ });
+ }
+ },
+ [activeHandle, canvasRef, editor, isShiftHeldRef],
+ );
+
+ const handlePointerUp = useCallback(
+ ({ event }: { event: React.PointerEvent }) => {
+ if (scaleStateRef.current || rotationStateRef.current) {
+ editor.timeline.commitPreview();
+ scaleStateRef.current = null;
+ rotationStateRef.current = null;
+ setActiveHandle(null);
+ snapLinesRef.current = [];
+ setSnapLines([]);
+ }
+ (event.currentTarget as HTMLElement).releasePointerCapture(
+ event.pointerId,
+ );
+ },
+ [editor],
+ );
+
+ return {
+ selectedWithBounds,
+ hasVisualSelection,
+ activeHandle,
+ snapLines,
+ handleCornerPointerDown,
+ handleRotationPointerDown,
+ handlePointerMove,
+ handlePointerUp,
+ };
+}
diff --git a/apps/web/src/lib/commands/index.ts b/apps/web/src/lib/commands/index.ts
index 03de07c4..a2119fc1 100644
--- a/apps/web/src/lib/commands/index.ts
+++ b/apps/web/src/lib/commands/index.ts
@@ -1,5 +1,6 @@
export { Command } from "./base-command";
export { BatchCommand } from "./batch-command";
+export { PreviewTracker } from "./preview-tracker";
export * from "./timeline";
export * from "./media";
diff --git a/apps/web/src/lib/commands/preview-tracker.ts b/apps/web/src/lib/commands/preview-tracker.ts
new file mode 100644
index 00000000..68c8dee3
--- /dev/null
+++ b/apps/web/src/lib/commands/preview-tracker.ts
@@ -0,0 +1,23 @@
+export class PreviewTracker<T> {
+ private snapshot: T | null = null;
+
+ begin({ state }: { state: T }): void {
+ if (this.snapshot === null) {
+ this.snapshot = structuredClone(state);
+ }
+ }
+
+ isActive(): boolean {
+ return this.snapshot !== null;
+ }
+
+ getSnapshot(): T | null {
+ return this.snapshot;
+ }
+
+ end(): T | null {
+ const snapshot = this.snapshot;
+ this.snapshot = null;
+ return snapshot;
+ }
+}
diff --git a/apps/web/src/lib/commands/scene/index.ts b/apps/web/src/lib/commands/scene/index.ts
index 5bf9406a..2b843c99 100644
--- a/apps/web/src/lib/commands/scene/index.ts
+++ b/apps/web/src/lib/commands/scene/index.ts
@@ -3,3 +3,5 @@ export { DeleteSceneCommand } from "./delete-scene";
export { RenameSceneCommand } from "./rename-scene";
export { ToggleBookmarkCommand } from "./toggle-bookmark";
export { RemoveBookmarkCommand } from "./remove-bookmark";
+export { UpdateBookmarkCommand } from "./update-bookmark";
+export { MoveBookmarkCommand } from "./move-bookmark";
diff --git a/apps/web/src/lib/commands/scene/move-bookmark.ts b/apps/web/src/lib/commands/scene/move-bookmark.ts
new file mode 100644
index 00000000..64382f03
--- /dev/null
+++ b/apps/web/src/lib/commands/scene/move-bookmark.ts
@@ -0,0 +1,59 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { TScene } from "@/types/timeline";
+import { updateSceneInArray } from "@/lib/scenes";
+import { getFrameTime, moveBookmarkInArray } from "@/lib/timeline/bookmarks";
+
+export class MoveBookmarkCommand extends Command {
+ private savedScenes: TScene[] | null = null;
+
+ constructor(
+ private fromTime: number,
+ private toTime: number,
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const activeScene = editor.scenes.getActiveScene();
+ const activeProject = editor.project.getActive();
+
+ if (!activeScene || !activeProject) {
+ return;
+ }
+
+ const scenes = editor.scenes.getScenes();
+ this.savedScenes = [...scenes];
+
+ const fromFrameTime = getFrameTime({
+ time: this.fromTime,
+ fps: activeProject.settings.fps,
+ });
+ const toFrameTime = getFrameTime({
+ time: this.toTime,
+ fps: activeProject.settings.fps,
+ });
+
+ const updatedBookmarks = moveBookmarkInArray({
+ bookmarks: activeScene.bookmarks,
+ fromTime: fromFrameTime,
+ toTime: toFrameTime,
+ });
+
+ const updatedScenes = updateSceneInArray({
+ scenes,
+ sceneId: activeScene.id,
+ updates: { bookmarks: updatedBookmarks },
+ });
+
+ editor.scenes.setScenes({ scenes: updatedScenes });
+ }
+
+ undo(): void {
+ if (this.savedScenes) {
+ const editor = EditorCore.getInstance();
+ editor.scenes.setScenes({ scenes: this.savedScenes });
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/scene/update-bookmark.ts b/apps/web/src/lib/commands/scene/update-bookmark.ts
new file mode 100644
index 00000000..5ce91a36
--- /dev/null
+++ b/apps/web/src/lib/commands/scene/update-bookmark.ts
@@ -0,0 +1,55 @@
+import { Command } from "@/lib/commands/base-command";
+import { EditorCore } from "@/core";
+import type { Bookmark, TScene } from "@/types/timeline";
+import { updateSceneInArray } from "@/lib/scenes";
+import { getFrameTime, updateBookmarkInArray } from "@/lib/timeline/bookmarks";
+
+export class UpdateBookmarkCommand extends Command {
+ private savedScenes: TScene[] | null = null;
+
+ constructor(
+ private time: number,
+ private updates: Partial<Omit<Bookmark, "time">>,
+ ) {
+ super();
+ }
+
+ execute(): void {
+ const editor = EditorCore.getInstance();
+ const activeScene = editor.scenes.getActiveScene();
+ const activeProject = editor.project.getActive();
+
+ if (!activeScene || !activeProject) {
+ return;
+ }
+
+ const scenes = editor.scenes.getScenes();
+ this.savedScenes = [...scenes];
+
+ const frameTime = getFrameTime({
+ time: this.time,
+ fps: activeProject.settings.fps,
+ });
+
+ const updatedBookmarks = updateBookmarkInArray({
+ bookmarks: activeScene.bookmarks,
+ frameTime,
+ updates: this.updates,
+ });
+
+ const updatedScenes = updateSceneInArray({
+ scenes,
+ sceneId: activeScene.id,
+ updates: { bookmarks: updatedBookmarks },
+ });
+
+ editor.scenes.setScenes({ scenes: updatedScenes });
+ }
+
+ undo(): void {
+ if (this.savedScenes) {
+ const editor = EditorCore.getInstance();
+ editor.scenes.setScenes({ scenes: this.savedScenes });
+ }
+ }
+}
diff --git a/apps/web/src/lib/commands/timeline/clipboard/paste.ts b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
index 39feb8ce..49554e0e 100644
--- a/apps/web/src/lib/commands/timeline/clipboard/paste.ts
+++ b/apps/web/src/lib/commands/timeline/clipboard/paste.ts
@@ -130,7 +130,9 @@ export class PasteCommand extends Command {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
- editor.selection.setSelectedElements({ elements: this.previousSelection });
+ editor.selection.setSelectedElements({
+ elements: this.previousSelection,
+ });
}
}
diff --git a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
index 4bec6444..cf1bd94c 100644
--- a/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/duplicate-elements.ts
@@ -84,7 +84,9 @@ export class DuplicateElementsCommand extends Command {
editor.timeline.updateTracks(updatedTracks);
if (this.duplicatedElements.length > 0) {
- editor.selection.setSelectedElements({ elements: this.duplicatedElements });
+ editor.selection.setSelectedElements({
+ elements: this.duplicatedElements,
+ });
}
}
@@ -92,7 +94,9 @@ export class DuplicateElementsCommand extends Command {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
- editor.selection.setSelectedElements({ elements: this.previousSelection });
+ editor.selection.setSelectedElements({
+ elements: this.previousSelection,
+ });
}
}
diff --git a/apps/web/src/lib/commands/timeline/element/insert-element.ts b/apps/web/src/lib/commands/timeline/element/insert-element.ts
index 6067d72b..e1551471 100644
--- a/apps/web/src/lib/commands/timeline/element/insert-element.ts
+++ b/apps/web/src/lib/commands/timeline/element/insert-element.ts
@@ -163,8 +163,8 @@ export class InsertElementCommand extends Command {
return false;
}
- if (element.type === "sticker" && !element.iconName) {
- console.error("Sticker element must have iconName");
+ if (element.type === "sticker" && !element.stickerId) {
+ console.error("Sticker element must have stickerId");
return false;
}
diff --git a/apps/web/src/lib/commands/timeline/element/split-elements.ts b/apps/web/src/lib/commands/timeline/element/split-elements.ts
index 13d4d730..abe133c9 100644
--- a/apps/web/src/lib/commands/timeline/element/split-elements.ts
+++ b/apps/web/src/lib/commands/timeline/element/split-elements.ts
@@ -119,7 +119,9 @@ export class SplitElementsCommand extends Command {
editor.timeline.updateTracks(updatedTracks);
if (this.rightSideElements.length > 0) {
- editor.selection.setSelectedElements({ elements: this.rightSideElements });
+ editor.selection.setSelectedElements({
+ elements: this.rightSideElements,
+ });
}
}
@@ -127,7 +129,9 @@ export class SplitElementsCommand extends Command {
if (this.savedState) {
const editor = EditorCore.getInstance();
editor.timeline.updateTracks(this.savedState);
- editor.selection.setSelectedElements({ elements: this.previousSelection });
+ editor.selection.setSelectedElements({
+ elements: this.previousSelection,
+ });
}
}
}
diff --git a/apps/web/src/lib/commands/timeline/index.ts b/apps/web/src/lib/commands/timeline/index.ts
index 30bc81ef..cb285d94 100644
--- a/apps/web/src/lib/commands/timeline/index.ts
+++ b/apps/web/src/lib/commands/timeline/index.ts
@@ -1,3 +1,5 @@
export * from "./track";
export * from "./element";
export * from "./clipboard";
+
+export { TracksSnapshotCommand } from "./tracks-snapshot";
diff --git a/apps/web/src/lib/commands/timeline/tracks-snapshot.ts b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts
new file mode 100644
index 00000000..2ba059c7
--- /dev/null
+++ b/apps/web/src/lib/commands/timeline/tracks-snapshot.ts
@@ -0,0 +1,20 @@
+import { Command } from "@/lib/commands/base-command";
+import type { TimelineTrack } from "@/types/timeline";
+import { EditorCore } from "@/core";
+
+export class TracksSnapshotCommand extends Command {
+ constructor(
+ private before: TimelineTrack[],
+ private after: TimelineTrack[],
+ ) {
+ super();
+ }
+
+ execute(): void {
+ EditorCore.getInstance().timeline.updateTracks(this.after);
+ }
+
+ undo(): void {
+ EditorCore.getInstance().timeline.updateTracks(this.before);
+ }
+}
diff --git a/apps/web/src/lib/fonts/google-fonts.ts b/apps/web/src/lib/fonts/google-fonts.ts
new file mode 100644
index 00000000..7e55dffd
--- /dev/null
+++ b/apps/web/src/lib/fonts/google-fonts.ts
@@ -0,0 +1,90 @@
+import type { FontAtlas } from "@/types/fonts";
+import { SYSTEM_FONTS } from "@/constants/font-constants";
+
+const GOOGLE_FONTS_CSS = "https://fonts.googleapis.com/css2";
+
+const fullLoaded = new Set<string>();
+
+let cachedAtlas: FontAtlas | null = null;
+let atlasFetchPromise: Promise<FontAtlas | null> | null = null;
+
+function encodeFamily(family: string): string {
+ return family.replace(/ /g, "+");
+}
+
+export function getCachedFontAtlas(): FontAtlas | null {
+ return cachedAtlas;
+}
+
+export function clearFontAtlasCache(): void {
+ cachedAtlas = null;
+ atlasFetchPromise = null;
+}
+
+async function fetchAtlas(): Promise<FontAtlas | null> {
+ if (cachedAtlas) return cachedAtlas;
+ if (atlasFetchPromise) return atlasFetchPromise;
+
+ atlasFetchPromise = fetch("/fonts/font-atlas.json")
+ .then(async (response) => {
+ if (!response.ok) return null;
+ const data: FontAtlas = await response.json();
+ cachedAtlas = data;
+ return data;
+ })
+ .catch(() => null);
+
+ return atlasFetchPromise;
+}
+
+function preloadChunkImages({ atlas }: { atlas: FontAtlas }): void {
+ const maxChunk = Math.max(
+ ...Object.values(atlas.fonts).map((entry) => entry.ch),
+ );
+ for (let i = 0; i <= maxChunk; i++) {
+ const img = new Image();
+ img.src = `/fonts/font-chunk-${i}.avif`;
+ }
+}
+
+export function prefetchFontAtlas(): Promise<FontAtlas | null> {
+ return fetchAtlas().then((atlas) => {
+ if (atlas) preloadChunkImages({ atlas });
+ return atlas;
+ });
+}
+
+export async function loadFullFont({
+ family,
+ weights = [400, 700],
+}: {
+ family: string;
+ weights?: number[];
+}): Promise<void> {
+ if (fullLoaded.has(family)) return;
+
+ const url = `${GOOGLE_FONTS_CSS}?family=${encodeFamily(family)}:wght@${weights.join(";")}&display=swap`;
+ const link = document.createElement("link");
+ link.rel = "stylesheet";
+ link.href = url;
+ document.head.appendChild(link);
+ await new Promise<void>((resolve) => {
+ link.addEventListener("load", () => resolve(), { once: true });
+ link.addEventListener("error", () => resolve(), { once: true });
+ });
+ await Promise.all(
+ weights.map((weight) =>
+ document.fonts.load(`${weight} 16px "${family.replace(/"/g, '\\"')}"`),
+ ),
+ );
+ fullLoaded.add(family);
+}
+
+export async function loadFonts({
+ families,
+}: {
+ families: string[];
+}): Promise<void> {
+ const googleFonts = families.filter((family) => !SYSTEM_FONTS.has(family));
+ await Promise.all(googleFonts.map((family) => loadFullFont({ family })));
+}
diff --git a/apps/web/src/lib/preview/element-bounds.ts b/apps/web/src/lib/preview/element-bounds.ts
new file mode 100644
index 00000000..ec26c14d
--- /dev/null
+++ b/apps/web/src/lib/preview/element-bounds.ts
@@ -0,0 +1,219 @@
+import type { TimelineTrack, TimelineElement } from "@/types/timeline";
+import type { MediaAsset } from "@/types/assets";
+import { isMainTrack } from "@/lib/timeline";
+import {
+ DEFAULT_LINE_HEIGHT,
+ FONT_SIZE_SCALE_REFERENCE,
+} from "@/constants/text-constants";
+
+export interface ElementBounds {
+ cx: number;
+ cy: number;
+ width: number;
+ height: number;
+ rotation: number;
+}
+
+export interface ElementWithBounds {
+ trackId: string;
+ elementId: string;
+ element: TimelineElement;
+ bounds: ElementBounds;
+}
+
+function getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth,
+ sourceHeight,
+ transform,
+}: {
+ canvasWidth: number;
+ canvasHeight: number;
+ sourceWidth: number;
+ sourceHeight: number;
+ transform: {
+ scale: number;
+ position: { x: number; y: number };
+ rotate: number;
+ };
+}): ElementBounds {
+ const containScale = Math.min(
+ canvasWidth / sourceWidth,
+ canvasHeight / sourceHeight,
+ );
+ const scaledWidth = sourceWidth * containScale * transform.scale;
+ const scaledHeight = sourceHeight * containScale * transform.scale;
+ const cx = canvasWidth / 2 + transform.position.x;
+ const cy = canvasHeight / 2 + transform.position.y;
+
+ return {
+ cx,
+ cy,
+ width: scaledWidth,
+ height: scaledHeight,
+ rotation: transform.rotate,
+ };
+}
+
+export function getElementBounds({
+ element,
+ canvasSize,
+ mediaAsset,
+}: {
+ element: TimelineElement;
+ canvasSize: { width: number; height: number };
+ mediaAsset?: MediaAsset | null;
+}): ElementBounds | null {
+ if (element.type === "audio") return null;
+ if ("hidden" in element && element.hidden) return null;
+
+ const { width: canvasWidth, height: canvasHeight } = canvasSize;
+
+ if (element.type === "video" || element.type === "image") {
+ const sourceWidth = mediaAsset?.width ?? canvasWidth;
+ const sourceHeight = mediaAsset?.height ?? canvasHeight;
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth,
+ sourceHeight,
+ transform: element.transform,
+ });
+ }
+
+ if (element.type === "sticker") {
+ return getVisualElementBounds({
+ canvasWidth,
+ canvasHeight,
+ sourceWidth: 200,
+ sourceHeight: 200,
+ transform: element.transform,
+ });
+ }
+
+ if (element.type === "text") {
+ const scaledFontSize =
+ element.fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
+ const letterSpacing = element.letterSpacing ?? 0;
+ const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
+ const lineHeightPx = scaledFontSize * lineHeight;
+
+ let measuredWidth = 100;
+ let measuredHeight = scaledFontSize;
+
+ const canvas = document.createElement("canvas");
+ canvas.width = 4096;
+ canvas.height = 4096;
+ const ctx = canvas.getContext("2d");
+
+ if (ctx) {
+ const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
+ const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
+ const fontFamily = `"${element.fontFamily.replace(/"/g, '\\"')}"`;
+ ctx.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
+ ctx.textAlign = element.textAlign as CanvasTextAlign;
+ if ("letterSpacing" in ctx) {
+ (
+ ctx as CanvasRenderingContext2D & { letterSpacing: string }
+ ).letterSpacing = `${letterSpacing}px`;
+ }
+
+ const lines = element.content.split("\n");
+ const lineMetrics = lines.map((line) => ctx.measureText(line));
+
+ let top = Number.POSITIVE_INFINITY;
+ let bottom = Number.NEGATIVE_INFINITY;
+ let maxWidth = 0;
+
+ for (let i = 0; i < lineMetrics.length; i++) {
+ const metrics = lineMetrics[i];
+ const y = i * lineHeightPx;
+ top = Math.min(
+ top,
+ y - (metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8),
+ );
+ bottom = Math.max(
+ bottom,
+ y + (metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2),
+ );
+ maxWidth = Math.max(maxWidth, metrics.width);
+ }
+
+ measuredWidth = maxWidth;
+ measuredHeight = bottom - top;
+ }
+
+ const width = measuredWidth * element.transform.scale;
+ const height = measuredHeight * element.transform.scale;
+ return {
+ cx: canvasWidth / 2 + element.transform.position.x,
+ cy: canvasHeight / 2 + element.transform.position.y,
+ width,
+ height,
+ rotation: element.transform.rotate,
+ };
+ }
+
+ return null;
+}
+
+export function getVisibleElementsWithBounds({
+ tracks,
+ currentTime,
+ canvasSize,
+ mediaAssets,
+}: {
+ tracks: TimelineTrack[];
+ currentTime: number;
+ canvasSize: { width: number; height: number };
+ mediaAssets: MediaAsset[];
+}): ElementWithBounds[] {
+ const mediaMap = new Map(mediaAssets.map((m) => [m.id, m]));
+ const visibleTracks = tracks.filter(
+ (track) => !("hidden" in track && track.hidden),
+ );
+ const orderedTracks = [
+ ...visibleTracks.filter((track) => !isMainTrack(track)),
+ ...visibleTracks.filter((track) => isMainTrack(track)),
+ ].reverse();
+
+ const result: ElementWithBounds[] = [];
+
+ for (const track of orderedTracks) {
+ const elements = track.elements
+ .filter((element) => !("hidden" in element && element.hidden))
+ .filter(
+ (element) =>
+ currentTime >= element.startTime &&
+ currentTime < element.startTime + element.duration,
+ )
+ .slice()
+ .sort((a, b) => {
+ if (a.startTime !== b.startTime) return a.startTime - b.startTime;
+ return a.id.localeCompare(b.id);
+ });
+
+ for (const element of elements) {
+ const mediaAsset =
+ element.type === "video" || element.type === "image"
+ ? mediaMap.get(element.mediaId)
+ : undefined;
+ const bounds = getElementBounds({
+ element,
+ canvasSize,
+ mediaAsset,
+ });
+ if (bounds) {
+ result.push({
+ trackId: track.id,
+ elementId: element.id,
+ element,
+ bounds,
+ });
+ }
+ }
+ }
+
+ return result;
+}
diff --git a/apps/web/src/lib/preview/hit-test.ts b/apps/web/src/lib/preview/hit-test.ts
new file mode 100644
index 00000000..c2fde87b
--- /dev/null
+++ b/apps/web/src/lib/preview/hit-test.ts
@@ -0,0 +1,60 @@
+import type { ElementWithBounds } from "./element-bounds";
+
+function pointInRotatedRect({
+ px,
+ py,
+ cx,
+ cy,
+ width,
+ height,
+ rotation,
+}: {
+ px: number;
+ py: number;
+ cx: number;
+ cy: number;
+ width: number;
+ height: number;
+ rotation: number;
+}): boolean {
+ const angleRad = (rotation * Math.PI) / 180;
+ const cos = Math.cos(-angleRad);
+ const sin = Math.sin(-angleRad);
+ const dx = px - cx;
+ const dy = py - cy;
+ const localX = dx * cos - dy * sin;
+ const localY = dx * sin + dy * cos;
+ const halfW = width / 2;
+ const halfH = height / 2;
+ return (
+ localX >= -halfW && localX <= halfW && localY >= -halfH && localY <= halfH
+ );
+}
+
+export function hitTest({
+ canvasX,
+ canvasY,
+ elementsWithBounds,
+}: {
+ canvasX: number;
+ canvasY: number;
+ elementsWithBounds: ElementWithBounds[];
+}): ElementWithBounds | null {
+ for (let i = elementsWithBounds.length - 1; i >= 0; i--) {
+ const { bounds } = elementsWithBounds[i];
+ if (
+ pointInRotatedRect({
+ px: canvasX,
+ py: canvasY,
+ cx: bounds.cx,
+ cy: bounds.cy,
+ width: bounds.width,
+ height: bounds.height,
+ rotation: bounds.rotation,
+ })
+ ) {
+ return elementsWithBounds[i];
+ }
+ }
+ return null;
+}
diff --git a/apps/web/src/lib/preview/preview-coords.ts b/apps/web/src/lib/preview/preview-coords.ts
new file mode 100644
index 00000000..08c16735
--- /dev/null
+++ b/apps/web/src/lib/preview/preview-coords.ts
@@ -0,0 +1,76 @@
+export function screenToCanvas({
+ clientX,
+ clientY,
+ canvas,
+}: {
+ clientX: number;
+ clientY: number;
+ canvas: HTMLCanvasElement;
+}): { x: number; y: number } {
+ const rect = canvas.getBoundingClientRect();
+ const scaleX = canvas.width / rect.width;
+ const scaleY = canvas.height / rect.height;
+ return {
+ x: (clientX - rect.left) * scaleX,
+ y: (clientY - rect.top) * scaleY,
+ };
+}
+
+export function canvasToOverlay({
+ canvasX,
+ canvasY,
+ canvasRect,
+ containerRect,
+ canvasSize,
+}: {
+ canvasX: number;
+ canvasY: number;
+ canvasRect: DOMRect;
+ containerRect: DOMRect;
+ canvasSize: { width: number; height: number };
+}): { x: number; y: number } {
+ const scaleX = canvasRect.width / canvasSize.width;
+ const scaleY = canvasRect.height / canvasSize.height;
+ return {
+ x: canvasRect.left - containerRect.left + canvasX * scaleX,
+ y: canvasRect.top - containerRect.top + canvasY * scaleY,
+ };
+}
+
+export function positionToOverlay({
+ positionX,
+ positionY,
+ canvasRect,
+ containerRect,
+ canvasSize,
+}: {
+ positionX: number;
+ positionY: number;
+ canvasRect: DOMRect;
+ containerRect: DOMRect;
+ canvasSize: { width: number; height: number };
+}): { x: number; y: number } {
+ const scaleX = canvasRect.width / canvasSize.width;
+ const scaleY = canvasRect.height / canvasSize.height;
+ const centerScreenX =
+ canvasRect.left - containerRect.left + (canvasSize.width / 2) * scaleX;
+ const centerScreenY =
+ canvasRect.top - containerRect.top + (canvasSize.height / 2) * scaleY;
+ return {
+ x: centerScreenX + positionX * scaleX,
+ y: centerScreenY + positionY * scaleY,
+ };
+}
+
+export function getDisplayScale({
+ canvasRect,
+ canvasSize,
+}: {
+ canvasRect: DOMRect;
+ canvasSize: { width: number; height: number };
+}): { x: number; y: number } {
+ return {
+ x: canvasRect.width / canvasSize.width,
+ y: canvasRect.height / canvasSize.height,
+ };
+}
diff --git a/apps/web/src/lib/preview/preview-snap.ts b/apps/web/src/lib/preview/preview-snap.ts
new file mode 100644
index 00000000..bd53f0ca
--- /dev/null
+++ b/apps/web/src/lib/preview/preview-snap.ts
@@ -0,0 +1,292 @@
+export interface SnapLine {
+ type: "horizontal" | "vertical";
+ position: number;
+}
+
+const SNAP_THRESHOLD = 10;
+const ROTATION_SNAP_STEP_DEGREES = 90;
+const ROTATION_SNAP_THRESHOLD_DEGREES = 5;
+export const MIN_SCALE = 0.01;
+
+export interface SnapResult {
+ snappedPosition: { x: number; y: number };
+ activeLines: SnapLine[];
+}
+
+export function snapPosition({
+ proposedPosition,
+ canvasSize,
+ elementSize,
+}: {
+ proposedPosition: { x: number; y: number };
+ canvasSize: { width: number; height: number };
+ elementSize: { width: number; height: number };
+}): SnapResult {
+ const centerX = 0;
+ const centerY = 0;
+ const left = -canvasSize.width / 2;
+ const right = canvasSize.width / 2;
+ const top = -canvasSize.height / 2;
+ const bottom = canvasSize.height / 2;
+
+ const halfWidth = elementSize.width / 2;
+ const halfHeight = elementSize.height / 2;
+ const activeLines: SnapLine[] = [];
+
+ type AxisSnapCandidate = {
+ snappedPosition: number;
+ line: SnapLine;
+ distance: number;
+ };
+
+ function getClosestAxisSnap({
+ candidates,
+ }: {
+ candidates: AxisSnapCandidate[];
+ }): AxisSnapCandidate | null {
+ const snapCandidatesWithinThreshold = candidates.filter(
+ (candidate) => candidate.distance <= SNAP_THRESHOLD,
+ );
+ if (snapCandidatesWithinThreshold.length === 0) {
+ return null;
+ }
+ return snapCandidatesWithinThreshold.reduce((closest, current) =>
+ current.distance < closest.distance ? current : closest,
+ );
+ }
+
+ const verticalTargets = [left, centerX, right];
+ const horizontalTargets = [top, centerY, bottom];
+
+ const xCandidates: AxisSnapCandidate[] = [];
+ for (const targetX of verticalTargets) {
+ xCandidates.push({
+ snappedPosition: targetX,
+ line: { type: "vertical", position: targetX },
+ distance: Math.abs(proposedPosition.x - targetX),
+ });
+ xCandidates.push({
+ snappedPosition: targetX + halfWidth,
+ line: { type: "vertical", position: targetX },
+ distance: Math.abs(proposedPosition.x - halfWidth - targetX),
+ });
+ xCandidates.push({
+ snappedPosition: targetX - halfWidth,
+ line: { type: "vertical", position: targetX },
+ distance: Math.abs(proposedPosition.x + halfWidth - targetX),
+ });
+ }
+ const yCandidates: AxisSnapCandidate[] = [];
+ for (const targetY of horizontalTargets) {
+ yCandidates.push({
+ snappedPosition: targetY,
+ line: { type: "horizontal", position: targetY },
+ distance: Math.abs(proposedPosition.y - targetY),
+ });
+ yCandidates.push({
+ snappedPosition: targetY + halfHeight,
+ line: { type: "horizontal", position: targetY },
+ distance: Math.abs(proposedPosition.y - halfHeight - targetY),
+ });
+ yCandidates.push({
+ snappedPosition: targetY - halfHeight,
+ line: { type: "horizontal", position: targetY },
+ distance: Math.abs(proposedPosition.y + halfHeight - targetY),
+ });
+ }
+
+ const closestX = getClosestAxisSnap({ candidates: xCandidates });
+ const closestY = getClosestAxisSnap({ candidates: yCandidates });
+
+ const x = closestX?.snappedPosition ?? proposedPosition.x;
+ const y = closestY?.snappedPosition ?? proposedPosition.y;
+ if (closestX) {
+ activeLines.push(closestX.line);
+ }
+ if (closestY) {
+ activeLines.push(closestY.line);
+ }
+
+ return {
+ snappedPosition: { x, y },
+ activeLines,
+ };
+}
+
+export interface ScaleSnapResult {
+ snappedScale: number;
+ activeLines: SnapLine[];
+}
+
+export function snapScale({
+ proposedScale,
+ position,
+ baseWidth,
+ baseHeight,
+ canvasSize,
+}: {
+ proposedScale: number;
+ position: { x: number; y: number };
+ baseWidth: number;
+ baseHeight: number;
+ canvasSize: { width: number; height: number };
+}): ScaleSnapResult {
+ const centerX = 0;
+ const centerY = 0;
+ const left = -canvasSize.width / 2;
+ const right = canvasSize.width / 2;
+ const top = -canvasSize.height / 2;
+ const bottom = canvasSize.height / 2;
+
+ const leftEdge = position.x - (baseWidth * proposedScale) / 2;
+ const rightEdge = position.x + (baseWidth * proposedScale) / 2;
+ const topEdge = position.y - (baseHeight * proposedScale) / 2;
+ const bottomEdge = position.y + (baseHeight * proposedScale) / 2;
+
+ interface SnapCandidate {
+ scale: number;
+ distance: number;
+ lines: SnapLine[];
+ }
+
+ const candidates: SnapCandidate[] = [];
+
+ const verticalTargets = [
+ { position: left, line: { type: "vertical" as const, position: left } },
+ {
+ position: centerX,
+ line: { type: "vertical" as const, position: centerX },
+ },
+ { position: right, line: { type: "vertical" as const, position: right } },
+ ];
+
+ for (const target of verticalTargets) {
+ const distanceLeft = Math.abs(leftEdge - target.position);
+ if (distanceLeft <= SNAP_THRESHOLD) {
+ const scale = (2 * (position.x - target.position)) / baseWidth;
+ if (scale > MIN_SCALE) {
+ candidates.push({
+ scale,
+ distance: distanceLeft,
+ lines: [target.line],
+ });
+ }
+ }
+ const distanceRight = Math.abs(rightEdge - target.position);
+ if (distanceRight <= SNAP_THRESHOLD) {
+ const scale = (2 * (target.position - position.x)) / baseWidth;
+ if (scale > MIN_SCALE) {
+ candidates.push({
+ scale,
+ distance: distanceRight,
+ lines: [target.line],
+ });
+ }
+ }
+ }
+
+ const horizontalTargets = [
+ { position: top, line: { type: "horizontal" as const, position: top } },
+ {
+ position: centerY,
+ line: { type: "horizontal" as const, position: centerY },
+ },
+ {
+ position: bottom,
+ line: { type: "horizontal" as const, position: bottom },
+ },
+ ];
+
+ for (const target of horizontalTargets) {
+ const distanceTop = Math.abs(topEdge - target.position);
+ if (distanceTop <= SNAP_THRESHOLD) {
+ const scale = (2 * (position.y - target.position)) / baseHeight;
+ if (scale > MIN_SCALE) {
+ candidates.push({
+ scale,
+ distance: distanceTop,
+ lines: [target.line],
+ });
+ }
+ }
+ const distanceBottom = Math.abs(bottomEdge - target.position);
+ if (distanceBottom <= SNAP_THRESHOLD) {
+ const scale = (2 * (target.position - position.y)) / baseHeight;
+ if (scale > MIN_SCALE) {
+ candidates.push({
+ scale,
+ distance: distanceBottom,
+ lines: [target.line],
+ });
+ }
+ }
+ }
+
+ if (candidates.length === 0) {
+ return { snappedScale: proposedScale, activeLines: [] };
+ }
+
+ const best = candidates.reduce((acc, candidate) =>
+ candidate.distance < acc.distance ? candidate : acc,
+ );
+
+ const snappedLeft = position.x - (baseWidth * best.scale) / 2;
+ const snappedRight = position.x + (baseWidth * best.scale) / 2;
+ const snappedTop = position.y - (baseHeight * best.scale) / 2;
+ const snappedBottom = position.y + (baseHeight * best.scale) / 2;
+
+ const activeLines: SnapLine[] = [];
+ const seenKeys = new Set<string>();
+
+ function addLine({ line }: { line: SnapLine }) {
+ const key = `${line.type}-${line.position}`;
+ if (!seenKeys.has(key)) {
+ seenKeys.add(key);
+ activeLines.push(line);
+ }
+ }
+
+ for (const target of verticalTargets) {
+ if (
+ Math.abs(snappedLeft - target.position) <= 1 ||
+ Math.abs(snappedRight - target.position) <= 1
+ ) {
+ addLine({ line: target.line });
+ }
+ }
+ for (const target of horizontalTargets) {
+ if (
+ Math.abs(snappedTop - target.position) <= 1 ||
+ Math.abs(snappedBottom - target.position) <= 1
+ ) {
+ addLine({ line: target.line });
+ }
+ }
+
+ return {
+ snappedScale: best.scale,
+ activeLines,
+ };
+}
+
+export interface RotationSnapResult {
+ snappedRotation: number;
+ isSnapped: boolean;
+}
+
+export function snapRotation({
+ proposedRotation,
+}: {
+ proposedRotation: number;
+}): RotationSnapResult {
+ const nearestRotationSnap =
+ Math.round(proposedRotation / ROTATION_SNAP_STEP_DEGREES) *
+ ROTATION_SNAP_STEP_DEGREES;
+ const distanceToNearestSnap = Math.abs(
+ proposedRotation - nearestRotationSnap,
+ );
+ if (distanceToNearestSnap <= ROTATION_SNAP_THRESHOLD_DEGREES) {
+ return { snappedRotation: nearestRotationSnap, isSnapped: true };
+ }
+ return { snappedRotation: proposedRotation, isSnapped: false };
+}
diff --git a/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts b/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts
new file mode 100644
index 00000000..573dc251
--- /dev/null
+++ b/apps/web/src/lib/stickers/__tests__/sticker-id.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, test } from "bun:test";
+import { buildStickerId, parseStickerId } from "../sticker-id";
+
+describe("sticker-id strict mode", () => {
+ test("parses provider-prefixed IDs", () => {
+ expect(parseStickerId({ stickerId: "icons:mdi:home" })).toEqual({
+ providerId: "icons",
+ providerValue: "mdi:home",
+ });
+ expect(parseStickerId({ stickerId: "emoji:noto:grinning-face" })).toEqual({
+ providerId: "emoji",
+ providerValue: "noto:grinning-face",
+ });
+ });
+
+ test("throws for IDs without provider prefix", () => {
+ expect(() => parseStickerId({ stickerId: "home" })).toThrow();
+ });
+
+ test("throws for malformed IDs", () => {
+ expect(() => parseStickerId({ stickerId: "" })).toThrow();
+ expect(() => parseStickerId({ stickerId: "icons:" })).toThrow();
+ expect(() => parseStickerId({ stickerId: ":mdi:home" })).toThrow();
+ });
+
+ test("builds sticker IDs unchanged", () => {
+ expect(
+ buildStickerId({
+ providerId: "flags",
+ providerValue: "US",
+ }),
+ ).toBe("flags:US");
+ });
+});
diff --git a/apps/web/src/lib/stickers/index.ts b/apps/web/src/lib/stickers/index.ts
new file mode 100644
index 00000000..1f924c6d
--- /dev/null
+++ b/apps/web/src/lib/stickers/index.ts
@@ -0,0 +1,175 @@
+import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
+import type { StickerCategory } from "@/types/stickers";
+import { getAllProviders, getProvider } from "./registry";
+import { resolveStickerId } from "./resolver";
+import { registerDefaultStickerProviders } from "./providers";
+import type { StickerProvider, StickerSearchResult } from "./types";
+
+const DEFAULT_SEARCH_LIMIT = 100;
+
+function mergeSearchResults({
+ results,
+}: {
+ results: StickerSearchResult[];
+}): StickerSearchResult {
+ const deduplicatedItems = new Map<
+ string,
+ StickerSearchResult["items"][number]
+ >();
+ let total = 0;
+ let hasMore = false;
+
+ for (const result of results) {
+ total += result.total;
+ hasMore = hasMore || result.hasMore;
+ for (const item of result.items) {
+ if (!deduplicatedItems.has(item.id)) {
+ deduplicatedItems.set(item.id, item);
+ }
+ }
+ }
+
+ return {
+ items: Array.from(deduplicatedItems.values()),
+ total,
+ hasMore,
+ };
+}
+
+function getProviderByCategory({
+ category,
+}: {
+ category: StickerCategory;
+}): StickerProvider | null {
+ if (category === "all") {
+ return null;
+ }
+
+ try {
+ return getProvider({ providerId: category });
+ } catch {
+ return null;
+ }
+}
+
+export async function searchStickers({
+ query,
+ category,
+ limit = DEFAULT_SEARCH_LIMIT,
+}: {
+ query: string;
+ category: StickerCategory;
+ limit?: number;
+}): Promise<StickerSearchResult> {
+ registerDefaultStickerProviders({});
+
+ const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
+ if (effectiveCategory !== "all") {
+ const provider = getProviderByCategory({ category: effectiveCategory });
+ if (!provider) {
+ return {
+ items: [],
+ total: 0,
+ hasMore: false,
+ };
+ }
+ return provider.search({
+ query,
+ options: { limit },
+ });
+ }
+
+ const providers = getAllProviders();
+ if (providers.length === 0) {
+ return {
+ items: [],
+ total: 0,
+ hasMore: false,
+ };
+ }
+
+ const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
+ const settledResults = await Promise.allSettled(
+ providers.map((provider) =>
+ provider.search({
+ query,
+ options: { limit: perProviderLimit },
+ }),
+ ),
+ );
+
+ const fulfilledResults = settledResults
+ .filter(
+ (result): result is PromiseFulfilledResult<StickerSearchResult> =>
+ result.status === "fulfilled",
+ )
+ .map((result) => result.value);
+
+ return mergeSearchResults({
+ results: fulfilledResults,
+ });
+}
+
+export async function browseStickers({
+ category,
+ page = 1,
+ limit = DEFAULT_SEARCH_LIMIT,
+}: {
+ category: StickerCategory;
+ page?: number;
+ limit?: number;
+}): Promise<StickerSearchResult> {
+ registerDefaultStickerProviders({});
+
+ const effectiveCategory = category in STICKER_CATEGORIES ? category : "all";
+ if (effectiveCategory !== "all") {
+ const provider = getProviderByCategory({ category: effectiveCategory });
+ if (!provider) {
+ return {
+ items: [],
+ total: 0,
+ hasMore: false,
+ };
+ }
+ return provider.browse({
+ options: { page, limit },
+ });
+ }
+
+ const providers = getAllProviders();
+ if (providers.length === 0) {
+ return {
+ items: [],
+ total: 0,
+ hasMore: false,
+ };
+ }
+
+ const perProviderLimit = Math.max(1, Math.ceil(limit / providers.length));
+ const settledResults = await Promise.allSettled(
+ providers.map((provider) =>
+ provider.browse({
+ options: { page, limit: perProviderLimit },
+ }),
+ ),
+ );
+
+ const fulfilledResults = settledResults
+ .filter(
+ (result): result is PromiseFulfilledResult<StickerSearchResult> =>
+ result.status === "fulfilled",
+ )
+ .map((result) => result.value);
+
+ return mergeSearchResults({
+ results: fulfilledResults,
+ });
+}
+
+export { resolveStickerId };
+export type {
+ StickerItem,
+ StickerProvider,
+ StickerResolveOptions,
+ StickerSearchResult,
+} from "./types";
diff --git a/apps/web/src/lib/stickers/providers/emoji.ts b/apps/web/src/lib/stickers/providers/emoji.ts
new file mode 100644
index 00000000..cc198236
--- /dev/null
+++ b/apps/web/src/lib/stickers/providers/emoji.ts
@@ -0,0 +1,119 @@
+import {
+ POPULAR_COLLECTIONS,
+ getIconSvgUrl,
+ searchIcons,
+} from "@/lib/iconify-api";
+import { buildStickerId, parseStickerId } from "../sticker-id";
+import type {
+ StickerItem,
+ StickerProvider,
+ StickerSearchResult,
+} from "../types";
+
+const EMOJI_PROVIDER_ID = "emoji";
+const DEFAULT_SEARCH_LIMIT = 100;
+
+const EMOJI_PREFIXES = POPULAR_COLLECTIONS.emoji.map(
+ (collection) => collection.prefix,
+);
+
+const DEFAULT_EMOJI_BROWSE = [
+ "noto:grinning-face",
+ "noto:smiling-face-with-heart-eyes",
+ "noto:fire",
+ "noto:rocket",
+ "noto:party-popper",
+ "noto:clapping-hands",
+ "noto:sparkles",
+ "noto:red-heart",
+ "noto:thumbs-up",
+ "noto:eyes",
+ "noto:thinking-face",
+ "noto:hundred-points",
+];
+
+function getDisplayNameFromIconName({
+ iconName,
+}: {
+ iconName: string;
+}): string {
+ const parts = iconName.split(":");
+ const rawName = parts[parts.length - 1] ?? iconName;
+ return rawName.replaceAll("-", " ").replaceAll("_", " ");
+}
+
+function toStickerItem({ iconName }: { iconName: string }): StickerItem {
+ return {
+ id: buildStickerId({
+ providerId: EMOJI_PROVIDER_ID,
+ providerValue: iconName,
+ }),
+ provider: EMOJI_PROVIDER_ID,
+ name: getDisplayNameFromIconName({ iconName }),
+ previewUrl: getIconSvgUrl(iconName, { width: 64, height: 64 }),
+ metadata: { iconName },
+ };
+}
+
+function computeHasMore({
+ total,
+ limit,
+ start = 0,
+}: {
+ total: number;
+ limit: number;
+ start?: number;
+}): boolean {
+ return start + limit < total;
+}
+
+export const emojiProvider: StickerProvider = {
+ id: EMOJI_PROVIDER_ID,
+ async search({
+ query,
+ options,
+ }: {
+ query: string;
+ options?: { limit?: number };
+ }): Promise<StickerSearchResult> {
+ const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT;
+ const searchResult = await searchIcons(query, limit, EMOJI_PREFIXES);
+ return {
+ items: searchResult.icons.map((iconName) => toStickerItem({ iconName })),
+ total: searchResult.total,
+ hasMore: computeHasMore({
+ total: searchResult.total,
+ limit: searchResult.limit,
+ start: searchResult.start,
+ }),
+ };
+ },
+ async browse({
+ options,
+ }: {
+ options?: { page?: number; limit?: number };
+ }): Promise<StickerSearchResult> {
+ const limit = options?.limit ?? DEFAULT_EMOJI_BROWSE.length;
+ const items = DEFAULT_EMOJI_BROWSE.slice(0, limit).map((iconName) =>
+ toStickerItem({ iconName }),
+ );
+ return {
+ items,
+ total: items.length,
+ hasMore: false,
+ };
+ },
+ resolveUrl({
+ stickerId,
+ options,
+ }: {
+ stickerId: string;
+ options?: { width?: number; height?: number };
+ }): string {
+ const { providerValue } = parseStickerId({ stickerId });
+ return getIconSvgUrl(providerValue, {
+ width: options?.width,
+ height: options?.height,
+ });
+ },
+};
diff --git a/apps/web/src/lib/stickers/providers/flags.ts b/apps/web/src/lib/stickers/providers/flags.ts
new file mode 100644
index 00000000..91cc5489
--- /dev/null
+++ b/apps/web/src/lib/stickers/providers/flags.ts
@@ -0,0 +1,167 @@
+import { buildStickerId, parseStickerId } from "../sticker-id";
+import type {
+ StickerItem,
+ StickerProvider,
+ StickerSearchResult,
+} from "../types";
+
+const FLAGS_PROVIDER_ID = "flags";
+const FLAGS_DATASET_URL = "/countries.json";
+const DEFAULT_SEARCH_LIMIT = 100;
+const DEFAULT_FLAGS_BASE_URL = "/flags";
+
+type CountryRecord = {
+ name: string;
+ code: string;
+ languages?: string[];
+ flag_colors?: string[];
+ region?: string;
+};
+
+let countriesPromise: Promise<CountryRecord[]> | null = null;
+
+function getFlagsBaseUrl(): string {
+ return DEFAULT_FLAGS_BASE_URL.replace(/\/$/, "");
+}
+
+function buildFlagUrl({ code }: { code: string }): string {
+ const normalizedCode = code.toUpperCase();
+ return `${getFlagsBaseUrl()}/${encodeURIComponent(normalizedCode)}.svg`;
+}
+
+async function loadCountries(): Promise<CountryRecord[]> {
+ if (countriesPromise) {
+ return countriesPromise;
+ }
+
+ countriesPromise = fetch(FLAGS_DATASET_URL)
+ .then(async (response) => {
+ if (!response.ok) {
+ throw new Error(`Failed to load countries: ${response.status}`);
+ }
+ return (await response.json()) as CountryRecord[];
+ })
+ .catch((error) => {
+ console.error("Failed to load countries dataset:", error);
+ return [];
+ });
+
+ return countriesPromise;
+}
+
+function toStickerItem({ country }: { country: CountryRecord }): StickerItem {
+ const normalizedCode = country.code.toUpperCase();
+ return {
+ id: buildStickerId({
+ providerId: FLAGS_PROVIDER_ID,
+ providerValue: normalizedCode,
+ }),
+ provider: FLAGS_PROVIDER_ID,
+ name: country.name,
+ previewUrl: buildFlagUrl({ code: normalizedCode }),
+ metadata: {
+ code: normalizedCode,
+ region: country.region ?? null,
+ languages: country.languages ?? [],
+ flagColors: country.flag_colors ?? [],
+ },
+ };
+}
+
+function normalizeQuery({ query }: { query: string }): string {
+ return query.trim().toLowerCase();
+}
+
+function filterCountriesByQuery({
+ countries,
+ query,
+}: {
+ countries: CountryRecord[];
+ query: string;
+}): CountryRecord[] {
+ if (!query) {
+ return countries;
+ }
+
+ return countries.filter((country) => {
+ const normalizedName = country.name.toLowerCase();
+ const normalizedCode = country.code.toLowerCase();
+ const normalizedRegion = country.region?.toLowerCase() ?? "";
+ return (
+ normalizedName.includes(query) ||
+ normalizedCode.includes(query) ||
+ normalizedRegion.includes(query)
+ );
+ });
+}
+
+function paginateCountries({
+ countries,
+ options,
+}: {
+ countries: CountryRecord[];
+ options?: { page?: number; limit?: number };
+}): { items: CountryRecord[]; hasMore: boolean; total: number } {
+ const page = Math.max(1, options?.page ?? 1);
+ const limit = Math.max(1, options?.limit ?? DEFAULT_SEARCH_LIMIT);
+ const startIndex = (page - 1) * limit;
+ const endIndex = startIndex + limit;
+ const pagedItems = countries.slice(startIndex, endIndex);
+ return {
+ items: pagedItems,
+ hasMore: endIndex < countries.length,
+ total: countries.length,
+ };
+}
+
+export const flagsProvider: StickerProvider = {
+ id: FLAGS_PROVIDER_ID,
+ async search({
+ query,
+ options,
+ }: {
+ query: string;
+ options?: { limit?: number };
+ }): Promise<StickerSearchResult> {
+ const countries = await loadCountries();
+ const normalizedQuery = normalizeQuery({ query });
+ const filteredCountries = filterCountriesByQuery({
+ countries,
+ query: normalizedQuery,
+ });
+ const paged = paginateCountries({
+ countries: filteredCountries,
+ options: {
+ page: 1,
+ limit: options?.limit ?? DEFAULT_SEARCH_LIMIT,
+ },
+ });
+ return {
+ items: paged.items.map((country) => toStickerItem({ country })),
+ total: paged.total,
+ hasMore: paged.hasMore,
+ };
+ },
+ async browse({
+ options,
+ }: {
+ options?: { page?: number; limit?: number };
+ }): Promise<StickerSearchResult> {
+ const countries = await loadCountries();
+ const paged = paginateCountries({ countries, options });
+ return {
+ items: paged.items.map((country) => toStickerItem({ country })),
+ total: paged.total,
+ hasMore: paged.hasMore,
+ };
+ },
+ resolveUrl({
+ stickerId,
+ }: {
+ stickerId: string;
+ options?: { width?: number; height?: number };
+ }): string {
+ const { providerValue } = parseStickerId({ stickerId });
+ return buildFlagUrl({ code: providerValue });
+ },
+};
diff --git a/apps/web/src/lib/stickers/providers/icons.ts b/apps/web/src/lib/stickers/providers/icons.ts
new file mode 100644
index 00000000..06b2a4c2
--- /dev/null
+++ b/apps/web/src/lib/stickers/providers/icons.ts
@@ -0,0 +1,122 @@
+import {
+ POPULAR_COLLECTIONS,
+ getIconSvgUrl,
+ searchIcons,
+} from "@/lib/iconify-api";
+import { buildStickerId, parseStickerId } from "../sticker-id";
+import type {
+ StickerItem,
+ StickerProvider,
+ StickerSearchResult,
+} from "../types";
+
+const ICONS_PROVIDER_ID = "icons";
+const DEFAULT_SEARCH_LIMIT = 100;
+
+const ICONS_PREFIXES = Array.from(
+ new Set(
+ [...POPULAR_COLLECTIONS.general, ...POPULAR_COLLECTIONS.brands].map(
+ (collection) => collection.prefix,
+ ),
+ ),
+);
+
+const DEFAULT_ICONS_BROWSE = [
+ "mdi:home",
+ "mdi:star",
+ "mdi:heart",
+ "mdi:check-circle",
+ "mdi:account",
+ "mdi:camera",
+ "mdi:music",
+ "mdi:map-marker",
+ "mdi:calendar",
+ "mdi:lightning-bolt",
+ "mdi:cog",
+ "mdi:rocket",
+];
+
+function getDisplayNameFromIconName({
+ iconName,
+}: {
+ iconName: string;
+}): string {
+ const [, rawName = iconName] = iconName.split(":");
+ return rawName.replaceAll("-", " ").replaceAll("_", " ");
+}
+
+function toStickerItem({ iconName }: { iconName: string }): StickerItem {
+ return {
+ id: buildStickerId({
+ providerId: ICONS_PROVIDER_ID,
+ providerValue: iconName,
+ }),
+ provider: ICONS_PROVIDER_ID,
+ name: getDisplayNameFromIconName({ iconName }),
+ previewUrl: getIconSvgUrl(iconName, { width: 64, height: 64 }),
+ metadata: { iconName },
+ };
+}
+
+function computeHasMore({
+ total,
+ limit,
+ start = 0,
+}: {
+ total: number;
+ limit: number;
+ start?: number;
+}): boolean {
+ return start + limit < total;
+}
+
+export const iconsProvider: StickerProvider = {
+ id: ICONS_PROVIDER_ID,
+ async search({
+ query,
+ options,
+ }: {
+ query: string;
+ options?: { limit?: number };
+ }): Promise<StickerSearchResult> {
+ const limit = options?.limit ?? DEFAULT_SEARCH_LIMIT;
+ const searchResult = await searchIcons(query, limit, ICONS_PREFIXES);
+ return {
+ items: searchResult.icons.map((iconName) => toStickerItem({ iconName })),
+ total: searchResult.total,
+ hasMore: computeHasMore({
+ total: searchResult.total,
+ limit: searchResult.limit,
+ start: searchResult.start,
+ }),
+ };
+ },
+ async browse({
+ options,
+ }: {
+ options?: { page?: number; limit?: number };
+ }): Promise<StickerSearchResult> {
+ const limit = options?.limit ?? DEFAULT_ICONS_BROWSE.length;
+ const items = DEFAULT_ICONS_BROWSE.slice(0, limit).map((iconName) =>
+ toStickerItem({ iconName }),
+ );
+ return {
+ items,
+ total: items.length,
+ hasMore: false,
+ };
+ },
+ resolveUrl({
+ stickerId,
+ options,
+ }: {
+ stickerId: string;
+ options?: { width?: number; height?: number };
+ }): string {
+ const { providerValue } = parseStickerId({ stickerId });
+ return getIconSvgUrl(providerValue, {
+ width: options?.width,
+ height: options?.height,
+ });
+ },
+};
diff --git a/apps/web/src/lib/stickers/providers/index.ts b/apps/web/src/lib/stickers/providers/index.ts
new file mode 100644
index 00000000..3c4faee9
--- /dev/null
+++ b/apps/web/src/lib/stickers/providers/index.ts
@@ -0,0 +1,26 @@
+import { hasProvider, registerProvider } from "../registry";
+import type { StickerProvider } from "@/types/stickers";
+import { emojiProvider } from "./emoji";
+import { flagsProvider } from "./flags";
+import { iconsProvider } from "./icons";
+import { shapesProvider } from "./shapes";
+
+const defaultProviders: StickerProvider[] = [
+ iconsProvider,
+ emojiProvider,
+ flagsProvider,
+ shapesProvider,
+];
+
+export function registerDefaultStickerProviders({
+ providersToRegister = defaultProviders,
+}: {
+ providersToRegister?: StickerProvider[];
+} = {}): void {
+ for (const provider of providersToRegister) {
+ if (hasProvider({ providerId: provider.id })) {
+ continue;
+ }
+ registerProvider({ provider });
+ }
+}
diff --git a/apps/web/src/lib/stickers/providers/shapes.ts b/apps/web/src/lib/stickers/providers/shapes.ts
new file mode 100644
index 00000000..81873dcb
--- /dev/null
+++ b/apps/web/src/lib/stickers/providers/shapes.ts
@@ -0,0 +1,118 @@
+import { buildStickerId, parseStickerId } from "../sticker-id";
+import type {
+ StickerItem,
+ StickerProvider,
+ StickerSearchResult,
+} from "../types";
+
+const SHAPES_PROVIDER_ID = "shapes";
+
+const SHAPES = [
+ { key: "circle", name: "Circle" },
+ { key: "square", name: "Square" },
+ { key: "triangle", name: "Triangle" },
+ { key: "star", name: "Star" },
+ { key: "hexagon", name: "Hexagon" },
+ { key: "diamond", name: "Diamond" },
+] as const;
+
+function buildShapeUrl({ shapeKey }: { shapeKey: string }): string {
+ return `/shapes/${shapeKey}.svg`;
+}
+
+function toStickerItem({
+ shape,
+}: {
+ shape: (typeof SHAPES)[number];
+}): StickerItem {
+ return {
+ id: buildStickerId({
+ providerId: SHAPES_PROVIDER_ID,
+ providerValue: shape.key,
+ }),
+ provider: SHAPES_PROVIDER_ID,
+ name: shape.name,
+ previewUrl: buildShapeUrl({ shapeKey: shape.key }),
+ metadata: { shape: shape.key },
+ };
+}
+
+function filterShapesByQuery({
+ query,
+}: {
+ query: string;
+}): Array<(typeof SHAPES)[number]> {
+ const normalizedQuery = query.trim().toLowerCase();
+ if (!normalizedQuery) {
+ return [...SHAPES];
+ }
+
+ return SHAPES.filter((shape) =>
+ shape.name.toLowerCase().includes(normalizedQuery),
+ );
+}
+
+function paginateShapes({
+ shapes,
+ options,
+}: {
+ shapes: Array<(typeof SHAPES)[number]>;
+ options?: { page?: number; limit?: number };
+}): { items: Array<(typeof SHAPES)[number]>; hasMore: boolean; total: number } {
+ const page = Math.max(1, options?.page ?? 1);
+ const limit = Math.max(1, options?.limit ?? SHAPES.length);
+ const startIndex = (page - 1) * limit;
+ const endIndex = startIndex + limit;
+ const pagedItems = shapes.slice(startIndex, endIndex);
+ return {
+ items: pagedItems,
+ hasMore: endIndex < shapes.length,
+ total: shapes.length,
+ };
+}
+
+export const shapesProvider: StickerProvider = {
+ id: SHAPES_PROVIDER_ID,
+ async search({
+ query,
+ options,
+ }: {
+ query: string;
+ options?: { limit?: number };
+ }): Promise<StickerSearchResult> {
+ const filteredShapes = filterShapesByQuery({ query });
+ const paged = paginateShapes({
+ shapes: filteredShapes,
+ options: { page: 1, limit: options?.limit ?? SHAPES.length },
+ });
+ return {
+ items: paged.items.map((shape) => toStickerItem({ shape })),
+ total: paged.total,
+ hasMore: paged.hasMore,
+ };
+ },
+ async browse({
+ options,
+ }: {
+ options?: { page?: number; limit?: number };
+ }): Promise<StickerSearchResult> {
+ const paged = paginateShapes({
+ shapes: [...SHAPES],
+ options,
+ });
+ return {
+ items: paged.items.map((shape) => toStickerItem({ shape })),
+ total: paged.total,
+ hasMore: paged.hasMore,
+ };
+ },
+ resolveUrl({
+ stickerId,
+ }: {
+ stickerId: string;
+ options?: { width?: number; height?: number };
+ }): string {
+ const { providerValue } = parseStickerId({ stickerId });
+ return buildShapeUrl({ shapeKey: providerValue });
+ },
+};
diff --git a/apps/web/src/lib/stickers/registry.ts b/apps/web/src/lib/stickers/registry.ts
new file mode 100644
index 00000000..0fbad594
--- /dev/null
+++ b/apps/web/src/lib/stickers/registry.ts
@@ -0,0 +1,31 @@
+import type { StickerProvider } from "@/types/stickers";
+
+const providers = new Map<string, StickerProvider>();
+
+export function registerProvider({
+ provider,
+}: {
+ provider: StickerProvider;
+}): void {
+ providers.set(provider.id, provider);
+}
+
+export function hasProvider({ providerId }: { providerId: string }): boolean {
+ return providers.has(providerId);
+}
+
+export function getProvider({
+ providerId,
+}: {
+ providerId: string;
+}): StickerProvider {
+ const provider = providers.get(providerId);
+ if (!provider) {
+ throw new Error(`Unknown sticker provider: ${providerId}`);
+ }
+ return provider;
+}
+
+export function getAllProviders(): StickerProvider[] {
+ return Array.from(providers.values());
+}
diff --git a/apps/web/src/lib/stickers/resolver.ts b/apps/web/src/lib/stickers/resolver.ts
new file mode 100644
index 00000000..3252cd60
--- /dev/null
+++ b/apps/web/src/lib/stickers/resolver.ts
@@ -0,0 +1,22 @@
+import { getProvider } from "./registry";
+import { parseStickerId } from "./sticker-id";
+import { registerDefaultStickerProviders } from "./providers";
+import type { StickerResolveOptions } from "@/types/stickers";
+
+export function resolveStickerId({
+ stickerId,
+ options,
+}: {
+ stickerId: string;
+ options?: StickerResolveOptions;
+}): string {
+ registerDefaultStickerProviders();
+
+ const parsedStickerId = parseStickerId({ stickerId });
+ return getProvider({
+ providerId: parsedStickerId.providerId,
+ }).resolveUrl({
+ stickerId,
+ options,
+ });
+}
diff --git a/apps/web/src/lib/stickers/sticker-id.ts b/apps/web/src/lib/stickers/sticker-id.ts
new file mode 100644
index 00000000..8cb9364b
--- /dev/null
+++ b/apps/web/src/lib/stickers/sticker-id.ts
@@ -0,0 +1,34 @@
+export function parseStickerId({ stickerId }: { stickerId: string }): {
+ providerId: string;
+ providerValue: string;
+} {
+ const normalizedStickerId = stickerId.trim();
+ if (!normalizedStickerId) {
+ throw new Error("Sticker ID must be a non-empty string");
+ }
+
+ const separatorIndex = normalizedStickerId.indexOf(":");
+ if (
+ separatorIndex <= 0 ||
+ separatorIndex === normalizedStickerId.length - 1
+ ) {
+ throw new Error(
+ `Invalid sticker ID format: "${stickerId}". Expected "provider:value".`,
+ );
+ }
+
+ const providerId = normalizedStickerId.slice(0, separatorIndex).trim();
+ const providerValue = normalizedStickerId.slice(separatorIndex + 1).trim();
+
+ return { providerId, providerValue };
+}
+
+export function buildStickerId({
+ providerId,
+ providerValue,
+}: {
+ providerId: string;
+ providerValue: string;
+}): string {
+ return `${providerId}:${providerValue}`;
+}
diff --git a/apps/web/src/lib/stickers/types.ts b/apps/web/src/lib/stickers/types.ts
new file mode 100644
index 00000000..91cbcaae
--- /dev/null
+++ b/apps/web/src/lib/stickers/types.ts
@@ -0,0 +1,8 @@
+export type {
+ StickerItem,
+ StickerProvider,
+ StickerProviderBrowseOptions,
+ StickerProviderSearchOptions,
+ StickerResolveOptions,
+ StickerSearchResult,
+} from "@/types/stickers";
diff --git a/apps/web/src/lib/timeline/bookmarks.ts b/apps/web/src/lib/timeline/bookmarks.ts
index 0b06c2f6..efaa8f74 100644
--- a/apps/web/src/lib/timeline/bookmarks.ts
+++ b/apps/web/src/lib/timeline/bookmarks.ts
@@ -1,14 +1,27 @@
+import type { Bookmark } from "@/types/timeline";
import { roundToFrame } from "@/lib/time";
+export const BOOKMARK_TIME_EPSILON = 0.001;
+
+function bookmarkTimeEqual({
+ bookmarkTime,
+ frameTime,
+}: {
+ bookmarkTime: number;
+ frameTime: number;
+}): boolean {
+ return Math.abs(bookmarkTime - frameTime) < BOOKMARK_TIME_EPSILON;
+}
+
export function findBookmarkIndex({
bookmarks,
frameTime,
}: {
- bookmarks: number[];
+ bookmarks: Bookmark[];
frameTime: number;
}): number {
- return bookmarks.findIndex(
- (bookmark) => Math.abs(bookmark - frameTime) < 0.001,
+ return bookmarks.findIndex((bookmark) =>
+ bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
);
}
@@ -16,40 +29,84 @@ export function isBookmarkAtTime({
bookmarks,
frameTime,
}: {
- bookmarks: number[];
+ bookmarks: Bookmark[];
frameTime: number;
}): boolean {
- return bookmarks.some((bookmark) => Math.abs(bookmark - frameTime) < 0.001);
+ return bookmarks.some((bookmark) =>
+ bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
+ );
}
export function toggleBookmarkInArray({
bookmarks,
frameTime,
}: {
- bookmarks: number[];
+ bookmarks: Bookmark[];
frameTime: number;
-}): number[] {
+}): Bookmark[] {
const bookmarkIndex = findBookmarkIndex({ bookmarks, frameTime });
if (bookmarkIndex !== -1) {
- return bookmarks.filter((_, i) => i !== bookmarkIndex);
+ return bookmarks.filter((_, index) => index !== bookmarkIndex);
}
- return [...bookmarks, frameTime].sort((a, b) => a - b);
+ const newBookmarks = [...bookmarks, { time: frameTime }];
+ return newBookmarks.slice().sort((a, b) => a.time - b.time);
}
export function removeBookmarkFromArray({
bookmarks,
frameTime,
}: {
- bookmarks: number[];
+ bookmarks: Bookmark[];
frameTime: number;
-}): number[] {
+}): Bookmark[] {
return bookmarks.filter(
- (bookmark) => Math.abs(bookmark - frameTime) >= 0.001,
+ (bookmark) =>
+ !bookmarkTimeEqual({ bookmarkTime: bookmark.time, frameTime }),
);
}
+export function updateBookmarkInArray({
+ bookmarks,
+ frameTime,
+ updates,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+ updates: Partial<Omit<Bookmark, "time">>;
+}): Bookmark[] {
+ const index = findBookmarkIndex({ bookmarks, frameTime });
+ if (index === -1) {
+ return bookmarks;
+ }
+
+ const updated = { ...bookmarks[index], ...updates };
+ const result = [...bookmarks];
+ result[index] = updated;
+ return result;
+}
+
+export function moveBookmarkInArray({
+ bookmarks,
+ fromTime,
+ toTime,
+}: {
+ bookmarks: Bookmark[];
+ fromTime: number;
+ toTime: number;
+}): Bookmark[] {
+ const index = findBookmarkIndex({ bookmarks, frameTime: fromTime });
+ if (index === -1) {
+ return bookmarks;
+ }
+
+ const updated = { ...bookmarks[index], time: toTime };
+ const result = [...bookmarks];
+ result[index] = updated;
+ return result.slice().sort((a, b) => a.time - b.time);
+}
+
export function getFrameTime({
time,
fps,
@@ -59,3 +116,34 @@ export function getFrameTime({
}): number {
return roundToFrame({ time, fps });
}
+
+export function getBookmarkAtTime({
+ bookmarks,
+ frameTime,
+}: {
+ bookmarks: Bookmark[];
+ frameTime: number;
+}): Bookmark | null {
+ const index = findBookmarkIndex({ bookmarks, frameTime });
+ return index === -1 ? null : bookmarks[index];
+}
+
+export function getBookmarksActiveAtTime({
+ bookmarks,
+ time,
+}: {
+ bookmarks: Bookmark[];
+ time: number;
+}): Bookmark[] {
+ return bookmarks.filter((bookmark) => {
+ const start = bookmark.time;
+ const end =
+ bookmark.duration != null && bookmark.duration > 0
+ ? start + bookmark.duration
+ : start;
+ return (
+ time >= start - BOOKMARK_TIME_EPSILON &&
+ time <= end + BOOKMARK_TIME_EPSILON
+ );
+ });
+}
diff --git a/apps/web/src/lib/timeline/drag-utils.ts b/apps/web/src/lib/timeline/drag-utils.ts
new file mode 100644
index 00000000..43d896ae
--- /dev/null
+++ b/apps/web/src/lib/timeline/drag-utils.ts
@@ -0,0 +1,19 @@
+import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+
+export function getMouseTimeFromClientX({
+ clientX,
+ containerRect,
+ zoomLevel,
+ scrollLeft,
+}: {
+ clientX: number;
+ containerRect: DOMRect;
+ zoomLevel: number;
+ scrollLeft: number;
+}): number {
+ const mouseX = clientX - containerRect.left + scrollLeft;
+ return Math.max(
+ 0,
+ mouseX / (TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel),
+ );
+}
diff --git a/apps/web/src/lib/timeline/element-utils.ts b/apps/web/src/lib/timeline/element-utils.ts
index a167e548..fa74b14e 100644
--- a/apps/web/src/lib/timeline/element-utils.ts
+++ b/apps/web/src/lib/timeline/element-utils.ts
@@ -1,5 +1,10 @@
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
-import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
+import {
+ DEFAULT_BLEND_MODE,
+ DEFAULT_OPACITY,
+ DEFAULT_TRANSFORM,
+ TIMELINE_CONSTANTS,
+} from "@/constants/timeline-constants";
import type {
CreateTimelineElement,
CreateVideoElement,
@@ -16,6 +21,7 @@ import type {
StickerElement,
UploadAudioElement,
} from "@/types/timeline";
+import type { MediaType } from "@/types/assets";
export function canElementHaveAudio(
element: TimelineElement,
@@ -23,6 +29,17 @@ export function canElementHaveAudio(
return element.type === "audio" || element.type === "video";
}
+export function isVisualElement(
+ element: TimelineElement,
+): element is VideoElement | ImageElement | TextElement | StickerElement {
+ return (
+ element.type === "video" ||
+ element.type === "image" ||
+ element.type === "text" ||
+ element.type === "sticker"
+ );
+}
+
export function canElementBeHidden(
element: TimelineElement,
): element is VideoElement | ImageElement | TextElement | StickerElement {
@@ -142,28 +159,36 @@ export function buildTextElement({
fontWeight: t.fontWeight ?? DEFAULT_TEXT_ELEMENT.fontWeight,
fontStyle: t.fontStyle ?? DEFAULT_TEXT_ELEMENT.fontStyle,
textDecoration: t.textDecoration ?? DEFAULT_TEXT_ELEMENT.textDecoration,
+ letterSpacing: t.letterSpacing ?? DEFAULT_TEXT_ELEMENT.letterSpacing,
+ lineHeight: t.lineHeight ?? DEFAULT_TEXT_ELEMENT.lineHeight,
transform: t.transform ?? DEFAULT_TEXT_ELEMENT.transform,
opacity: t.opacity ?? DEFAULT_TEXT_ELEMENT.opacity,
+ blendMode: t.blendMode ?? DEFAULT_BLEND_MODE,
};
}
export function buildStickerElement({
- iconName,
+ stickerId,
+ name,
startTime,
}: {
- iconName: string;
+ stickerId: string;
+ name?: string;
startTime: number;
}): CreateStickerElement {
+ const stickerNameFromId =
+ stickerId.split(":").slice(1).pop()?.replaceAll("-", " ") ?? stickerId;
return {
type: "sticker",
- name: iconName.split(":")[1] || iconName,
- iconName,
+ name: name ?? stickerNameFromId,
+ stickerId,
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
startTime,
trimStart: 0,
trimEnd: 0,
- transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
- opacity: 1,
+ transform: { ...DEFAULT_TRANSFORM },
+ opacity: DEFAULT_OPACITY,
+ blendMode: DEFAULT_BLEND_MODE,
};
}
@@ -188,8 +213,9 @@ export function buildVideoElement({
trimEnd: 0,
muted: false,
hidden: false,
- transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
- opacity: 1,
+ transform: { ...DEFAULT_TRANSFORM },
+ opacity: DEFAULT_OPACITY,
+ blendMode: DEFAULT_BLEND_MODE,
};
}
@@ -213,8 +239,9 @@ export function buildImageElement({
trimStart: 0,
trimEnd: 0,
hidden: false,
- transform: { scale: 1, position: { x: 0, y: 0 }, rotate: 0 },
- opacity: 1,
+ transform: { ...DEFAULT_TRANSFORM },
+ opacity: DEFAULT_OPACITY,
+ blendMode: DEFAULT_BLEND_MODE,
};
}
@@ -249,6 +276,37 @@ export function buildUploadAudioElement({
return element;
}
+export function buildElementFromMedia({
+ mediaId,
+ mediaType,
+ name,
+ duration,
+ startTime,
+ buffer,
+}: {
+ mediaId: string;
+ mediaType: MediaType;
+ name: string;
+ duration: number;
+ startTime: number;
+ buffer?: AudioBuffer;
+}): CreateTimelineElement {
+ switch (mediaType) {
+ case "audio":
+ return buildUploadAudioElement({
+ mediaId,
+ name,
+ duration,
+ startTime,
+ buffer,
+ });
+ case "video":
+ return buildVideoElement({ mediaId, name, duration, startTime });
+ case "image":
+ return buildImageElement({ mediaId, name, duration, startTime });
+ }
+}
+
export function buildLibraryAudioElement({
sourceUrl,
name,
@@ -302,3 +360,19 @@ export function getElementsAtTime({
return result;
}
+
+export function collectFontFamilies({
+ tracks,
+}: {
+ tracks: TimelineTrack[];
+}): string[] {
+ const families = new Set<string>();
+ for (const track of tracks) {
+ for (const element of track.elements) {
+ if (element.type === "text" && element.fontFamily) {
+ families.add(element.fontFamily);
+ }
+ }
+ }
+ return [...families];
+}
diff --git a/apps/web/src/middleware.ts b/apps/web/src/proxy.ts
index c7f28025..dfe8eb84 100644
--- a/apps/web/src/middleware.ts
+++ b/apps/web/src/proxy.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
-export async function middleware() {
+export async function proxy() {
return NextResponse.next();
}
diff --git a/apps/web/src/services/renderer/nodes/sticker-node.ts b/apps/web/src/services/renderer/nodes/sticker-node.ts
index 9842320e..a2fb0170 100644
--- a/apps/web/src/services/renderer/nodes/sticker-node.ts
+++ b/apps/web/src/services/renderer/nodes/sticker-node.ts
@@ -1,9 +1,9 @@
import type { CanvasRenderer } from "../canvas-renderer";
+import { resolveStickerId } from "@/lib/stickers";
import { VisualNode, type VisualNodeParams } from "./visual-node";
export interface StickerNodeParams extends VisualNodeParams {
- iconName: string;
- color?: string;
+ stickerId: string;
}
export class StickerNode extends VisualNode<StickerNodeParams> {
@@ -18,15 +18,15 @@ export class StickerNode extends VisualNode<StickerNodeParams> {
private async load() {
const image = new Image();
this.image = image;
- const color = this.params.color
- ? `&color=${encodeURIComponent(this.params.color)}`
- : "";
- const url = `https://api.iconify.design/${this.params.iconName}.svg?width=200&height=200${color}`;
+ const url = resolveStickerId({
+ stickerId: this.params.stickerId,
+ options: { width: 200, height: 200 },
+ });
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () =>
- reject(new Error(`Failed to load sticker: ${this.params.iconName}`));
+ reject(new Error(`Failed to load sticker: ${this.params.stickerId}`));
image.src = url;
});
}
diff --git a/apps/web/src/services/renderer/nodes/text-node.ts b/apps/web/src/services/renderer/nodes/text-node.ts
index 121ba834..b7e36d64 100644
--- a/apps/web/src/services/renderer/nodes/text-node.ts
+++ b/apps/web/src/services/renderer/nodes/text-node.ts
@@ -1,7 +1,10 @@
import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
import type { TextElement } from "@/types/timeline";
-import { FONT_SIZE_SCALE_REFERENCE } from "@/constants/text-constants";
+import {
+ DEFAULT_LINE_HEIGHT,
+ FONT_SIZE_SCALE_REFERENCE,
+} from "@/constants/text-constants";
function scaleFontSize({
fontSize,
@@ -13,6 +16,107 @@ function scaleFontSize({
return fontSize * (canvasHeight / FONT_SIZE_SCALE_REFERENCE);
}
+function quoteFontFamily({ fontFamily }: { fontFamily: string }): string {
+ return `"${fontFamily.replace(/"/g, '\\"')}"`;
+}
+
+function getMetricAscent({
+ metrics,
+ fallback,
+}: {
+ metrics: TextMetrics;
+ fallback: number;
+}): number {
+ return metrics.actualBoundingBoxAscent ?? fallback * 0.8;
+}
+
+function getMetricDescent({
+ metrics,
+ fallback,
+}: {
+ metrics: TextMetrics;
+ fallback: number;
+}): number {
+ return metrics.actualBoundingBoxDescent ?? fallback * 0.2;
+}
+
+interface TextBlockMeasurement {
+ visualCenterOffset: number;
+ height: number;
+ maxWidth: number;
+}
+
+function measureTextBlock({
+ lineMetrics,
+ lineHeightPx,
+ fallbackFontSize,
+}: {
+ lineMetrics: TextMetrics[];
+ lineHeightPx: number;
+ fallbackFontSize: number;
+}): TextBlockMeasurement {
+ let top = Number.POSITIVE_INFINITY;
+ let bottom = Number.NEGATIVE_INFINITY;
+ let maxWidth = 0;
+
+ for (let i = 0; i < lineMetrics.length; i++) {
+ const metrics = lineMetrics[i];
+ const y = i * lineHeightPx;
+ top = Math.min(
+ top,
+ y - getMetricAscent({ metrics, fallback: fallbackFontSize }),
+ );
+ bottom = Math.max(
+ bottom,
+ y + getMetricDescent({ metrics, fallback: fallbackFontSize }),
+ );
+ maxWidth = Math.max(maxWidth, metrics.width);
+ }
+
+ const height = bottom - top;
+ const visualCenterOffset = (top + bottom) / 2;
+
+ return { visualCenterOffset, height, maxWidth };
+}
+
+function drawTextDecoration({
+ ctx,
+ textDecoration,
+ lineWidth,
+ lineY,
+ metrics,
+ scaledFontSize,
+ textAlign,
+}: {
+ ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
+ textDecoration: string;
+ lineWidth: number;
+ lineY: number;
+ metrics: TextMetrics;
+ scaledFontSize: number;
+ textAlign: CanvasTextAlign;
+}): void {
+ if (textDecoration === "none" || !textDecoration) return;
+
+ const thickness = Math.max(1, scaledFontSize * 0.07);
+ const ascent = getMetricAscent({ metrics, fallback: scaledFontSize });
+ const descent = getMetricDescent({ metrics, fallback: scaledFontSize });
+
+ let xStart = -lineWidth / 2;
+ if (textAlign === "left") xStart = 0;
+ if (textAlign === "right") xStart = -lineWidth;
+
+ if (textDecoration === "underline") {
+ const underlineY = lineY + descent + thickness;
+ ctx.fillRect(xStart, underlineY, lineWidth, thickness);
+ }
+
+ if (textDecoration === "line-through") {
+ const strikeY = lineY - (ascent - descent) * 0.35;
+ ctx.fillRect(xStart, strikeY, lineWidth, thickness);
+ }
+}
+
export type TextNodeParams = TextElement & {
canvasCenter: { x: number; y: number };
canvasHeight: number;
@@ -38,6 +142,10 @@ export class TextNode extends BaseNode<TextNodeParams> {
const y = this.params.transform.position.y + this.params.canvasCenter.y;
renderer.context.translate(x, y);
+ renderer.context.scale(
+ this.params.transform.scale,
+ this.params.transform.scale,
+ );
if (this.params.transform.rotate) {
renderer.context.rotate((this.params.transform.rotate * Math.PI) / 180);
}
@@ -48,39 +156,72 @@ export class TextNode extends BaseNode<TextNodeParams> {
fontSize: this.params.fontSize,
canvasHeight: this.params.canvasHeight,
});
- renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${this.params.fontFamily}`;
+ const fontFamily = quoteFontFamily({ fontFamily: this.params.fontFamily });
+ renderer.context.font = `${fontStyle} ${fontWeight} ${scaledFontSize}px ${fontFamily}, sans-serif`;
renderer.context.textAlign = this.params.textAlign;
- renderer.context.textBaseline = this.params.textBaseline || "middle";
renderer.context.fillStyle = this.params.color;
+ const letterSpacing = this.params.letterSpacing ?? 0;
+ const lineHeight = this.params.lineHeight ?? DEFAULT_LINE_HEIGHT;
+ if ("letterSpacing" in renderer.context) {
+ (
+ renderer.context as CanvasRenderingContext2D & { letterSpacing: string }
+ ).letterSpacing = `${letterSpacing}px`;
+ }
+
+ const lines = this.params.content.split("\n");
+ const lineHeightPx = scaledFontSize * lineHeight;
+ const baseline = this.params.textBaseline ?? "middle";
+
+ renderer.context.textBaseline = baseline;
+ const lineMetrics = lines.map((line) => renderer.context.measureText(line));
+ const lineCount = lines.length;
+
+ const block = measureTextBlock({
+ lineMetrics,
+ lineHeightPx,
+ fallbackFontSize: scaledFontSize,
+ });
+
const prevAlpha = renderer.context.globalAlpha;
+ renderer.context.globalCompositeOperation = (
+ this.params.blendMode && this.params.blendMode !== "normal"
+ ? this.params.blendMode
+ : "source-over"
+ ) as GlobalCompositeOperation;
renderer.context.globalAlpha = this.params.opacity;
- if (this.params.backgroundColor) {
- const metrics = renderer.context.measureText(this.params.content);
- const ascent = metrics.actualBoundingBoxAscent ?? scaledFontSize * 0.8;
- const descent = metrics.actualBoundingBoxDescent ?? scaledFontSize * 0.2;
- const textW = metrics.width;
- const textH = ascent + descent;
+ if (this.params.backgroundColor && lineCount > 0) {
const padX = 8;
const padY = 4;
-
renderer.context.fillStyle = this.params.backgroundColor;
- let bgLeft = -textW / 2;
+ let bgLeft = -block.maxWidth / 2;
if (renderer.context.textAlign === "left") bgLeft = 0;
- if (renderer.context.textAlign === "right") bgLeft = -textW;
+ if (renderer.context.textAlign === "right") bgLeft = -block.maxWidth;
renderer.context.fillRect(
bgLeft - padX,
- -textH / 2 - padY,
- textW + padX * 2,
- textH + padY * 2,
+ -block.height / 2 - padY,
+ block.maxWidth + padX * 2,
+ block.height + padY * 2,
);
renderer.context.fillStyle = this.params.color;
}
- renderer.context.fillText(this.params.content, 0, 0);
+ for (let i = 0; i < lineCount; i++) {
+ const y = i * lineHeightPx - block.visualCenterOffset;
+ renderer.context.fillText(lines[i], 0, y);
+ drawTextDecoration({
+ ctx: renderer.context,
+ textDecoration: this.params.textDecoration ?? "none",
+ lineWidth: lineMetrics[i].width,
+ lineY: y,
+ metrics: lineMetrics[i],
+ scaledFontSize,
+ textAlign: this.params.textAlign,
+ });
+ }
renderer.context.globalAlpha = prevAlpha;
renderer.context.restore();
diff --git a/apps/web/src/services/renderer/nodes/visual-node.ts b/apps/web/src/services/renderer/nodes/visual-node.ts
index 9c6c88ca..7179369e 100644
--- a/apps/web/src/services/renderer/nodes/visual-node.ts
+++ b/apps/web/src/services/renderer/nodes/visual-node.ts
@@ -1,5 +1,6 @@
import type { CanvasRenderer } from "../canvas-renderer";
import { BaseNode } from "./base-node";
+import type { BlendMode } from "@/types/rendering";
import type { Transform } from "@/types/timeline";
const VISUAL_EPSILON = 1 / 1000;
@@ -11,6 +12,7 @@ export interface VisualNodeParams {
trimEnd: number;
transform: Transform;
opacity: number;
+ blendMode?: BlendMode;
}
export abstract class VisualNode<
@@ -51,6 +53,11 @@ export abstract class VisualNode<
const x = renderer.width / 2 + transform.position.x - scaledWidth / 2;
const y = renderer.height / 2 + transform.position.y - scaledHeight / 2;
+ renderer.context.globalCompositeOperation = (
+ this.params.blendMode && this.params.blendMode !== "normal"
+ ? this.params.blendMode
+ : "source-over"
+ ) as GlobalCompositeOperation;
renderer.context.globalAlpha = opacity;
if (transform.rotate !== 0) {
diff --git a/apps/web/src/services/renderer/scene-builder.ts b/apps/web/src/services/renderer/scene-builder.ts
index d1b8217e..ee7f3d6c 100644
--- a/apps/web/src/services/renderer/scene-builder.ts
+++ b/apps/web/src/services/renderer/scene-builder.ts
@@ -66,6 +66,7 @@ export function buildScene(params: BuildSceneParams) {
trimEnd: element.trimEnd,
transform: element.transform,
opacity: element.opacity,
+ blendMode: element.blendMode,
}),
);
}
@@ -79,6 +80,7 @@ export function buildScene(params: BuildSceneParams) {
trimEnd: element.trimEnd,
transform: element.transform,
opacity: element.opacity,
+ blendMode: element.blendMode,
}),
);
}
@@ -98,14 +100,14 @@ export function buildScene(params: BuildSceneParams) {
if (element.type === "sticker") {
contentNodes.push(
new StickerNode({
- iconName: element.iconName,
+ stickerId: element.stickerId,
duration: element.duration,
timeOffset: element.startTime,
trimStart: element.trimStart,
trimEnd: element.trimEnd,
transform: element.transform,
opacity: element.opacity,
- color: element.color,
+ blendMode: element.blendMode,
}),
);
}
diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts
index ee1485ba..5f82e14a 100644
--- a/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts
+++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/index.ts
@@ -7,3 +7,4 @@ export * from "./v0";
export * from "./v1";
export * from "./v2";
export * from "./v3";
+export * from "./v5";
diff --git a/apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts b/apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts
new file mode 100644
index 00000000..5e181c76
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/fixtures/v5.ts
@@ -0,0 +1,54 @@
+export const v5Project = {
+ id: "project-v5-456",
+ version: 5,
+ metadata: {
+ id: "project-v5-456",
+ name: "My V5 Project",
+ thumbnail: "data:image/png;base64,abc123",
+ duration: 30,
+ createdAt: "2024-06-01T10:00:00.000Z",
+ updatedAt: "2024-06-01T14:00:00.000Z",
+ },
+ settings: {
+ fps: 30,
+ canvasSize: { width: 1920, height: 1080 },
+ background: { type: "color", color: "#000000" },
+ },
+ currentSceneId: "scene-main",
+ scenes: [
+ {
+ id: "scene-main",
+ name: "Main scene",
+ isMain: true,
+ tracks: [
+ {
+ id: "track-1",
+ type: "video",
+ name: "Video Track",
+ isMain: true,
+ elements: [],
+ },
+ ],
+ bookmarks: [2.0, 5.5, 12.0],
+ createdAt: "2024-06-01T10:00:00.000Z",
+ updatedAt: "2024-06-01T14:00:00.000Z",
+ },
+ {
+ id: "scene-intro",
+ name: "Intro",
+ isMain: false,
+ tracks: [
+ {
+ id: "track-2",
+ type: "video",
+ name: "Video Track",
+ isMain: true,
+ elements: [],
+ },
+ ],
+ bookmarks: [],
+ createdAt: "2024-06-01T10:00:00.000Z",
+ updatedAt: "2024-06-01T14:00:00.000Z",
+ },
+ ],
+};
diff --git a/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts
new file mode 100644
index 00000000..81d03147
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v3-to-v4.test.ts
@@ -0,0 +1,140 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV3ToV4 } from "../transformers/v3-to-v4";
+import { v3Project } from "./fixtures";
+
+describe("V3 to V4 Migration", () => {
+ test("normalizes legacy text fontWeight values", () => {
+ const projectWithLegacyTextWeight = {
+ ...v3Project,
+ scenes: [
+ {
+ ...v3Project.scenes[0],
+ tracks: [
+ {
+ id: "track-text",
+ type: "text",
+ name: "Text Track",
+ hidden: false,
+ elements: [
+ {
+ id: "text-1",
+ type: "text",
+ name: "Title",
+ content: "Hello",
+ duration: 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ fontSize: 64,
+ fontFamily: "Inter",
+ color: "#ffffff",
+ backgroundColor: "transparent",
+ textAlign: "center",
+ fontWeight: "bold",
+ fontStyle: "normal",
+ textDecoration: "none",
+ transform: {
+ scale: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ },
+ opacity: 1,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = transformProjectV3ToV4({
+ project: projectWithLegacyTextWeight,
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(4);
+
+ const migratedScene = (
+ result.project.scenes as Array<Record<string, unknown>>
+ )[0];
+ const migratedTrack = (
+ migratedScene.tracks as Array<Record<string, unknown>>
+ )[0];
+ const migratedElement = (
+ migratedTrack.elements as Array<Record<string, unknown>>
+ )[0];
+
+ expect(migratedElement.fontWeight).toBe("700");
+ });
+
+ test("does not mutate non-text tracks", () => {
+ const projectWithoutTextTrack = {
+ ...v3Project,
+ scenes: [
+ {
+ ...v3Project.scenes[0],
+ tracks: [
+ {
+ id: "track-sticker",
+ type: "sticker",
+ name: "Sticker Track",
+ hidden: false,
+ elements: [
+ {
+ id: "sticker-1",
+ type: "sticker",
+ name: "Flag",
+ iconName: "mdi:home",
+ duration: 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ scale: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ },
+ opacity: 1,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = transformProjectV3ToV4({ project: projectWithoutTextTrack });
+ const migratedScene = (
+ result.project.scenes as Array<Record<string, unknown>>
+ )[0];
+ const migratedTrack = (
+ migratedScene.tracks as Array<Record<string, unknown>>
+ )[0];
+ const migratedElement = (
+ migratedTrack.elements as Array<Record<string, unknown>>
+ )[0];
+
+ expect(migratedElement.iconName).toBe("mdi:home");
+ });
+
+ test("skips projects that are already v4", () => {
+ const result = transformProjectV3ToV4({
+ project: { ...v3Project, version: 4 },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v4");
+ });
+
+ test("skips projects with no id", () => {
+ const result = transformProjectV3ToV4({
+ project: {
+ version: 3,
+ scenes: [],
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("no project id");
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts
new file mode 100644
index 00000000..ce0c3019
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v4-to-v5.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV4ToV5 } from "../transformers/v4-to-v5";
+import { v3Project } from "./fixtures";
+
+describe("V4 to V5 Migration", () => {
+ test("migrates sticker iconName to stickerId and removes legacy color", () => {
+ const projectWithLegacySticker = {
+ ...v3Project,
+ version: 4,
+ scenes: [
+ {
+ ...v3Project.scenes[0],
+ tracks: [
+ {
+ id: "track-sticker",
+ type: "sticker",
+ name: "Sticker Track",
+ hidden: false,
+ elements: [
+ {
+ id: "sticker-1",
+ type: "sticker",
+ name: "Home",
+ iconName: "mdi:home",
+ color: "#ff0000",
+ duration: 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ scale: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ },
+ opacity: 1,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = transformProjectV4ToV5({
+ project: projectWithLegacySticker,
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(5);
+
+ const migratedScene = (
+ result.project.scenes as Array<Record<string, unknown>>
+ )[0];
+ const migratedTrack = (
+ migratedScene.tracks as Array<Record<string, unknown>>
+ )[0];
+ const migratedElement = (
+ migratedTrack.elements as Array<Record<string, unknown>>
+ )[0];
+
+ expect(migratedElement.stickerId).toBe("icons:mdi:home");
+ expect("iconName" in migratedElement).toBe(false);
+ expect("color" in migratedElement).toBe(false);
+ });
+
+ test("keeps provider-prefixed stickerId values unchanged", () => {
+ const projectWithStickerId = {
+ ...v3Project,
+ version: 4,
+ scenes: [
+ {
+ ...v3Project.scenes[0],
+ tracks: [
+ {
+ id: "track-sticker",
+ type: "sticker",
+ name: "Sticker Track",
+ hidden: false,
+ elements: [
+ {
+ id: "sticker-1",
+ type: "sticker",
+ name: "Flag",
+ stickerId: "flags:AD",
+ duration: 5,
+ startTime: 0,
+ trimStart: 0,
+ trimEnd: 0,
+ transform: {
+ scale: 1,
+ position: { x: 0, y: 0 },
+ rotate: 0,
+ },
+ opacity: 1,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+
+ const result = transformProjectV4ToV5({ project: projectWithStickerId });
+ const migratedScene = (
+ result.project.scenes as Array<Record<string, unknown>>
+ )[0];
+ const migratedTrack = (
+ migratedScene.tracks as Array<Record<string, unknown>>
+ )[0];
+ const migratedElement = (
+ migratedTrack.elements as Array<Record<string, unknown>>
+ )[0];
+
+ expect(migratedElement.stickerId).toBe("flags:AD");
+ });
+
+ test("skips projects that are already v5", () => {
+ const result = transformProjectV4ToV5({
+ project: { ...v3Project, version: 5 },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v5");
+ });
+
+ test("skips projects with no id", () => {
+ const result = transformProjectV4ToV5({
+ project: {
+ version: 4,
+ scenes: [],
+ },
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("no project id");
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts
new file mode 100644
index 00000000..6bbd5634
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/__tests__/v5-to-v6.test.ts
@@ -0,0 +1,92 @@
+import { describe, expect, test } from "bun:test";
+import { transformProjectV5ToV6 } from "../transformers/v5-to-v6";
+import { v5Project } from "./fixtures";
+
+describe("V5 to V6 Migration", () => {
+ test("converts number bookmarks to Bookmark objects", async () => {
+ const result = transformProjectV5ToV6({
+ project: v5Project as Parameters<
+ typeof transformProjectV5ToV6
+ >[0]["project"],
+ });
+
+ expect(result.skipped).toBe(false);
+ expect(result.project.version).toBe(6);
+
+ const mainScene = (
+ result.project.scenes as Array<{ bookmarks: unknown[] }>
+ )[0];
+ expect(mainScene.bookmarks).toEqual([
+ { time: 2.0 },
+ { time: 5.5 },
+ { time: 12.0 },
+ ]);
+
+ const introScene = (
+ result.project.scenes as Array<{ bookmarks: unknown[] }>
+ )[1];
+ expect(introScene.bookmarks).toEqual([]);
+ });
+
+ test("skips projects that are already v6", () => {
+ const result = transformProjectV5ToV6({
+ project: {
+ ...v5Project,
+ version: 6,
+ scenes: [
+ {
+ ...(v5Project as { scenes: unknown[] }).scenes[0],
+ bookmarks: [{ time: 2 }, { time: 5 }],
+ },
+ ],
+ } as Parameters<typeof transformProjectV5ToV6>[0]["project"],
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("already v6");
+ });
+
+ test("skips projects with no id", () => {
+ const result = transformProjectV5ToV6({
+ project: {
+ version: 5,
+ scenes: [],
+ } as Parameters<typeof transformProjectV5ToV6>[0]["project"],
+ });
+
+ expect(result.skipped).toBe(true);
+ expect(result.reason).toBe("no project id");
+ });
+
+ test("preserves existing Bookmark objects with note, color, duration", () => {
+ const projectWithRichBookmarks = {
+ ...v5Project,
+ version: 5,
+ scenes: [
+ {
+ ...(v5Project as { scenes: Array<Record<string, unknown>> })
+ .scenes[0],
+ bookmarks: [
+ { time: 1, note: "Intro", color: "#ef4444" },
+ { time: 5.5, duration: 2 },
+ ],
+ },
+ ],
+ };
+
+ const result = transformProjectV5ToV6({
+ project: projectWithRichBookmarks as Parameters<
+ typeof transformProjectV5ToV6
+ >[0]["project"],
+ });
+
+ expect(result.skipped).toBe(false);
+ const mainScene = (
+ result.project.scenes as Array<{ bookmarks: unknown[] }>
+ )[0];
+ expect(mainScene.bookmarks).toEqual([
+ { time: 1, note: "Intro", color: "#ef4444" },
+ { time: 5.5, duration: 2 },
+ ]);
+ });
+});
diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts
index df0b8007..00c009c7 100644
--- a/apps/web/src/services/storage/migrations/index.ts
+++ b/apps/web/src/services/storage/migrations/index.ts
@@ -2,13 +2,19 @@ export { StorageMigration } from "./base";
import { V0toV1Migration } from "./v0-to-v1";
import { V1toV2Migration } from "./v1-to-v2";
import { V2toV3Migration } from "./v2-to-v3";
+import { V3toV4Migration } from "./v3-to-v4";
+import { V4toV5Migration } from "./v4-to-v5";
+import { V5toV6Migration } from "./v5-to-v6";
export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner";
-export const CURRENT_PROJECT_VERSION = 3;
+export const CURRENT_PROJECT_VERSION = 6;
export const migrations = [
new V0toV1Migration(),
new V1toV2Migration(),
new V2toV3Migration(),
+ new V3toV4Migration(),
+ new V4toV5Migration(),
+ new V5toV6Migration(),
];
diff --git a/apps/web/src/services/storage/migrations/runner.ts b/apps/web/src/services/storage/migrations/runner.ts
index b261252b..e1c2d09a 100644
--- a/apps/web/src/services/storage/migrations/runner.ts
+++ b/apps/web/src/services/storage/migrations/runner.ts
@@ -136,7 +136,11 @@ function getProjectVersion({ project }: { project: ProjectRecord }): number {
return 0;
}
-function getProjectName({ project }: { project: ProjectRecord }): string | null {
+function getProjectName({
+ project,
+}: {
+ project: ProjectRecord;
+}): string | null {
const metadata = project.metadata;
if (isRecord(metadata) && typeof metadata.name === "string") {
return metadata.name;
diff --git a/apps/web/src/services/storage/migrations/transformers/index.ts b/apps/web/src/services/storage/migrations/transformers/index.ts
index 74cf46a9..8878d6ba 100644
--- a/apps/web/src/services/storage/migrations/transformers/index.ts
+++ b/apps/web/src/services/storage/migrations/transformers/index.ts
@@ -1,4 +1,6 @@
export { transformProjectV0ToV1 } from "./v0-to-v1";
export { transformProjectV1ToV2 } from "./v1-to-v2";
export { transformProjectV2ToV3 } from "./v2-to-v3";
+export { transformProjectV3ToV4 } from "./v3-to-v4";
+export { transformProjectV4ToV5 } from "./v4-to-v5";
export type { MigrationResult, ProjectRecord } from "./types";
diff --git a/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts
new file mode 100644
index 00000000..d6e1efee
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v3-to-v4.ts
@@ -0,0 +1,189 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+const LEGACY_FONT_WEIGHT_MAP = {
+ normal: "400",
+ bold: "700",
+} as const;
+
+const VALID_NUMERIC_FONT_WEIGHTS = new Set([
+ "100",
+ "200",
+ "300",
+ "400",
+ "500",
+ "600",
+ "700",
+ "800",
+ "900",
+]);
+
+export function transformProjectV3ToV4({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ const projectId = getProjectId({ project });
+ if (!projectId) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (isV4Project({ project })) {
+ return { project, skipped: true, reason: "already v4" };
+ }
+
+ const migratedProject = normalizeProjectTextFontWeights({ project });
+
+ return {
+ project: {
+ ...migratedProject,
+ version: 4,
+ },
+ skipped: false,
+ };
+}
+
+function normalizeProjectTextFontWeights({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) {
+ return project;
+ }
+
+ let hasSceneChanges = false;
+ const normalizedScenes = scenesValue.map((scene) => {
+ const normalizedScene = normalizeSceneTextFontWeights({ scene });
+ if (normalizedScene !== scene) {
+ hasSceneChanges = true;
+ }
+ return normalizedScene;
+ });
+
+ if (!hasSceneChanges) {
+ return project;
+ }
+
+ return {
+ ...project,
+ scenes: normalizedScenes,
+ };
+}
+
+function normalizeSceneTextFontWeights({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ const tracksValue = scene.tracks;
+ if (!Array.isArray(tracksValue)) {
+ return scene;
+ }
+
+ let hasTrackChanges = false;
+ const normalizedTracks = tracksValue.map((track) => {
+ const normalizedTrack = normalizeTrackTextFontWeights({ track });
+ if (normalizedTrack !== track) {
+ hasTrackChanges = true;
+ }
+ return normalizedTrack;
+ });
+
+ if (!hasTrackChanges) {
+ return scene;
+ }
+
+ return {
+ ...scene,
+ tracks: normalizedTracks,
+ };
+}
+
+function normalizeTrackTextFontWeights({ track }: { track: unknown }): unknown {
+ if (!isRecord(track)) {
+ return track;
+ }
+
+ if (track.type !== "text") {
+ return track;
+ }
+
+ const elementsValue = track.elements;
+ if (!Array.isArray(elementsValue)) {
+ return track;
+ }
+
+ let hasElementChanges = false;
+ const normalizedElements = elementsValue.map((element) => {
+ const normalizedElement = normalizeTextElementFontWeight({ element });
+ if (normalizedElement !== element) {
+ hasElementChanges = true;
+ }
+ return normalizedElement;
+ });
+
+ if (!hasElementChanges) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: normalizedElements,
+ };
+}
+
+function normalizeTextElementFontWeight({
+ element,
+}: {
+ element: unknown;
+}): unknown {
+ if (!isRecord(element) || element.type !== "text") {
+ return element;
+ }
+
+ const normalizedWeight = normalizeFontWeight({ value: element.fontWeight });
+ if (normalizedWeight === element.fontWeight) {
+ return element;
+ }
+
+ return {
+ ...element,
+ fontWeight: normalizedWeight,
+ };
+}
+
+function normalizeFontWeight({ value }: { value: unknown }): unknown {
+ if (typeof value === "number") {
+ const numericWeight = String(value);
+ if (VALID_NUMERIC_FONT_WEIGHTS.has(numericWeight)) {
+ return numericWeight;
+ }
+ return value;
+ }
+
+ if (typeof value !== "string") {
+ return value;
+ }
+
+ const normalized = value.trim().toLowerCase();
+ if (normalized in LEGACY_FONT_WEIGHT_MAP) {
+ return LEGACY_FONT_WEIGHT_MAP[
+ normalized as keyof typeof LEGACY_FONT_WEIGHT_MAP
+ ];
+ }
+
+ if (VALID_NUMERIC_FONT_WEIGHTS.has(normalized)) {
+ return normalized;
+ }
+
+ return value;
+}
+
+export { getProjectId } from "./utils";
+
+function isV4Project({ project }: { project: ProjectRecord }): boolean {
+ const versionValue = project.version;
+ return typeof versionValue === "number" && versionValue >= 4;
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts b/apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts
new file mode 100644
index 00000000..971e3510
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v4-to-v5.ts
@@ -0,0 +1,177 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+const KNOWN_STICKER_PROVIDER_IDS = new Set([
+ "icons",
+ "emoji",
+ "flags",
+ "shapes",
+]);
+
+export function transformProjectV4ToV5({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ const projectId = getProjectId({ project });
+ if (!projectId) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (isV5Project({ project })) {
+ return { project, skipped: true, reason: "already v5" };
+ }
+
+ const migratedProject = migrateProjectStickerElements({ project });
+
+ return {
+ project: {
+ ...migratedProject,
+ version: 5,
+ },
+ skipped: false,
+ };
+}
+
+function migrateProjectStickerElements({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) {
+ return project;
+ }
+
+ let hasSceneChanges = false;
+ const migratedScenes = scenesValue.map((scene) => {
+ const migratedScene = migrateSceneStickerElements({ scene });
+ if (migratedScene !== scene) {
+ hasSceneChanges = true;
+ }
+ return migratedScene;
+ });
+
+ if (!hasSceneChanges) {
+ return project;
+ }
+
+ return {
+ ...project,
+ scenes: migratedScenes,
+ };
+}
+
+function migrateSceneStickerElements({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ const tracksValue = scene.tracks;
+ if (!Array.isArray(tracksValue)) {
+ return scene;
+ }
+
+ let hasTrackChanges = false;
+ const migratedTracks = tracksValue.map((track) => {
+ const migratedTrack = migrateTrackStickerElements({ track });
+ if (migratedTrack !== track) {
+ hasTrackChanges = true;
+ }
+ return migratedTrack;
+ });
+
+ if (!hasTrackChanges) {
+ return scene;
+ }
+
+ return {
+ ...scene,
+ tracks: migratedTracks,
+ };
+}
+
+function migrateTrackStickerElements({ track }: { track: unknown }): unknown {
+ if (!isRecord(track)) {
+ return track;
+ }
+
+ const elementsValue = track.elements;
+ if (!Array.isArray(elementsValue)) {
+ return track;
+ }
+
+ let hasElementChanges = false;
+ const migratedElements = elementsValue.map((element) => {
+ const migratedElement = migrateStickerElement({ element });
+ if (migratedElement !== element) {
+ hasElementChanges = true;
+ }
+ return migratedElement;
+ });
+
+ if (!hasElementChanges) {
+ return track;
+ }
+
+ return {
+ ...track,
+ elements: migratedElements,
+ };
+}
+
+function migrateStickerElement({ element }: { element: unknown }): unknown {
+ if (!isRecord(element) || element.type !== "sticker") {
+ return element;
+ }
+
+ const existingStickerId =
+ typeof element.stickerId === "string" ? element.stickerId : null;
+ const legacyIconName =
+ typeof element.iconName === "string" ? element.iconName : null;
+
+ const normalizedStickerId =
+ normalizeStickerId({
+ value: existingStickerId ?? legacyIconName,
+ }) ?? null;
+
+ const hasLegacyIconName = "iconName" in element;
+ const hasLegacyColor = "color" in element;
+ const shouldUpdateStickerId = normalizedStickerId !== existingStickerId;
+
+ if (!hasLegacyIconName && !hasLegacyColor && !shouldUpdateStickerId) {
+ return element;
+ }
+
+ const {
+ iconName: _legacyIconName,
+ color: _legacyColor,
+ ...remaining
+ } = element;
+ return normalizedStickerId
+ ? { ...remaining, stickerId: normalizedStickerId }
+ : remaining;
+}
+
+function normalizeStickerId({ value }: { value: unknown }): string | null {
+ if (typeof value !== "string" || value.length === 0) {
+ return null;
+ }
+
+ const separatorIndex = value.indexOf(":");
+ if (separatorIndex === -1) {
+ return `icons:${value}`;
+ }
+
+ const maybeProvider = value.slice(0, separatorIndex);
+ if (KNOWN_STICKER_PROVIDER_IDS.has(maybeProvider)) {
+ return value;
+ }
+
+ return `icons:${value}`;
+}
+
+function isV5Project({ project }: { project: ProjectRecord }): boolean {
+ const versionValue = project.version;
+ return typeof versionValue === "number" && versionValue >= 5;
+}
diff --git a/apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts b/apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts
new file mode 100644
index 00000000..ca855149
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/transformers/v5-to-v6.ts
@@ -0,0 +1,88 @@
+import type { MigrationResult, ProjectRecord } from "./types";
+import { getProjectId, isRecord } from "./utils";
+
+export function transformProjectV5ToV6({
+ project,
+}: {
+ project: ProjectRecord;
+}): MigrationResult<ProjectRecord> {
+ const projectId = getProjectId({ project });
+ if (!projectId) {
+ return { project, skipped: true, reason: "no project id" };
+ }
+
+ if (isV6Project({ project })) {
+ return { project, skipped: true, reason: "already v6" };
+ }
+
+ const migratedProject = migrateProjectBookmarks({ project });
+
+ return {
+ project: {
+ ...migratedProject,
+ version: 6,
+ },
+ skipped: false,
+ };
+}
+
+function migrateProjectBookmarks({
+ project,
+}: {
+ project: ProjectRecord;
+}): ProjectRecord {
+ const scenesValue = project.scenes;
+ if (!Array.isArray(scenesValue)) {
+ return project;
+ }
+
+ let hasSceneChanges = false;
+ const migratedScenes = scenesValue.map((scene) => {
+ const migratedScene = migrateSceneBookmarks({ scene });
+ if (migratedScene !== scene) {
+ hasSceneChanges = true;
+ }
+ return migratedScene;
+ });
+
+ if (!hasSceneChanges) {
+ return project;
+ }
+
+ return {
+ ...project,
+ scenes: migratedScenes,
+ };
+}
+
+function migrateSceneBookmarks({ scene }: { scene: unknown }): unknown {
+ if (!isRecord(scene)) {
+ return scene;
+ }
+
+ const bookmarksValue = scene.bookmarks;
+ if (!Array.isArray(bookmarksValue)) {
+ return scene;
+ }
+
+ const needsMigration = bookmarksValue.some(
+ (bookmark) => typeof bookmark === "number",
+ );
+ if (!needsMigration) {
+ return scene;
+ }
+
+ const migratedBookmarks = bookmarksValue.map((bookmark) =>
+ typeof bookmark === "number" ? { time: bookmark } : bookmark,
+ );
+
+ return {
+ ...scene,
+ bookmarks: migratedBookmarks,
+ };
+}
+
+function isV6Project({ project }: { project: ProjectRecord }): boolean {
+ const versionValue = project.version;
+ return typeof versionValue === "number" && versionValue >= 6;
+}
diff --git a/apps/web/src/services/storage/migrations/v3-to-v4.ts b/apps/web/src/services/storage/migrations/v3-to-v4.ts
new file mode 100644
index 00000000..67b2fdf7
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v3-to-v4.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV3ToV4 } from "./transformers/v3-to-v4";
+
+export class V3toV4Migration extends StorageMigration {
+ from = 3;
+ to = 4;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV3ToV4({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v4-to-v5.ts b/apps/web/src/services/storage/migrations/v4-to-v5.ts
new file mode 100644
index 00000000..829a746d
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v4-to-v5.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV4ToV5 } from "./transformers/v4-to-v5";
+
+export class V4toV5Migration extends StorageMigration {
+ from = 4;
+ to = 5;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV4ToV5({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/migrations/v5-to-v6.ts b/apps/web/src/services/storage/migrations/v5-to-v6.ts
new file mode 100644
index 00000000..b621bd72
--- /dev/null
+++ b/apps/web/src/services/storage/migrations/v5-to-v6.ts
@@ -0,0 +1,16 @@
+import { StorageMigration } from "./base";
+import type { ProjectRecord } from "./transformers/types";
+import { transformProjectV5ToV6 } from "./transformers/v5-to-v6";
+
+export class V5toV6Migration extends StorageMigration {
+ from = 5;
+ to = 6;
+
+ async transform(project: ProjectRecord): Promise<{
+ project: ProjectRecord;
+ skipped: boolean;
+ reason?: string;
+ }> {
+ return transformProjectV5ToV6({ project });
+ }
+}
diff --git a/apps/web/src/services/storage/service.ts b/apps/web/src/services/storage/service.ts
index 02f8d290..aac152da 100644
--- a/apps/web/src/services/storage/service.ts
+++ b/apps/web/src/services/storage/service.ts
@@ -14,7 +14,30 @@ import {
migrations,
runStorageMigrations,
} from "@/services/storage/migrations";
-import type { TimelineTrack, TScene } from "@/types/timeline";
+import type { Bookmark, TimelineTrack, TScene } from "@/types/timeline";
+
+function normalizeBookmarks({ raw }: { raw: unknown }): Bookmark[] {
+ if (!Array.isArray(raw)) return [];
+ return raw
+ .map((item): Bookmark | null => {
+ if (typeof item === "number") return { time: item };
+ const obj = item as Record<string, unknown>;
+ if (
+ typeof obj !== "object" ||
+ obj === null ||
+ typeof obj.time !== "number"
+ ) {
+ return null;
+ }
+ return {
+ time: obj.time,
+ ...(typeof obj.note === "string" && { note: obj.note }),
+ ...(typeof obj.color === "string" && { color: obj.color }),
+ ...(typeof obj.duration === "number" && { duration: obj.duration }),
+ };
+ })
+ .filter((b): b is Bookmark => b !== null);
+}
class StorageService {
private projectsAdapter: IndexedDBAdapter<SerializedProject>;
@@ -137,7 +160,7 @@ class StorageService {
? { ...track, isMain: track.isMain ?? false } // legacy: isMain was optional
: track,
),
- bookmarks: scene.bookmarks ?? [],
+ bookmarks: normalizeBookmarks({ raw: scene.bookmarks }),
createdAt: new Date(scene.createdAt),
updatedAt: new Date(scene.updatedAt),
})) ?? [];
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,
+ }),
+ },
+ ),
+);
diff --git a/apps/web/src/types/drag.ts b/apps/web/src/types/drag.ts
index e6e5daba..61aba9b5 100644
--- a/apps/web/src/types/drag.ts
+++ b/apps/web/src/types/drag.ts
@@ -15,7 +15,7 @@ export interface TextDragData extends BaseDragData {
export interface StickerDragData extends BaseDragData {
type: "sticker";
- iconName: string;
+ stickerId: string;
}
export type TimelineDragData = MediaDragData | TextDragData | StickerDragData;
diff --git a/apps/web/src/types/eyedropper.d.ts b/apps/web/src/types/eyedropper.d.ts
new file mode 100644
index 00000000..2d0e17e8
--- /dev/null
+++ b/apps/web/src/types/eyedropper.d.ts
@@ -0,0 +1,13 @@
+interface EyeDropperResult {
+ sRGBHex: string;
+}
+
+interface EyeDropper {
+ open(options?: { signal?: AbortSignal }): Promise<EyeDropperResult>;
+}
+
+declare const EyeDropper:
+ | {
+ new (): EyeDropper;
+ }
+ | undefined;
diff --git a/apps/web/src/types/fonts.ts b/apps/web/src/types/fonts.ts
new file mode 100644
index 00000000..a2db6f91
--- /dev/null
+++ b/apps/web/src/types/fonts.ts
@@ -0,0 +1,24 @@
+export interface FontOption {
+ value: string;
+ label: string;
+ category: "system" | "google" | "custom";
+ weights?: number[];
+ hasClassName?: boolean;
+}
+
+export interface GoogleFontMeta {
+ family: string;
+ category: string;
+}
+
+export interface FontAtlasEntry {
+ x: number;
+ y: number;
+ w: number;
+ ch: number;
+ s: string[];
+}
+
+export interface FontAtlas {
+ fonts: Record<string, FontAtlasEntry>;
+}
diff --git a/apps/web/src/types/rendering.ts b/apps/web/src/types/rendering.ts
new file mode 100644
index 00000000..f5e18502
--- /dev/null
+++ b/apps/web/src/types/rendering.ts
@@ -0,0 +1,18 @@
+export type BlendMode =
+ | "normal"
+ | "darken"
+ | "multiply"
+ | "color-burn"
+ | "lighten"
+ | "screen"
+ | "plus-lighter"
+ | "color-dodge"
+ | "overlay"
+ | "soft-light"
+ | "hard-light"
+ | "difference"
+ | "exclusion"
+ | "hue"
+ | "saturation"
+ | "color"
+ | "luminosity";
diff --git a/apps/web/src/types/sounds.ts b/apps/web/src/types/sounds.ts
index f7526846..eecf7701 100644
--- a/apps/web/src/types/sounds.ts
+++ b/apps/web/src/types/sounds.ts
@@ -1,39 +1,39 @@
-export interface SoundEffect {
- id: number;
- name: string;
- description: string;
- url: string;
- previewUrl?: string;
- downloadUrl?: string;
- duration: number;
- filesize: number;
- type: string;
- channels: number;
- bitrate: number;
- bitdepth: number;
- samplerate: number;
- username: string;
- tags: string[];
- license: string;
- created: string;
- downloads: number;
- rating: number;
- ratingCount: number;
-}
-
-export interface SavedSound {
- id: number; // freesound id
- name: string;
- username: string;
- previewUrl?: string;
- downloadUrl?: string;
- duration: number;
- tags: string[];
- license: string;
- savedAt: string; // iso date string
-}
-
-export interface SavedSoundsData {
- sounds: SavedSound[];
- lastModified: string;
-}
+export interface SoundEffect {
+ id: number;
+ name: string;
+ description: string;
+ url: string;
+ previewUrl?: string;
+ downloadUrl?: string;
+ duration: number;
+ filesize: number;
+ type: string;
+ channels: number;
+ bitrate: number;
+ bitdepth: number;
+ samplerate: number;
+ username: string;
+ tags: string[];
+ license: string;
+ created: string;
+ downloads: number;
+ rating: number;
+ ratingCount: number;
+}
+
+export interface SavedSound {
+ id: number; // freesound id
+ name: string;
+ username: string;
+ previewUrl?: string;
+ downloadUrl?: string;
+ duration: number;
+ tags: string[];
+ license: string;
+ savedAt: string; // iso date string
+}
+
+export interface SavedSoundsData {
+ sounds: SavedSound[];
+ lastModified: string;
+}
diff --git a/apps/web/src/types/stickers.ts b/apps/web/src/types/stickers.ts
index c6adfd7a..65c9aeb2 100644
--- a/apps/web/src/types/stickers.ts
+++ b/apps/web/src/types/stickers.ts
@@ -1,3 +1,54 @@
-import type { STICKER_CATEGORIES } from "@/constants/stickers-constants";
+import type { STICKER_CATEGORIES } from "@/constants/sticker-constants";
-export type StickerCategory = (typeof STICKER_CATEGORIES)[number];
+export type StickerCategory = keyof typeof STICKER_CATEGORIES;
+
+export interface StickerItem {
+ id: string;
+ provider: string;
+ name: string;
+ previewUrl: string;
+ metadata: Record<string, unknown>;
+}
+
+export interface StickerSearchResult {
+ items: StickerItem[];
+ total: number;
+ hasMore: boolean;
+}
+
+export interface StickerProviderSearchOptions {
+ limit?: number;
+}
+
+export interface StickerProviderBrowseOptions {
+ page?: number;
+ limit?: number;
+}
+
+export interface StickerResolveOptions {
+ width?: number;
+ height?: number;
+}
+
+export interface StickerProvider {
+ id: string;
+ search({
+ query,
+ options,
+ }: {
+ query: string;
+ options?: StickerProviderSearchOptions;
+ }): Promise<StickerSearchResult>;
+ browse({
+ options,
+ }: {
+ options?: StickerProviderBrowseOptions;
+ }): Promise<StickerSearchResult>;
+ resolveUrl({
+ stickerId,
+ options,
+ }: {
+ stickerId: string;
+ options?: StickerResolveOptions;
+ }): string;
+}
diff --git a/apps/web/src/types/timeline.ts b/apps/web/src/types/timeline.ts
index 69d40518..da96c7de 100644
--- a/apps/web/src/types/timeline.ts
+++ b/apps/web/src/types/timeline.ts
@@ -1,9 +1,18 @@
+import type { BlendMode } from "./rendering";
+
+export interface Bookmark {
+ time: number;
+ note?: string;
+ color?: string;
+ duration?: number;
+}
+
export interface TScene {
id: string;
name: string;
isMain: boolean;
tracks: TimelineTrack[];
- bookmarks: number[];
+ bookmarks: Bookmark[];
createdAt: Date;
updatedAt: Date;
}
@@ -87,6 +96,7 @@ export interface VideoElement extends BaseTimelineElement {
hidden?: boolean;
transform: Transform;
opacity: number;
+ blendMode?: BlendMode;
}
export interface ImageElement extends BaseTimelineElement {
@@ -95,6 +105,7 @@ export interface ImageElement extends BaseTimelineElement {
hidden?: boolean;
transform: Transform;
opacity: number;
+ blendMode?: BlendMode;
}
export interface TextElement extends BaseTimelineElement {
@@ -108,18 +119,21 @@ export interface TextElement extends BaseTimelineElement {
fontWeight: "normal" | "bold";
fontStyle: "normal" | "italic";
textDecoration: "none" | "underline" | "line-through";
+ letterSpacing?: number;
+ lineHeight?: number;
hidden?: boolean;
transform: Transform;
opacity: number;
+ blendMode?: BlendMode;
}
export interface StickerElement extends BaseTimelineElement {
type: "sticker";
- iconName: string;
+ stickerId: string;
hidden?: boolean;
transform: Transform;
opacity: number;
- color?: string;
+ blendMode?: BlendMode;
}
export type TimelineElement =
@@ -147,8 +161,6 @@ export type CreateTimelineElement =
| CreateTextElement
| CreateStickerElement;
-// ---- Drag State ----
-
export interface ElementDragState {
isDragging: boolean;
elementId: string | null;
diff --git a/apps/web/src/utils/color.ts b/apps/web/src/utils/color.ts
new file mode 100644
index 00000000..7b01a92e
--- /dev/null
+++ b/apps/web/src/utils/color.ts
@@ -0,0 +1,182 @@
+import { converter, formatHex, formatHex8, parse, type Rgb } from "culori";
+
+export type ColorFormat = "hex" | "rgb" | "hsl" | "hsv";
+
+const toRgb = converter("rgb");
+const toHsv = converter("hsv");
+const toHsl = converter("hsl");
+
+export function hexToHsv({ hex }: { hex: string }): [number, number, number] {
+ const color = toHsv(`#${hex}`);
+ if (!color) return [0, 0, 0];
+ return [color.h ?? 0, color.s ?? 0, color.v ?? 0];
+}
+
+export function hsvToHex({
+ h,
+ s,
+ v,
+}: {
+ h: number;
+ s: number;
+ v: number;
+}): string {
+ const hex = formatHex({ mode: "hsv", h, s, v });
+ return hex.slice(1);
+}
+
+export function parseHexAlpha({ hex }: { hex: string }): {
+ rgb: string;
+ alpha: number;
+} {
+ const color = parse(`#${hex}`);
+ const rgbHex = color
+ ? formatHex(color).slice(1)
+ : hex.slice(0, 6).toLowerCase();
+ return {
+ rgb: rgbHex,
+ alpha: color?.alpha ?? 1,
+ };
+}
+
+export function appendAlpha({
+ rgbHex,
+ alpha,
+}: {
+ rgbHex: string;
+ alpha: number;
+}): string {
+ if (alpha >= 1) return rgbHex;
+ const hex8 = formatHex8({ mode: "rgb", r: 0, g: 0, b: 0, alpha });
+ const alphaHex = hex8.slice(7, 9);
+ return rgbHex + alphaHex;
+}
+
+function stripCssNoise({ text }: { text: string }): string {
+ let cleaned = text.trim();
+ cleaned = cleaned
+ .replace(/\s*!important\s*/gi, "")
+ .replace(/;+\s*$/, "")
+ .trim();
+
+ const colonIndex = cleaned.indexOf(":");
+ const parenIndex = cleaned.indexOf("(");
+ if (colonIndex !== -1 && (parenIndex === -1 || colonIndex < parenIndex)) {
+ cleaned = cleaned.slice(colonIndex + 1).trim();
+ }
+
+ return cleaned;
+}
+
+function colorToHexWithAlpha({ color }: { color: Rgb }): string {
+ const hex = formatHex(color).slice(1);
+ if (color.alpha !== undefined && color.alpha < 1) {
+ const hex8 = formatHex8(color);
+ return hex8.slice(1);
+ }
+ return hex;
+}
+
+export function extractColorFromText({
+ text,
+}: {
+ text: string;
+}): string | null {
+ const cleaned = stripCssNoise({ text });
+
+ const color = toRgb(cleaned);
+ if (color) return colorToHexWithAlpha({ color });
+
+ // bare hex without # (culori needs the prefix)
+ const bareHexMatch = cleaned.match(/^([0-9a-fA-F]{3,8})$/);
+ if (bareHexMatch) {
+ const withHash = toRgb(`#${bareHexMatch[1]}`);
+ if (withHash) return colorToHexWithAlpha({ color: withHash });
+ }
+
+ // fallback: find #hex anywhere in the original text
+ const embeddedHexMatch = text.match(/#([0-9a-fA-F]{3,8})\b/);
+ if (embeddedHexMatch) {
+ const embedded = toRgb(`#${embeddedHexMatch[1]}`);
+ if (embedded) return colorToHexWithAlpha({ color: embedded });
+ }
+
+ return null;
+}
+
+export function formatColorValue({
+ hex,
+ format,
+}: {
+ hex: string;
+ format: ColorFormat;
+}): string {
+ switch (format) {
+ case "hex":
+ return hex;
+ case "rgb": {
+ const color = toRgb(`#${hex}`);
+ if (!color) return hex;
+ return `${Math.round(color.r * 255)}, ${Math.round(color.g * 255)}, ${Math.round(color.b * 255)}`;
+ }
+ case "hsl": {
+ const color = toHsl(`#${hex}`);
+ if (!color) return hex;
+ return `${Math.round(color.h ?? 0)}, ${Math.round((color.s ?? 0) * 100)}%, ${Math.round((color.l ?? 0) * 100)}%`;
+ }
+ case "hsv": {
+ const color = toHsv(`#${hex}`);
+ if (!color) return hex;
+ return `${Math.round(color.h ?? 0)}, ${Math.round((color.s ?? 0) * 100)}%, ${Math.round((color.v ?? 0) * 100)}%`;
+ }
+ }
+}
+
+export function parseColorInput({
+ input,
+ format,
+}: {
+ input: string;
+ format: ColorFormat;
+}): string | null {
+ switch (format) {
+ case "hex": {
+ const cleaned = input.replace("#", "");
+ const isValidHex = /^[0-9a-fA-F]{3,8}$/.test(cleaned);
+ return isValidHex ? cleaned : null;
+ }
+ case "rgb": {
+ const parts = input.split(",").map((part) => parseInt(part.trim(), 10));
+ if (parts.length < 3 || parts.some(Number.isNaN)) return null;
+ const color = {
+ mode: "rgb" as const,
+ r: parts[0] / 255,
+ g: parts[1] / 255,
+ b: parts[2] / 255,
+ };
+ return formatHex(color).slice(1);
+ }
+ case "hsl": {
+ const parts = input.split(",").map((part) => parseFloat(part.trim()));
+ if (parts.length < 3 || parts.some(Number.isNaN)) return null;
+ const color = {
+ mode: "hsl" as const,
+ h: parts[0],
+ s: parts[1] / 100,
+ l: parts[2] / 100,
+ };
+ return formatHex(color).slice(1);
+ }
+ case "hsv": {
+ const parts = input.split(",").map((part) => parseFloat(part.trim()));
+ if (parts.length < 3 || parts.some(Number.isNaN)) return null;
+ const color = {
+ mode: "hsv" as const,
+ h: parts[0],
+ s: parts[1] / 100,
+ v: parts[2] / 100,
+ };
+ return formatHex(color).slice(1);
+ }
+ }
+}
diff --git a/apps/web/src/utils/math.ts b/apps/web/src/utils/math.ts
index 012ac747..ac82ca17 100644
--- a/apps/web/src/utils/math.ts
+++ b/apps/web/src/utils/math.ts
@@ -9,3 +9,19 @@ export function clamp({
}): number {
return Math.max(min, Math.min(max, value));
}
+
+export function evaluateMathExpression({
+ input,
+}: {
+ input: string;
+}): number | null {
+ const sanitized = input.trim();
+ if (!/^[\d.\s+\-*/()]+$/.test(sanitized)) return null;
+ try {
+ const result = new Function(`return (${sanitized})`)();
+ if (typeof result !== "number" || !Number.isFinite(result)) return null;
+ return result;
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index ee05eade..a8b420f7 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -1,38 +1,38 @@
{
- "compilerOptions": {
- "target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
- "allowJs": true,
- "skipLibCheck": true,
- "strict": true,
- "noEmit": true,
- "esModuleInterop": true,
- "module": "esnext",
- "moduleResolution": "bundler",
- "resolveJsonModule": true,
- "isolatedModules": true,
- "jsx": "react-jsx",
- "incremental": true,
- "plugins": [
- {
- "name": "next"
- }
- ],
- "paths": {
- "@/*": ["./src/*"]
- },
- "forceConsistentCasingInFileNames": true
- },
- "include": [
- "**/*.ts",
- "**/*.tsx",
- "apps/web/.next/types/**/*.ts",
- "next-env.d.ts",
- ".next/types/**/*.ts",
- "src/types/**/*.d.ts",
- "../../use-frame-cache.ts",
- "src/stores/timeline-store.ts",
- ".next/dev/types/**/*.ts"
- ],
- "exclude": ["node_modules"]
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./src/*"]
+ },
+ "forceConsistentCasingInFileNames": true
+ },
+ "include": [
+ "**/*.ts",
+ "**/*.tsx",
+ "apps/web/.next/types/**/*.ts",
+ "next-env.d.ts",
+ ".next/types/**/*.ts",
+ "src/types/**/*.d.ts",
+ "../../use-frame-cache.ts",
+ "src/stores/timeline-store.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": ["node_modules"]
}
diff --git a/biome.json b/biome.json
index 798ab9d2..55134582 100644
--- a/biome.json
+++ b/biome.json
@@ -13,8 +13,7 @@
"!**/node_modules/**",
"!**/dist/**",
"!**/build/**",
- "!**/apps/web/public/ffmpeg/**",
- "!**/apps/web/src/components/ui/**"
+ "!**/apps/web/public/ffmpeg/**"
]
},
"formatter": {
@@ -31,7 +30,6 @@
"javascript": {
"formatter": {
"quoteStyle": "double"
-
}
},
"assist": {
diff --git a/bun.lock b/bun.lock
index 5282a08c..6c46fcb9 100644
--- a/bun.lock
+++ b/bun.lock
@@ -28,11 +28,16 @@
"@huggingface/transformers": "^3.8.1",
"@opencut/env": "workspace:*",
"@opencut/ui": "workspace:*",
+ "@radix-ui/react-accordion": "^1.2.12",
+ "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
+ "@radix-ui/react-primitive": "^2.1.4",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
+ "@types/culori": "^4.0.1",
"@upstash/ratelimit": "^2.0.6",
"@upstash/redis": "^1.35.4",
"@vercel/analytics": "^1.4.1",
@@ -42,6 +47,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
+ "culori": "^4.0.2",
"dayjs": "^1.11.13",
"drizzle-orm": "^0.44.2",
"embla-carousel-react": "^8.5.1",
@@ -56,7 +62,7 @@
"next-themes": "^0.4.4",
"pg": "^8.16.2",
"postgres": "^3.4.5",
- "radix-ui": "^1.4.2",
+ "radix-ui": "^1.4.3",
"react": "^19.0.0",
"react-day-picker": "^8.10.1",
"react-dom": "^19.0.0",
@@ -65,6 +71,7 @@
"react-markdown": "^10.1.0",
"react-phone-number-input": "^3.4.11",
"react-resizable-panels": "^2.1.7",
+ "react-window": "^2.2.7",
"recharts": "^2.14.1",
"rehype-autolink-headings": "^7.1.0",
"rehype-parse": "^9.0.1",
@@ -76,12 +83,13 @@
"tailwindcss-animate": "^1.0.7",
"unified": "^11.0.5",
"use-deep-compare-effect": "^1.8.1",
- "vaul": "^1.1.1",
+ "vaul": "^1.1.2",
"wavesurfer.js": "^7.9.8",
"zod": "^3.25.67",
"zustand": "^5.0.2",
},
"devDependencies": {
+ "@napi-rs/canvas": "^0.1.92",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@types/bun": "latest",
@@ -93,6 +101,7 @@
"dotenv": "^16.5.0",
"drizzle-kit": "^0.31.4",
"postcss": "^8",
+ "sharp": "^0.34.5",
"tailwindcss": "^4.1.11",
"tsx": "^4.7.1",
"typescript": "^5.8.3",
@@ -295,6 +304,30 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="],
+ "@napi-rs/canvas": ["@napi-rs/canvas@0.1.92", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.92", "@napi-rs/canvas-darwin-arm64": "0.1.92", "@napi-rs/canvas-darwin-x64": "0.1.92", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.92", "@napi-rs/canvas-linux-arm64-gnu": "0.1.92", "@napi-rs/canvas-linux-arm64-musl": "0.1.92", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.92", "@napi-rs/canvas-linux-x64-gnu": "0.1.92", "@napi-rs/canvas-linux-x64-musl": "0.1.92", "@napi-rs/canvas-win32-arm64-msvc": "0.1.92", "@napi-rs/canvas-win32-x64-msvc": "0.1.92" } }, "sha512-q7ZaUCJkEU5BeOdE7fBx1XWRd2T5Ady65nxq4brMf5L4cE1VV/ACq5w9Z5b/IVJs8CwSSIwc30nlthH0gFo4Ig=="],
+
+ "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.92", "", { "os": "android", "cpu": "arm64" }, "sha512-rDOtq53ujfOuevD5taxAuIFALuf1QsQWZe1yS/N4MtT+tNiDBEdjufvQRPWZ11FubL2uwgP8ApYU3YOaNu1ZsQ=="],
+
+ "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.92", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4PT6GRGCr7yMRehp42x0LJb1V0IEy1cDZDDayv7eKbFUIGbPFkV7CRC9Bee5MPkjg1EB4ZPXXUyy3gjQm7mR8Q=="],
+
+ "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.92", "", { "os": "darwin", "cpu": "x64" }, "sha512-5e/3ZapP7CqPtDcZPtmowCsjoyQwuNMMD7c0GKPtZQ8pgQhLkeq/3fmk0HqNSD1i227FyJN/9pDrhw/UMTkaWA=="],
+
+ "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.92", "", { "os": "linux", "cpu": "arm" }, "sha512-j6KaLL9iir68lwpzzY+aBGag1PZp3+gJE2mQ3ar4VJVmyLRVOh+1qsdNK1gfWoAVy5w6U7OEYFrLzN2vOFUSng=="],
+
+ "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.92", "", { "os": "linux", "cpu": "arm64" }, "sha512-s3NlnJMHOSotUYVoTCoC1OcomaChFdKmZg0VsHFeIkeHbwX0uPHP4eCX1irjSfMykyvsGHTQDfBAtGYuqxCxhQ=="],
+
+ "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.92", "", { "os": "linux", "cpu": "arm64" }, "sha512-xV0GQnukYq5qY+ebkAwHjnP2OrSGBxS3vSi1zQNQj0bkXU6Ou+Tw7JjCM7pZcQ28MUyEBS1yKfo7rc7ip2IPFQ=="],
+
+ "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.92", "", { "os": "linux", "cpu": "none" }, "sha512-+GKvIFbQ74eB/TopEdH6XIXcvOGcuKvCITLGXy7WLJAyNp3Kdn1ncjxg91ihatBaPR+t63QOE99yHuIWn3UQ9w=="],
+
+ "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.92", "", { "os": "linux", "cpu": "x64" }, "sha512-tFd6MwbEhZ1g64iVY2asV+dOJC+GT3Yd6UH4G3Hp0/VHQ6qikB+nvXEULskFYZ0+wFqlGPtXjG1Jmv7sJy+3Ww=="],
+
+ "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.92", "", { "os": "linux", "cpu": "x64" }, "sha512-uSuqeSveB/ZGd72VfNbHCSXO9sArpZTvznMVsb42nqPP7gBGEH6NJQ0+hmF+w24unEmxBhPYakP/Wiosm16KkA=="],
+
+ "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.92", "", { "os": "win32", "cpu": "arm64" }, "sha512-20SK5AU/OUNz9ZuoAPj5ekWai45EIBDh/XsdrVZ8le/pJVlhjFU3olbumSQUXRFn7lBRS+qwM8kA//uLaDx6iQ=="],
+
+ "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.92", "", { "os": "win32", "cpu": "x64" }, "sha512-KEhyZLzq1MXCNlXybz4k25MJmHFp+uK1SIb8yJB0xfrQjz5aogAMhyseSzewo+XxAq3OAOdyKvfHGNzT3w1RPg=="],
+
"@next/env": ["@next/env@16.1.3", "", {}, "sha512-BLP14oBOvZWXgfdJf9ao+VD8O30uE+x7PaV++QtACLX329WcRSJRO5YJ+Bcvu0Q+c/lei41TjSiFf6pXqnpbQA=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CpOD3lmig6VflihVoGxiR/l5Jkjfi4uLaOR4ziriMv0YMDoF6cclI+p5t2nstM8TmaFiY6PCTBgRWB57/+LiBA=="],
@@ -349,9 +382,9 @@
"@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="],
- "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collapsible": "1.1.11", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A=="],
+ "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
- "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ=="],
+ "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
@@ -359,9 +392,9 @@
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="],
- "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA=="],
+ "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="],
- "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg=="],
+ "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
@@ -369,7 +402,7 @@
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
- "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-UsQUMjcYTsBjTSXw0P3GO0werEQvUY2plgRQuKoCTtkNr45q1DiL51j4m7gxhABzZ0BadoXNsIbg7F3KwiUBbw=="],
+ "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="],
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
@@ -377,31 +410,31 @@
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
- "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ=="],
+ "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="],
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
- "@radix-ui/react-form": ["@radix-ui/react-form@0.1.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IXLKFnaYvFg/KkeV5QfOX7tRnwHXp127koOFUjLWMTrRv5Rny3DQcAtIFFeA/Cli4HHM8DuJCXAUsgnFVJndlw=="],
+ "@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="],
- "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-CPYZ24Mhirm+g6D8jArmLzjYu4Eyg3TTUHswR26QgzXBHBe64BO/RHOJKzmF/Dxb4y4f9PKyJdwm/O/AhNkb+Q=="],
+ "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="],
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="],
- "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew=="],
+ "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="],
- "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Z71C7LGD+YDYo3TV81paUs8f3Zbmkvg6VLRQpKYfzioOE6n7fOhA3ApK/V/2Odolxjoc4ENk8AYCjohCNayd5A=="],
+ "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="],
- "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g=="],
+ "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="],
- "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.7", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-w1vm7AGI8tNXVovOK7TYQHrAGpRF7qQL+ENpT1a743De5Zmay2RbWGKAiYDKIyIuqptns+znCKwNztE2xl1n0Q=="],
+ "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="],
- "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F90uYnlBsLPU1UbSLciLsWQmk8+hdWa6SFw4GXaIdNWxFxI5ITKVdAG64f+Twaa9ic6xE7pqxPyUmodrGjT4pQ=="],
+ "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="],
- "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw=="],
+ "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
@@ -409,35 +442,35 @@
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
- "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
+ "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="],
- "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g=="],
+ "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="],
- "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q=="],
+ "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
- "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.9", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A=="],
+ "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
- "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw=="],
+ "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
- "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ=="],
+ "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],
- "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw=="],
+ "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
- "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg=="],
+ "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="],
- "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.9", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ZoFkBBz9zv9GWer7wIjvdRxmh2wyc2oKWw6C6CseWd6/yq1DK/l5lJ+wnsmFwJZbBYqr02mrf8A2q/CVCuM3ZA=="],
+ "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="],
- "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-toggle": "1.1.9", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kiU694Km3WFLTC75DdqgM/3Jauf3rD9wxeS9XtyWFKsBUeZA337lC+6uUazT7I1DhanZ5gyD5Stf8uf2dbQxOQ=="],
+ "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="],
- "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.10" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-jiwQsduEL++M4YBIurjSa+voD86OIytCod0/dbIxFZDLD8NfO1//keXYMfsW8BPcfqwoNjt+y06XcJqAb4KR7A=="],
+ "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
@@ -499,7 +532,9 @@
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.16", "", { "dependencies": { "lodash.castarray": "^4.4.0", "lodash.isplainobject": "^4.0.6", "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA=="],
- "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
+ "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
+
+ "@types/culori": ["@types/culori@4.0.1", "", {}, "sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ=="],
"@types/d3-array": ["@types/d3-array@3.2.1", "", {}, "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg=="],
@@ -575,7 +610,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
- "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="],
+ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="],
@@ -615,6 +650,8 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+ "culori": ["culori@4.0.2", "", {}, "sha512-1+BhOB8ahCn4O0cep0Sh2l9KCOfOdY+BXJnKMHFFzDEouSr/el18QwXEMRlOj9UY5nCeA8UN3a/82rUWRBeyBw=="],
+
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
@@ -965,7 +1002,7 @@
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
- "radix-ui": ["radix-ui@1.4.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.11", "@radix-ui/react-alert-dialog": "1.1.14", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.2", "@radix-ui/react-collapsible": "1.1.11", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.15", "@radix-ui/react-dialog": "1.1.14", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-dropdown-menu": "2.1.15", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.7", "@radix-ui/react-hover-card": "1.1.14", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.15", "@radix-ui/react-menubar": "1.1.15", "@radix-ui/react-navigation-menu": "1.2.13", "@radix-ui/react-one-time-password-field": "0.1.7", "@radix-ui/react-password-toggle-field": "0.1.2", "@radix-ui/react-popover": "1.1.14", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.7", "@radix-ui/react-roving-focus": "1.1.10", "@radix-ui/react-scroll-area": "1.2.9", "@radix-ui/react-select": "2.2.5", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.5", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.5", "@radix-ui/react-tabs": "1.1.12", "@radix-ui/react-toast": "1.2.14", "@radix-ui/react-toggle": "1.1.9", "@radix-ui/react-toggle-group": "1.1.10", "@radix-ui/react-toolbar": "1.1.10", "@radix-ui/react-tooltip": "1.2.7", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-fT/3YFPJzf2WUpqDoQi005GS8EpCi+53VhcLaHUj5fwkPYiZAjk1mSxFvbMA8Uq71L03n+WysuYC+mlKkXxt/Q=="],
+ "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="],
"raf-schd": ["raf-schd@4.0.3", "", {}, "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="],
@@ -999,6 +1036,8 @@
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
+ "react-window": ["react-window@2.2.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w=="],
+
"recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="],
"recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="],
@@ -1161,116 +1200,98 @@
"@opencut/web/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
- "@radix-ui/react-accordion/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
-
- "@radix-ui/react-alert-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-alert-dialog/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
+ "@radix-ui/react-alert-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
- "@radix-ui/react-checkbox/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-checkbox/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
+ "@radix-ui/react-aspect-ratio/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-collapsible/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-collapsible/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
-
- "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+ "@radix-ui/react-checkbox/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-context-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-collapsible/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+ "@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-dropdown-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
- "@radix-ui/react-form/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-context-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-hover-card/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-hover-card/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
+ "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
- "@radix-ui/react-hover-card/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
+ "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-hover-card/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
+ "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-menu/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
+ "@radix-ui/react-form/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-menu/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
+ "@radix-ui/react-hover-card/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-menu/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
+ "@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-menu/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
+ "@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
- "@radix-ui/react-menubar/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
-
- "@radix-ui/react-navigation-menu/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
-
- "@radix-ui/react-navigation-menu/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
-
- "@radix-ui/react-navigation-menu/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
-
- "@radix-ui/react-one-time-password-field/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
-
- "@radix-ui/react-password-toggle-field/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
-
- "@radix-ui/react-popover/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-menubar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-popover/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
+ "@radix-ui/react-navigation-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-popover/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
+ "@radix-ui/react-one-time-password-field/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-popover/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
+ "@radix-ui/react-password-toggle-field/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-popover/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
+ "@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
- "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+ "@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-radio-group/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-radio-group/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
+ "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-roving-focus/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-radio-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-scroll-area/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-scroll-area/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
+ "@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
-
- "@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
-
- "@radix-ui/react-slider/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-switch/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
-
- "@radix-ui/react-tabs/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
- "@radix-ui/react-tabs/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
+ "@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-toast/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-toast/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
+ "@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-toast/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
+ "@radix-ui/react-toast/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-toggle/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-toggle/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-toggle-group/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-toggle-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
- "@radix-ui/react-toolbar/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
+ "@radix-ui/react-toolbar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-toolbar/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
+ "@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
+
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
+
"@tailwindcss/oxide/detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" }, "bundled": true }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
@@ -1291,6 +1312,8 @@
"cmdk/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
+ "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
+
"dom-helpers/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"lightningcss/detect-libc": ["detect-libc@2.0.4", "", {}, "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA=="],
@@ -1305,26 +1328,12 @@
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
- "radix-ui/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
-
- "radix-ui/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw=="],
-
- "radix-ui/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
-
- "radix-ui/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
-
- "radix-ui/@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.7", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ=="],
-
- "radix-ui/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
-
- "radix-ui/@radix-ui/react-select": ["@radix-ui/react-select@2.2.5", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA=="],
+ "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"radix-ui/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="],
"radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
- "radix-ui/@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.7", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw=="],
-
"react-day-picker/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
"recharts/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
@@ -1375,11 +1384,67 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
- "@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ=="],
+ "@radix-ui/react-accordion/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-aspect-ratio/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-avatar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-checkbox/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-collapsible/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-context-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-form/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-hover-card/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
- "@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA=="],
+ "@radix-ui/react-menubar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
- "@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
+ "@radix-ui/react-navigation-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-one-time-password-field/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-password-toggle-field/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-radio-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-slider/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-switch/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-toast/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-toggle-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-toolbar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
+ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VyyPYFlOMNylG45GoAe0xDoLwWuowvf92F9kySqzYh8vmYm7D2u4iUJKa1tOUpS70Ku13ASrOkS4ScXFsTaCNQ=="],
@@ -1395,6 +1460,8 @@
"cmdk/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+ "cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"vaul/@radix-ui/react-dialog/@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA=="],
@@ -1405,6 +1472,8 @@
"vaul/@radix-ui/react-dialog/@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA=="],
+ "vaul/@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
+
"vaul/@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
}
}
diff --git a/docs/countries-search.md b/docs/countries-search.md
new file mode 100644
index 00000000..62ed3913
--- /dev/null
+++ b/docs/countries-search.md
@@ -0,0 +1,51 @@
+# Countries search — Data Guide
+
+## Structure
+
+Each country entry in `apps/web/public/countries.json` follows this shape:
+
+```json
+{
+ "name": "Denmark",
+ "code": "DK",
+ "languages": ["danish"],
+ "flag_colors": ["red", "white"],
+ "region": "Northern Europe"
+}
+```
+
+### name
+Use the commonly recognized country or territory name.
+
+### code
+Use the canonical country/territory code.
+
+### flag_colors
+Canonical color names only: `red`, `white`, `blue`, `green`, `yellow`, `black`, `orange`, `purple`.
+
+### languages
+The official or widely spoken languages.
+
+### region
+Consistent values — don't mix "Europe" and "Western Europe" for the same type of country.
+
+| Region | Examples |
+|---|---|
+| Northern Europe | Denmark, Sweden, Norway, Finland, Iceland |
+| Western Europe | France, Germany, Switzerland, UK, Netherlands |
+| Southern Europe | Italy, Spain, Greece, Portugal |
+| Eastern Europe | Poland, Russia, Czech Republic, Hungary |
+| Middle East | Saudi Arabia, UAE, Israel, Turkey |
+| East Asia | Japan, China, South Korea |
+| Southeast Asia | Thailand, Vietnam, Indonesia, Singapore |
+| South Asia | India, Pakistan, Bangladesh |
+| Central Asia | Kazakhstan, Uzbekistan |
+| North Africa | Egypt, Morocco, Tunisia |
+| Sub-Saharan Africa | Nigeria, Kenya, South Africa, Ethiopia |
+| North America | USA, Canada, Mexico |
+| Central America | Panama, Costa Rica, Guatemala |
+| Caribbean | Cuba, Bahamas, Jamaica |
+| South America | Brazil, Argentina, Colombia |
+| Oceania | Australia, New Zealand |
+| Atlantic Ocean | Ascension Island, Saint Helena |
+| North Atlantic | Bermuda |
diff --git a/package.json b/package.json
index be5d523e..213b6870 100644
--- a/package.json
+++ b/package.json
@@ -1,32 +1,33 @@
{
- "name": "opencut",
- "packageManager": "bun@1.2.18",
- "workspaces": [
- "apps/*",
- "packages/*"
- ],
- "scripts": {
- "dev:web": "turbo run dev --filter=@opencut/web",
- "build:web": "turbo run build --filter=@opencut/web",
- "dev:tools": "turbo run dev --filter=@opencut/tools",
- "build:tools": "turbo run build --filter=@opencut/tools",
- "start:tools": "turbo run start --filter=@opencut/tools",
- "lint:web": "biome lint apps/web/src --max-diagnostics=1000",
- "lint:web:fix": "biome lint apps/web/src --write --max-diagnostics=1000",
- "format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000",
- "test": "bun test"
- },
- "dependencies": {
- "@types/react": "^19.2.10",
- "@types/react-dom": "^19.2.3",
- "better-auth": "^1.4.15",
- "next": "^16.1.3"
- },
- "devDependencies": {
- "turbo": "^2.7.5",
- "typescript": "5.8.3"
- },
- "trustedDependencies": [
- "@tailwindcss/oxide"
- ]
+ "name": "opencut",
+ "packageManager": "bun@1.2.18",
+ "workspaces": [
+ "apps/*",
+ "packages/*"
+ ],
+ "scripts": {
+ "dev:web": "turbo run dev --filter=@opencut/web",
+ "build:web": "turbo run build --filter=@opencut/web",
+ "dev:tools": "turbo run dev --filter=@opencut/tools",
+ "build:tools": "turbo run build --filter=@opencut/tools",
+ "start:tools": "turbo run start --filter=@opencut/tools",
+ "lint:web": "biome lint apps/web/src --max-diagnostics=1000",
+ "lint:web:fix": "biome lint apps/web/src --write --max-diagnostics=1000",
+ "format:web": "biome format apps/web/src/services/renderer --write --max-diagnostics=1000",
+ "test": "bun test",
+ "generate:fonts": "npx tsx apps/web/scripts/generate-font-sprites.ts"
+ },
+ "dependencies": {
+ "@types/react": "^19.2.10",
+ "@types/react-dom": "^19.2.3",
+ "better-auth": "^1.4.15",
+ "next": "^16.1.3"
+ },
+ "devDependencies": {
+ "turbo": "^2.7.5",
+ "typescript": "5.8.3"
+ },
+ "trustedDependencies": [
+ "@tailwindcss/oxide"
+ ]
}
diff --git a/packages/env/package.json b/packages/env/package.json
index 0b794ef4..c07d117c 100644
--- a/packages/env/package.json
+++ b/packages/env/package.json
@@ -1,21 +1,21 @@
{
- "name": "@opencut/env",
- "version": "0.0.0",
- "description": "Environment package for OpenCut",
- "main": "./src/index.ts",
- "types": "./src/index.ts",
- "exports": {
- ".": "./src/index.ts",
- "./web": "./src/web.ts",
- "./tools": "./src/tools.ts"
- },
- "dependencies": {
- "zod": "^4.0.5"
- },
- "devDependencies": {
- "dotenv": "^16.4.7",
- "@types/bun": "latest",
- "@types/node": "^24.2.1",
- "typescript": "^5.8.3"
- }
+ "name": "@opencut/env",
+ "version": "0.0.0",
+ "description": "Environment package for OpenCut",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ ".": "./src/index.ts",
+ "./web": "./src/web.ts",
+ "./tools": "./src/tools.ts"
+ },
+ "dependencies": {
+ "zod": "^4.0.5"
+ },
+ "devDependencies": {
+ "dotenv": "^16.4.7",
+ "@types/bun": "latest",
+ "@types/node": "^24.2.1",
+ "typescript": "^5.8.3"
+ }
}
diff --git a/packages/env/src/tools.ts b/packages/env/src/tools.ts
index 625e3d71..a730f6ed 100644
--- a/packages/env/src/tools.ts
+++ b/packages/env/src/tools.ts
@@ -1,27 +1,27 @@
import { z } from "zod";
const toolsEnvSchema = z.object({
- // Node
- NODE_ENV: z.enum(["development", "production", "test"]),
- ANALYZE: z.string().optional(),
- NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(),
+ // Node
+ NODE_ENV: z.enum(["development", "production", "test"]),
+ ANALYZE: z.string().optional(),
+ NEXT_RUNTIME: z.enum(["nodejs", "edge"]).optional(),
- // Public
- NEXT_PUBLIC_SITE_URL: z.url().default("http://localhost:3000"),
+ // Public
+ NEXT_PUBLIC_SITE_URL: z.url().default("http://localhost:3000"),
- // Server
- DATABASE_URL: z
- .string()
- .startsWith("postgres://")
- .or(z.string().startsWith("postgresql://")),
+ // Server
+ DATABASE_URL: z
+ .string()
+ .startsWith("postgres://")
+ .or(z.string().startsWith("postgresql://")),
- BETTER_AUTH_SECRET: z.string(),
- UPSTASH_REDIS_REST_URL: z.url(),
- UPSTASH_REDIS_REST_TOKEN: z.string(),
- CLOUDFLARE_ACCOUNT_ID: z.string(),
- R2_ACCESS_KEY_ID: z.string(),
- R2_SECRET_ACCESS_KEY: z.string(),
- R2_BUCKET_NAME: z.string(),
+ BETTER_AUTH_SECRET: z.string(),
+ UPSTASH_REDIS_REST_URL: z.url(),
+ UPSTASH_REDIS_REST_TOKEN: z.string(),
+ CLOUDFLARE_ACCOUNT_ID: z.string(),
+ R2_ACCESS_KEY_ID: z.string(),
+ R2_SECRET_ACCESS_KEY: z.string(),
+ R2_BUCKET_NAME: z.string(),
});
export type ToolsEnv = z.infer<typeof toolsEnvSchema>;
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 81dbae68..7b39fb96 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -1,19 +1,19 @@
{
- "name": "@opencut/ui",
- "version": "0.0.0",
- "description": "UI package for OpenCut",
- "main": "./src/index.ts",
- "types": "./src/index.ts",
- "exports": {
- "./icons": "./src/icons/index.tsx"
- },
- "dependencies": {
- "@iconify/react": "^6.0.2",
- "@types/react": "^19.2.7",
- "react": "^19.2.0"
- },
- "devDependencies": {
- "@types/bun": "latest",
- "typescript": "^5.8.3"
- }
+ "name": "@opencut/ui",
+ "version": "0.0.0",
+ "description": "UI package for OpenCut",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "exports": {
+ "./icons": "./src/icons/index.tsx"
+ },
+ "dependencies": {
+ "@iconify/react": "^6.0.2",
+ "@types/react": "^19.2.7",
+ "react": "^19.2.0"
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ "typescript": "^5.8.3"
+ }
}
diff --git a/packages/ui/src/icons/ui.tsx b/packages/ui/src/icons/ui.tsx
index 60231c33..382ec7bb 100644
--- a/packages/ui/src/icons/ui.tsx
+++ b/packages/ui/src/icons/ui.tsx
@@ -30,3 +30,176 @@ export function OcVideoIcon({
</svg>
);
}
+
+export function OcCheckerboardIcon({
+ className = "",
+ size = 32,
+}: {
+ className?: string;
+ size?: number;
+}) {
+ return (
+ <svg
+ width={size}
+ height={size}
+ className={className}
+ viewBox="0 0 10 10"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Checkerboard</title>
+ <path d="M2 0H0V2H2V0Z" fill="currentColor" fillOpacity="0.5" />
+ <path d="M2 4H0V6H2V4Z" fill="currentColor" fillOpacity="0.5" />
+ <path d="M2 8H0V10H2V8Z" fill="currentColor" fillOpacity="0.5" />
+ <path d="M4 2H2V4H4V2Z" fill="currentColor" fillOpacity="0.5" />
+ <path d="M4 6H2V8H4V6Z" fill="currentColor" fillOpacity="0.5" />
+ <path d="M6 0H4V2H6V0Z" fill="currentColor" fillOpacity="0.5" />
+ <path d="M6 4H4V6H6V4Z" fill="currentColor" fillOpacity="0.5" />
+ <path d="M6 8H4V10H6V8Z" fill="currentColor" fillOpacity="0.5" />
+ <path d="M10 0H8V2H10V0Z" fill="currentColor" fillOpacity="0.5" />
+ <path d="M10 4H8V6H10V4Z" fill="currentColor" fillOpacity="0.5" />
+ <path d="M10 8H8V10H10V8Z" fill="currentColor" fillOpacity="0.5" />
+ <path d="M8 2H6V4H8V2Z" fill="currentColor" fillOpacity="0.5" />
+ <path d="M8 6H6V8H8V6Z" fill="currentColor" fillOpacity="0.5" />
+ </svg>
+ );
+}
+
+export function OcFontWeightIcon({
+ className = "",
+ size = 32,
+}: {
+ className?: string;
+ size?: number;
+}) {
+ return (
+ <svg
+ width={size}
+ height={size}
+ className={className}
+ viewBox="0 0 10 10"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Font Weight</title>
+ <rect
+ x="0.8"
+ y="1"
+ width="1"
+ height="8"
+ fill="currentColor"
+ fillOpacity="0.5"
+ />
+ <rect
+ x="3.6"
+ y="1"
+ width="1.6"
+ height="8"
+ fill="currentColor"
+ fillOpacity="0.5"
+ />
+ <rect
+ x="6.8"
+ y="1"
+ width="2.2"
+ height="8"
+ fill="currentColor"
+ fillOpacity="0.5"
+ />
+ </svg>
+ );
+}
+
+export function OcSlidersVerticalIcon({
+ className = "",
+ size = 32,
+}: {
+ className?: string;
+ size?: number;
+}) {
+ return (
+ <svg
+ width={size}
+ height={size}
+ className={className}
+ viewBox="0 0 24 24"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Sliders Vertical</title>
+ <path
+ d="M17.5103 2.82471L17.5103 21.1753"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ />
+ <path
+ d="M6.48975 2.82471L6.48975 21.1753"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ strokeLinecap="round"
+ />
+ <path
+ d="M17.51 6.00665C19.3023 6.00665 20.755 7.45952 20.7551 9.25177C20.7551 11.0441 19.3023 12.4969 17.51 12.4969C15.7178 12.4968 14.2649 11.044 14.2649 9.25177C14.265 7.45959 15.7178 6.00675 17.51 6.00665Z"
+ fill="white"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ />
+ <path
+ d="M6.4895 11.702C8.28177 11.702 9.73452 13.1549 9.73462 14.9471C9.73462 16.7395 8.28183 18.1923 6.4895 18.1923C4.69726 18.1922 3.24438 16.7394 3.24438 14.9471C3.24448 13.155 4.69732 11.7021 6.4895 11.702Z"
+ fill="white"
+ stroke="currentColor"
+ strokeWidth="1.5"
+ />
+ </svg>
+ );
+}
+
+export function OcSocialIcon({
+ className = "",
+ size = 32,
+}: {
+ className?: string;
+ size?: number;
+}) {
+ return (
+ <svg
+ width={Math.round(size * (38 / 24))}
+ height={size}
+ className={className}
+ viewBox="0 0 38 24"
+ fill="none"
+ xmlns="http://www.w3.org/2000/svg"
+ >
+ <title>Social media</title>
+ <path
+ d="M2.48387 19.5491C2.59868 20.0193 2.70552 20.4948 2.88633 20.9473C3.07775 21.4262 3.3026 21.8847 3.62924 22.2857C4.07098 22.8288 4.60789 23.2515 5.23598 23.5555C5.68696 23.7737 6.16008 23.9218 6.65912 23.9713C7.38674 24.0433 8.10648 23.9746 8.81911 23.8231C9.26378 23.729 9.70995 23.6432 10.1538 23.5442C13.3768 22.8256 16.6108 22.1577 19.8275 21.4093C20.2543 21.31 20.664 21.1654 21.0675 20.9964C21.8407 20.672 22.4777 20.171 22.9686 19.4917C23.5407 18.6999 23.8513 17.8163 23.8643 16.8288C23.8728 16.1834 23.7735 15.5521 23.6444 14.9251L23.6175 14.8002C23.6071 14.7518 21.3023 4.17237 21.2704 4.03285C21.1826 3.64961 21.0721 3.27439 20.9187 2.91194C20.7321 2.47022 20.512 2.04815 20.2027 1.67925C19.8758 1.28947 19.5002 0.956899 19.0665 0.689289C18.4023 0.279255 17.6869 0.042959 16.9055 0.00601943C16.1998 -0.0272952 15.5107 0.081762 14.8255 0.232175C14.8255 0.232175 14.8202 0.225824 14.8193 0.222467L4.44388 2.48973C4.44443 2.49334 4.44508 2.49692 4.44557 2.50041C4.05809 2.59258 3.66784 2.67478 3.29121 2.80762C2.87866 2.9533 2.47597 3.1249 2.10684 3.36167C1.5255 3.73496 1.05539 4.22125 0.691874 4.81275C0.280021 5.4829 0.0437631 6.20548 0.00614467 6.99499C-0.0274064 7.69941 0.080661 8.38675 0.227418 9.07065"
+ fill="#FFFC00"
+ />
+ <path
+ d="M13.6114 19.3048C13.5673 19.3144 13.5251 19.3219 13.4928 19.3274C13.4681 19.3351 13.4422 19.3418 13.4159 19.3475C12.4927 19.5493 11.7805 19.2452 11.152 18.9766C10.7016 18.7845 10.2767 18.6031 9.82974 18.6251C9.61022 18.6362 9.39558 18.6642 9.19193 18.7088C8.82499 18.789 8.54737 18.9096 8.34465 18.9974C8.21989 19.0515 8.11229 19.098 8.02546 19.117C7.93452 19.1369 7.81965 19.1408 7.73944 18.9927C7.676 18.8759 7.62372 18.7608 7.57332 18.6499C7.44727 18.3729 7.36309 18.2054 7.25119 18.2123C6.05436 18.2855 5.31297 18.1704 5.10742 17.8796C5.08587 17.8491 5.07057 17.8169 5.06156 17.7837C5.03559 17.6893 5.08457 17.5908 5.17486 17.5548C6.06158 17.2015 6.77365 16.534 7.29135 15.5702C7.69246 14.824 7.82634 14.1656 7.84032 14.093C7.84092 14.0897 7.84168 14.0863 7.84254 14.0832C7.91725 13.7813 7.89293 13.5398 7.77055 13.3657C7.54489 13.0445 7.01665 12.9948 6.66718 12.9618C6.58028 12.9538 6.49807 12.946 6.43152 12.935C6.11682 12.8832 5.58763 12.7441 5.57858 12.3944C5.57194 12.1395 5.85711 11.8986 6.11734 11.8417C6.1896 11.826 6.25637 11.8247 6.31578 11.8384C6.63978 11.9127 6.9195 11.9251 7.14702 11.8754C7.42996 11.8136 7.54322 11.6754 7.56961 11.6373C7.52868 11.4873 7.48403 11.329 7.44161 11.1793L7.44097 11.1773C7.145 10.1293 6.7765 8.825 6.94898 7.99318C7.46522 5.50329 9.56183 4.86029 10.1919 4.7226C10.2087 4.71893 10.466 4.66002 10.466 4.66002C10.4776 4.65727 10.4902 4.65435 10.5033 4.65149C11.1351 4.51342 13.3124 4.22268 14.8103 6.27386C15.3107 6.95918 15.5137 8.3008 15.6768 9.37907L15.6846 9.43066C15.7067 9.57709 15.7278 9.71564 15.7494 9.84971C15.7867 9.87171 15.9332 9.94372 16.1898 9.89818C16.4049 9.84242 16.6391 9.71805 16.8856 9.52882C16.9617 9.47022 17.054 9.44137 17.1178 9.42743C17.2141 9.40636 17.3163 9.4038 17.4054 9.42024L17.4102 9.42097C17.661 9.45323 17.8496 9.58606 17.8905 9.75991C17.9285 9.922 17.8609 10.1915 17.3202 10.5557C17.2649 10.593 17.1929 10.6346 17.1169 10.6784C16.8122 10.8544 16.3525 11.1199 16.2798 11.5062C16.2401 11.7157 16.3177 11.9454 16.5102 12.1888C16.5123 12.1915 16.5142 12.1942 16.5164 12.197C16.5786 12.285 18.068 14.3516 20.3698 14.2341C20.4668 14.2294 20.5521 14.2986 20.5674 14.3952C20.5728 14.4299 20.572 14.4659 20.5648 14.5025C20.4983 14.851 19.8712 15.2648 18.7518 15.6979C18.6468 15.7385 18.6391 15.9252 18.6389 16.2282C18.6389 16.3534 18.6379 16.4762 18.6284 16.6071C18.6197 16.7303 18.557 16.8032 18.4366 16.8296L18.4184 16.8335C18.34 16.8506 18.2258 16.8607 18.0798 16.8637C17.8246 16.8692 17.5403 16.8846 17.1912 16.9609C16.9876 17.0055 16.7808 17.0695 16.5762 17.1511C16.1608 17.3174 15.8492 17.6593 15.5192 18.0217C15.0572 18.5291 14.5346 19.1032 13.6114 19.3048Z"
+ fill="white"
+ />
+ <path
+ d="M10.5437 4.83846C11.1385 4.70847 13.2215 4.42157 14.657 6.3871C15.1297 7.03434 15.3287 8.3504 15.4886 9.40757C15.5141 9.5753 15.5386 9.73792 15.5635 9.89289L15.5742 9.9595L15.6285 9.99899C15.6505 10.0148 15.8532 10.1524 16.2238 10.0864L16.2298 10.0854L16.2359 10.0839C16.4768 10.0215 16.7342 9.88582 17.001 9.68087C17.0404 9.6507 17.0965 9.62791 17.1581 9.61444C17.2284 9.59909 17.3059 9.59606 17.3731 9.60868L17.3832 9.61036C17.5509 9.63123 17.6834 9.71072 17.7053 9.80405C17.7176 9.85682 17.7195 10.0563 17.2146 10.3964C17.1652 10.4297 17.0991 10.468 17.0223 10.5121C16.6876 10.7055 16.182 10.9974 16.0927 11.4706C16.0426 11.737 16.1329 12.0189 16.3613 12.308C16.4595 12.4468 17.9879 14.5472 20.3795 14.4253C20.3814 14.4363 20.3813 14.449 20.3782 14.4645C20.3591 14.5658 20.1795 14.9405 18.6835 15.5189C18.4493 15.6092 18.4489 15.8952 18.4488 16.2276C18.4485 16.3468 18.448 16.467 18.4389 16.5931C18.4359 16.6313 18.4344 16.6337 18.3962 16.642C18.3909 16.6431 18.3848 16.6445 18.3781 16.646C18.3094 16.661 18.2021 16.6694 18.0762 16.672C17.847 16.677 17.5351 16.6898 17.1509 16.7737C16.9365 16.8206 16.7197 16.8876 16.5062 16.9732C16.0496 17.156 15.7236 17.5141 15.3784 17.8934C14.9146 18.4024 14.4353 18.9291 13.5714 19.1179C13.5336 19.126 13.4965 19.1329 13.4593 19.139L13.4494 19.1408L13.4398 19.1436C13.419 19.1502 13.3979 19.1557 13.3761 19.1605C12.5122 19.3493 11.8587 19.0703 11.2269 18.8005C10.7562 18.5997 10.3116 18.4098 9.82086 18.4342C9.59148 18.4453 9.36634 18.4749 9.15206 18.5216C8.76801 18.6055 8.47993 18.7304 8.26976 18.8216C8.15412 18.8717 8.05396 18.9149 7.98553 18.9299C7.92948 18.9421 7.9277 18.939 7.90704 18.9009C7.8467 18.79 7.79594 18.678 7.74669 18.57C7.60908 18.2673 7.49038 18.0056 7.24022 18.0211C5.6405 18.1189 5.3224 17.8525 5.26317 17.7687C5.25389 17.7554 5.24846 17.7437 5.24545 17.7328C7.47179 16.8458 7.9955 14.2969 8.02769 14.1291C8.11652 13.7709 8.08247 13.4769 7.92644 13.2551C7.65012 12.8621 7.06963 12.8072 6.68563 12.7711C6.59736 12.7629 6.52132 12.7556 6.46266 12.7459C5.95613 12.6625 5.77231 12.5072 5.76918 12.3893C5.76571 12.2535 5.96197 12.0716 6.15826 12.0287C6.20237 12.0191 6.2426 12.0178 6.2741 12.025C6.62537 12.1056 6.93284 12.1182 7.18809 12.0624C7.58934 11.9747 7.72737 11.7479 7.74192 11.7223L7.77485 11.6639L7.75718 11.5991C7.71601 11.4478 7.67137 11.2896 7.62515 11.1268C7.33465 10.0979 6.97289 8.8183 7.13578 8.03213C7.62899 5.6535 9.63116 5.04094 10.2329 4.90946C10.2477 4.90621 10.5087 4.84636 10.5087 4.84636C10.5194 4.84395 10.5312 4.84118 10.5437 4.83846ZM10.4629 4.4644C10.4492 4.4674 10.4356 4.47037 10.4234 4.4732C10.3258 4.49557 10.1654 4.53227 10.151 4.53559C9.79852 4.61261 9.10406 4.81681 8.41914 5.31865C8.02752 5.60547 7.69404 5.95043 7.42803 6.34398C7.11081 6.81292 6.88688 7.3546 6.76267 7.95409C6.58071 8.83188 6.95615 10.1614 7.2578 11.2298L7.25842 11.2314C7.29073 11.3459 7.32451 11.4653 7.35696 11.5819C7.31009 11.6173 7.23118 11.6611 7.10678 11.6883C6.90663 11.732 6.65472 11.7197 6.35824 11.6517C6.27106 11.6319 6.17664 11.6329 6.07694 11.6547C5.91673 11.6897 5.7581 11.774 5.63032 11.8926C5.46952 12.0418 5.38367 12.2216 5.38817 12.3992C5.39123 12.5167 5.44043 12.7395 5.73557 12.9118C5.89793 13.0066 6.12173 13.078 6.40065 13.1239C6.47382 13.1359 6.55932 13.144 6.64988 13.1524C6.96421 13.1822 7.43988 13.2269 7.61527 13.4763C7.70416 13.6028 7.71843 13.7914 7.65778 14.0368C7.65624 14.0433 7.65468 14.0498 7.65343 14.0566C7.64008 14.1263 7.5116 14.7579 7.12397 15.4792C6.90376 15.8893 6.64136 16.2492 6.34466 16.5488C5.98253 16.9147 5.56535 17.1934 5.10482 17.3769C4.92393 17.4489 4.8263 17.6463 4.87802 17.8349C4.89312 17.8893 4.91781 17.9413 4.95155 17.9895V17.9895C5.06595 18.1515 5.27525 18.2654 5.59 18.3366C5.97457 18.4234 6.52667 18.4466 7.23078 18.4051C7.2794 18.4635 7.35085 18.6211 7.40004 18.7288C7.45192 18.8435 7.50565 18.9613 7.57252 19.0845C7.64481 19.2177 7.78926 19.3644 8.06592 19.3039C8.17077 19.2811 8.28621 19.2309 8.41994 19.1732C8.6157 19.0882 8.88342 18.972 9.23241 18.8958C9.42591 18.8535 9.63013 18.8268 9.83926 18.8164C10.2424 18.7965 10.6293 18.9615 11.077 19.1527C11.7323 19.4324 12.474 19.749 13.4564 19.5343C13.4832 19.5284 13.5099 19.5218 13.5361 19.5141C13.5688 19.5086 13.6093 19.5009 13.6517 19.4917C14.6344 19.277 15.1785 18.6792 15.6587 18.1516C15.9873 17.7912 16.2716 17.479 16.6465 17.3289C16.841 17.2511 17.0378 17.19 17.2314 17.1478C17.5647 17.0749 17.8379 17.0599 18.0836 17.0548C18.2441 17.0516 18.3668 17.0401 18.4585 17.02L18.4675 17.0183L18.4768 17.0162C18.6792 16.9721 18.8036 16.8278 18.8183 16.62C18.8282 16.483 18.829 16.3564 18.829 16.2282C18.8292 16.117 18.8294 15.9388 18.8494 15.8647C19.5075 15.6082 20.0002 15.3572 20.3143 15.1177C20.5708 14.9223 20.7137 14.7326 20.7516 14.5382C20.763 14.4799 20.7645 14.4219 20.7553 14.3649C20.7248 14.1719 20.5543 14.0328 20.3599 14.0429C18.1612 14.1549 16.7311 12.1702 16.6713 12.0857C16.6674 12.0801 16.6632 12.0746 16.659 12.0694C16.5026 11.8715 16.438 11.6939 16.4667 11.5416C16.5232 11.2418 16.9372 11.0026 17.2112 10.8444C17.2906 10.7988 17.3652 10.7556 17.4264 10.7144C17.6912 10.5361 17.8712 10.366 17.9772 10.1944C18.1038 9.98979 18.1005 9.82095 18.0758 9.71543C18.0154 9.46017 17.7713 9.27496 17.4373 9.23115C17.3232 9.21048 17.1989 9.2136 17.0773 9.24017C16.9945 9.25826 16.8745 9.29645 16.7702 9.37627C16.5471 9.54742 16.3385 9.65992 16.1495 9.71076C16.0468 9.72778 15.9708 9.72121 15.9202 9.71013C15.9043 9.60888 15.8886 9.50475 15.8721 9.3962L15.8649 9.34974C15.6987 8.25097 15.4919 6.88355 14.9638 6.16031C14.602 5.66497 14.1733 5.26466 13.6895 4.96988C13.2837 4.72293 12.8366 4.54857 12.3605 4.45154C11.5319 4.28302 10.8159 4.38727 10.4629 4.4644Z"
+ fill="#020202"
+ />
+ <path
+ d="M34.171 1.51855L20.6491 0.732336C18.4087 0.602071 16.4875 2.32309 16.358 4.57634L15.5762 18.1759C15.4467 20.4292 17.1579 22.3614 19.3983 22.4916L32.9202 23.2779C35.1606 23.4081 37.0818 21.6871 37.2113 19.4339L37.993 5.83429C38.1225 3.58104 36.4114 1.64882 34.171 1.51855Z"
+ fill="black"
+ />
+ <path
+ d="M30.5528 9.66749C31.5949 10.4816 32.8989 11.0007 34.3348 11.0842L34.4885 8.40945C34.2168 8.3937 33.9473 8.35051 33.6845 8.28053L33.5636 10.3859C32.1279 10.3025 30.8239 9.78331 29.7815 8.96928L29.4679 14.4277C29.3109 17.1583 26.897 19.2387 24.0765 19.0747C23.0241 19.0135 22.0636 18.6487 21.2814 18.0731C22.156 19.0451 23.4166 19.6897 24.8476 19.7729C27.6682 19.9369 30.0822 17.8564 30.2391 15.1258L30.5528 9.66749ZM31.7054 7.02699C31.1845 6.4082 30.864 5.62902 30.8334 4.78643L30.8532 4.4424L30.0868 4.39785C30.2185 5.47418 30.8241 6.42238 31.7054 7.02699ZM23.1858 16.0816C22.8985 15.6702 22.7587 15.1794 22.788 14.6847C22.8598 13.4358 23.9638 12.484 25.2542 12.5591C25.4947 12.573 25.7316 12.6225 25.9567 12.7062L26.114 9.97173C25.8482 9.92064 25.5787 9.88984 25.3082 9.87965L25.1859 12.0081C24.9607 11.9243 24.7235 11.8747 24.4829 11.8609C23.1926 11.7859 22.0885 12.7376 22.0168 13.9866C21.966 14.8697 22.4449 15.6647 23.1858 16.0816Z"
+ fill="#FF004F"
+ />
+ <path
+ d="M29.7815 8.96928C30.8239 9.78331 32.1279 10.3025 33.5636 10.3859L33.6845 8.28053C32.8926 8.06867 32.2064 7.62193 31.7054 7.02699C30.8241 6.42238 30.2185 5.47418 30.0868 4.39785L28.0738 4.28083L27.4599 14.964C27.3836 16.2092 26.2816 17.1571 24.9939 17.0821C24.2353 17.038 23.5814 16.6487 23.1858 16.0816C22.4449 15.6647 21.966 14.8697 22.0168 13.9866C22.0885 12.7376 23.1926 11.7859 24.4829 11.8609C24.7301 11.8753 24.9663 11.9264 25.1859 12.0081L25.3082 9.87965C22.534 9.77395 20.1794 11.8362 20.0245 14.5318C19.9471 15.8774 20.4319 17.1294 21.2814 18.0731C22.0636 18.6487 23.0241 19.0135 24.0765 19.0747C26.897 19.2387 29.3109 17.1583 29.4679 14.4277L29.7815 8.96928Z"
+ fill="white"
+ />
+ <path
+ d="M33.6845 8.28053L33.7172 7.71116C32.9945 7.6702 32.2973 7.43315 31.7054 7.02699C32.2151 7.63381 32.9071 8.07204 33.6845 8.28053ZM30.0868 4.39785C30.0742 4.29502 30.0659 4.19171 30.062 4.08825L30.0817 3.74422L27.3025 3.58263L26.6884 14.266C26.6124 15.5111 25.5105 16.4588 24.2228 16.3838C23.8448 16.3619 23.493 16.2543 23.1858 16.0816C23.5814 16.6487 24.2353 17.038 24.9939 17.0821C26.2816 17.1571 27.3836 16.2092 27.4599 14.964L28.0738 4.28083L30.0868 4.39785ZM25.3082 9.87965L25.3429 9.27363C25.1125 9.2294 24.8793 9.20037 24.6448 9.18684C21.8241 9.02283 19.4102 11.1033 19.2532 13.8337C19.1548 15.5455 19.9668 17.1062 21.2814 18.0731C20.4319 17.1294 19.9471 15.8774 20.0245 14.5318C20.1794 11.8362 22.534 9.77395 25.3082 9.87965Z"
+ fill="#00F2EA"
+ />
+ </svg>
+ );
+}
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
index d179b7d1..6b1c8bf9 100644
--- a/packages/ui/tsconfig.json
+++ b/packages/ui/tsconfig.json
@@ -1,20 +1,20 @@
{
- "compilerOptions": {
- "target": "ES2020",
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
- "module": "ESNext",
- "skipLibCheck": true,
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true,
- "jsx": "react-jsx",
- "strict": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true
- },
- "include": ["src/**/*"],
- "exclude": ["node_modules"]
+ "compilerOptions": {
+ "target": "ES2020",
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules"]
}
diff --git a/tsconfig.json b/tsconfig.json
index 04ab0f5e..4e68e321 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,7 +1,7 @@
{
- "compilerOptions": {
- "strictNullChecks": true,
- "moduleResolution": "bundler",
- "module": "esnext"
- }
+ "compilerOptions": {
+ "strictNullChecks": true,
+ "moduleResolution": "bundler",
+ "module": "esnext"
+ }
}
diff --git a/turbo.json b/turbo.json
index 2ca92852..e358ed12 100644
--- a/turbo.json
+++ b/turbo.json
@@ -1,44 +1,44 @@
{
- "$schema": "https://turborepo.com/schema.json",
- "tasks": {
- "build": {
- "dependsOn": ["^build"],
- "outputs": [".next/**", "!.next/cache/**"],
- "env": [
- "NODE_ENV",
- "NEXT_PUBLIC_SITE_URL",
- "NEXT_PUBLIC_MARBLE_API_URL",
- "DATABASE_URL",
- "BETTER_AUTH_SECRET",
- "UPSTASH_REDIS_REST_URL",
- "UPSTASH_REDIS_REST_TOKEN",
- "MARBLE_WORKSPACE_KEY",
- "FREESOUND_CLIENT_ID",
- "FREESOUND_API_KEY",
- "CLOUDFLARE_ACCOUNT_ID",
- "R2_ACCESS_KEY_ID",
- "R2_SECRET_ACCESS_KEY",
- "R2_BUCKET_NAME",
- "MODAL_TRANSCRIPTION_URL"
- ]
- },
- "check-types": {
- "dependsOn": ["^check-types"]
- },
- "dev": {
- "persistent": true,
- "cache": false
- },
- "lint": {
- "dependsOn": ["^lint"],
- "cache": false
- },
- "lint:fix": {
- "dependsOn": ["^lint:fix"],
- "cache": false
- },
- "format": {
- "dependsOn": ["^format"]
- }
- }
+ "$schema": "https://turborepo.com/schema.json",
+ "tasks": {
+ "build": {
+ "dependsOn": ["^build"],
+ "outputs": [".next/**", "!.next/cache/**"],
+ "env": [
+ "NODE_ENV",
+ "NEXT_PUBLIC_SITE_URL",
+ "NEXT_PUBLIC_MARBLE_API_URL",
+ "DATABASE_URL",
+ "BETTER_AUTH_SECRET",
+ "UPSTASH_REDIS_REST_URL",
+ "UPSTASH_REDIS_REST_TOKEN",
+ "MARBLE_WORKSPACE_KEY",
+ "FREESOUND_CLIENT_ID",
+ "FREESOUND_API_KEY",
+ "CLOUDFLARE_ACCOUNT_ID",
+ "R2_ACCESS_KEY_ID",
+ "R2_SECRET_ACCESS_KEY",
+ "R2_BUCKET_NAME",
+ "MODAL_TRANSCRIPTION_URL"
+ ]
+ },
+ "check-types": {
+ "dependsOn": ["^check-types"]
+ },
+ "dev": {
+ "persistent": true,
+ "cache": false
+ },
+ "lint": {
+ "dependsOn": ["^lint"],
+ "cache": false
+ },
+ "lint:fix": {
+ "dependsOn": ["^lint:fix"],
+ "cache": false
+ },
+ "format": {
+ "dependsOn": ["^format"]
+ }
+ }
}