diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b3b58e --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules +/.pnp +.pnp.js + +# testing +/coverage +/playwright-report +/test-results + +# next.js +.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +.turbo +dist +*.tsbuildinfo +docs diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..7229070 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..f8c66ba --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,21 @@ +{ + "name": "@pien-studio/api", + "private": true, + "scripts": { + "dev": "bun --watch src/index.ts", + "build": "bun build src/index.ts --outdir dist", + "start": "bun run dist/index.js", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@pien-studio/types": "workspace:*", + "elysia": "^1.1.25", + "zod": "^4.4.3" + }, + "devDependencies": { + "bun-types": "latest", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + } +} diff --git a/apps/api/src/index.test.ts b/apps/api/src/index.test.ts new file mode 100644 index 0000000..b7303bf --- /dev/null +++ b/apps/api/src/index.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { createApp } from "./index"; + +describe("api endpoints", () => { + it("returns root route info", async () => { + const app = createApp(); + const response = await app.handle(new Request("http://localhost/")); + const body = await response.json(); + expect(response.status).toBe(200); + expect(body.name).toBe("pien-api"); + }); + + it("returns healthy status", async () => { + const app = createApp(); + const response = await app.handle(new Request("http://localhost/health")); + const body = await response.json(); + expect(response.status).toBe(200); + expect(body.ok).toBe(true); + }); + + it("returns token for valid device payload", async () => { + const app = createApp(); + const response = await app.handle( + new Request("http://localhost/auth/device", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ deviceId: "dev1234", locale: "en" }), + }), + ); + const body = await response.json(); + expect(response.status).toBe(200); + expect(body.token).toBe("dev_dev1234"); + }); +}); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..bbe2fd4 --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,35 @@ +import { Elysia } from "elysia"; +import { DeviceSessionSchema } from "@pien-studio/types"; + +export function createApp() { + return new Elysia() + .get("/", () => ({ + name: "pien-api", + status: "ok", + })) + .get("/health", () => ({ ok: true, service: "pien-api" })) + .post("/auth/device", ({ body }) => { + const parsed = DeviceSessionSchema.safeParse(body); + if (!parsed.success) { + return new Response(JSON.stringify({ error: "invalid_device_payload" }), { + status: 400, + }); + } + return { + token: `dev_${parsed.data.deviceId}`, + scope: "local-sync", + }; + }) + .get("/sync/bootstrap", () => ({ + replication: { + pull: "/sync/pull", + push: "/sync/push", + strategy: "couch-compatible", + }, + })); +} + +if (import.meta.main) { + createApp().listen(4000); + console.log("pien api listening on http://localhost:4000"); +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..d479e30 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "types": ["bun-types"] + }, + "include": ["src/**/*.ts"] +} diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 0000000..26892d6 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["**/*.test.ts"], + }, +}); diff --git a/apps/web/app/editor/[projectId]/page.tsx b/apps/web/app/editor/[projectId]/page.tsx new file mode 100644 index 0000000..0edc0ba --- /dev/null +++ b/apps/web/app/editor/[projectId]/page.tsx @@ -0,0 +1,402 @@ +"use client"; + +import React from "react"; +import { useParams } from "next/navigation"; +import { MousePointer2, Hand, ScanFace, Type, ImagePlus } from "lucide-react"; +import { useEditorStore } from "../../../store/editor-store"; +import { useUiStore } from "../../../store/ui-store"; +import { CanvasSizeModal } from "../../../components/canvas-size-modal"; +import { EditorCanvasStage } from "../../../components/editor/editor-canvas-stage"; +import { EditorHeader } from "../../../components/editor/editor-header"; +import { EditorMobileSection } from "../../../components/editor/editor-mobile-section"; +import { EditorSidebar } from "../../../components/editor/editor-sidebar"; +import { ToolRail } from "../../../components/editor/tool-rail"; +import { exportProjectAsPng } from "../../../lib/export-png"; +import { createEditorToolControllers } from "../../../lib/editor-tool-controller"; +import { useFaceDetection } from "../../../hooks/use-face-detection"; +import { useFaceBlurWorkflow } from "../../../hooks/use-face-blur-workflow"; +import { useEditorAutosave } from "../../../hooks/use-editor-autosave"; +import { useEditorBindings } from "../../../hooks/use-editor-bindings"; +import { useEditorContextMenu } from "../../../hooks/use-editor-context-menu"; +import { useEditorLabels } from "../../../hooks/use-editor-labels"; +import { useEditorProjectLifecycle } from "../../../hooks/use-editor-project-lifecycle"; +import { useEditorShortcuts } from "../../../hooks/use-editor-shortcuts"; +import { useAssetCleanupJob } from "../../../hooks/use-asset-cleanup-job"; +import { useTranslations } from "../../../hooks/use-translations"; +import type { AspectRatio } from "@pien-studio/types"; + +const TOOL_ICONS: Record> = { + pointer: MousePointer2, + hand: Hand, + face: ScanFace, + "add-text": Type, + "import-image": ImagePlus, +}; + +export default function EditorPage() { + useAssetCleanupJob(); + const params = useParams<{ projectId: string }>(); + const projectId = params.projectId; + const { state, actions } = useEditorBindings(); + const { + project, + selectedLayerId, + selectedLayer, + canUndo, + canRedo, + history, + isDirty, + tool, + } = state; + const { + loadProjectById, + setProject, + saveCurrentProject, + selectLayer, + setSelectedLayerPosition, + setSelectedLayerPositionDraft, + setSelectedLayerSize, + setSelectedLayerSizeDraft, + setSelectedLayerRotation, + setSelectedLayerRotationDraft, + startTransaction, + commitTransaction, + removeSelectedLayer, + addLayerByType, + moveSelectedLayerOrder, + importImageFromFile, + setImageLayerFaceBlur, + setCanvasSize, + setTool, + undo, + redo, + exportProjectToJson, + jumpToPast, + jumpToFuture, + copySelectedLayer, + cutSelectedLayer, + pasteLayer, + } = actions; + const { theme, hydrate } = useUiStore((s) => s); + const { t } = useTranslations(); + const { headerLabels, contextMenuLabels, mobileLabels } = useEditorLabels(t); + const { contextMenu, openContextMenu, closeContextMenu } = useEditorContextMenu(); + const [canvasModalOpen, setCanvasModalOpen] = React.useState(false); + const [faceMlErrorModalOpen, setFaceMlErrorModalOpen] = React.useState(false); + const previousFaceStatusRef = React.useRef<"idle" | "detecting" | "unsupported">("idle"); + const imageInputRef = React.useRef(null); + + const selectedImageLayer = React.useMemo(() => { + if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) { + return null; + } + return { + id: selectedLayer.id, + sourceUri: selectedLayer.sourceUri, + width: selectedLayer.width, + height: selectedLayer.height, + }; + }, [selectedLayer?.id, selectedLayer?.type, selectedLayer?.sourceUri, selectedLayer?.width, selectedLayer?.height]); + + const isLayerStillSelected = React.useCallback((layerId: string) => { + const state = useEditorStore.getState(); + return state.selectedLayerId === layerId; + }, []); + + const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } = useFaceDetection({ + tool, + selectedLayerId, + selectedImageLayer, + activeLayerStillSelected: isLayerStillSelected, + }); + + const { + blurMethod, + setBlurMethod, + blurAmount, + setBlurAmount, + censorColor, + setCensorColor, + selectedFaceIndices, + faceBlurPreview, + toggleFaceIndex, + clearBlur, + blurFaces, + } = useFaceBlurWorkflow({ + selectedLayer, + faceDetectionsLayerId, + faceDetections, + setImageLayerFaceBlur, + }); + + React.useEffect(() => { + function handleBeforeUnload(e: BeforeUnloadEvent) { + if (isDirty) { + e.preventDefault(); + e.returnValue = ""; + } + } + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [isDirty]); + + useEditorProjectLifecycle({ + projectId, + hydrate, + loadProjectById, + setProject, + t, + }); + + const isDark = theme === "dark"; + const { width: cw, height: ch } = project.canvas; + + const handleSave = React.useCallback(async () => { + await saveCurrentProject(); + }, [saveCurrentProject]); + + const handleExportPng = React.useCallback(async () => { + await exportProjectAsPng(project, { isDark }); + }, [isDark, project, exportProjectAsPng]); + + const handleExportProjectFile = React.useCallback(() => { + const json = exportProjectToJson(); + const blob = new Blob([json], { type: "application/json" }); + const href = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = href; + anchor.download = `${project.title || "project"}.pien.json`; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(href); + }, [exportProjectToJson, project.title]); + + useEditorShortcuts({ + onSave: handleSave, + onCopy: copySelectedLayer, + onCut: cutSelectedLayer, + onPaste: pasteLayer, + onDelete: removeSelectedLayer, + }); + + useEditorAutosave({ + isDirty, + projectUpdatedAt: project.updatedAt, + saveCurrentProject, + }); + + React.useEffect(() => { + if (faceStatus === "unsupported" && previousFaceStatusRef.current !== "unsupported") { + setFaceMlErrorModalOpen(true); + } + previousFaceStatusRef.current = faceStatus; + }, [faceStatus]); + + async function handleImageImport(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + await importImageFromFile(file); + event.target.value = ""; + } + + function handleCanvasApply(width: number, height: number, aspect: AspectRatio) { + setCanvasSize(width, height); + } + + const toolControllers = React.useMemo( + () => + createEditorToolControllers({ + onAddTextLayer: () => addLayerByType("text"), + onImportImage: () => imageInputRef.current?.click(), + labels: { + pointer: t("editor.toolPointer"), + pan: t("editor.toolPan"), + face: t("editor.toolFace"), + text: t("editor.toolText"), + image: t("editor.toolImage"), + }, + }), + [addLayerByType, t], + ); + + const canvasBindings = { + onMoveLayer: (_id: string, x: number, y: number) => setSelectedLayerPositionDraft(x, y), + onMoveLayerEnd: (_id: string, x: number, y: number) => setSelectedLayerPosition(x, y), + onResizeLayer: (_id: string, width: number, height: number) => setSelectedLayerSizeDraft(width, height), + onResizeLayerEnd: (_id: string, width: number, height: number) => setSelectedLayerSize(width, height), + onRotateLayer: (_id: string, rotation: number) => setSelectedLayerRotationDraft(rotation), + onRotateLayerEnd: (_id: string, rotation: number) => setSelectedLayerRotation(rotation), + onInteractionStart: startTransaction, + onInteractionEnd: commitTransaction, + }; + + return ( +
+
+ imageInputRef.current?.click()} + onOpenCanvasSize={() => setCanvasModalOpen(true)} + onUndo={undo} + onRedo={redo} + onCopy={copySelectedLayer} + onCut={cutSelectedLayer} + onPaste={pasteLayer} + onSetHandTool={() => setTool("hand")} + onSetPointerTool={() => setTool("pointer")} + /> + +
+ + + + + void blurFaces(indices)} + onClearBlur={clearBlur} + /> +
+ + setCanvasModalOpen(true)} + onImportImage={() => imageInputRef.current?.click()} + onSelectLayer={selectLayer} + onMoveLayer={canvasBindings.onMoveLayer} + onMoveLayerEnd={canvasBindings.onMoveLayerEnd} + onResizeLayer={canvasBindings.onResizeLayer} + onResizeLayerEnd={canvasBindings.onResizeLayerEnd} + onRotateLayer={canvasBindings.onRotateLayer} + onRotateLayerEnd={canvasBindings.onRotateLayerEnd} + onInteractionStart={canvasBindings.onInteractionStart} + onInteractionEnd={canvasBindings.onInteractionEnd} + /> + + + setCanvasModalOpen(false)} + currentWidth={cw} + currentHeight={ch} + currentAspect={project.aspectRatio} + onApply={handleCanvasApply} + isDark={isDark} + /> + + {faceMlErrorModalOpen ? ( +
setFaceMlErrorModalOpen(false)}> +
event.stopPropagation()} + > +
+

{t("editor.faceTool")}

+ +
+

{t("editor.faceMlFailed")}

+
+ +
+
+
+ ) : null} +
+
+ ); +} diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css new file mode 100644 index 0000000..49956f9 --- /dev/null +++ b/apps/web/app/globals.css @@ -0,0 +1,40 @@ +@import "tailwindcss"; +@config "../tailwind.config.ts"; + +:root { + --color-cream: #fff7ef; + --color-berry: #cc5d8f; + --color-wine: #5a2339; + --color-mint: #c9f0df; + --color-ink: #1f1b24; + --color-accent: #f5a3c7; + --color-accent-strong: #e782b1; + --color-surface: #ffffff; + --color-surface-2: #f5f6f8; + --bg-a: #f5f6f8; + --bg-b: #f5f6f8; + --bg-c: #f5f6f8; +} + +:root[data-theme="dark"] { + --color-cream: #1f2126; + --color-berry: #d979a9; + --color-wine: #f1d9e7; + --color-mint: #2d3a35; + --color-ink: #eef1f5; + --color-accent: #f3a9cc; + --color-accent-strong: #e88bb8; + --color-surface: #2a2c31; + --color-surface-2: #23252a; + --bg-a: #1b1d21; + --bg-b: #1b1d21; + --bg-c: #1b1d21; +} + +body { + margin: 0; + color: var(--color-ink); + background: var(--bg-a), var(--bg-b), var(--bg-c); + min-height: 100vh; + font-family: "Zen Kaku Gothic New", "Noto Sans Thai", sans-serif; +} diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx new file mode 100644 index 0000000..54b43d0 --- /dev/null +++ b/apps/web/app/layout.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "pien.studio", + description: "Local-first expressive image editor", +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx new file mode 100644 index 0000000..4e20cc8 --- /dev/null +++ b/apps/web/app/page.tsx @@ -0,0 +1,256 @@ +"use client"; + +import React from "react"; +import { useRouter } from "next/navigation"; +import { addLayer, createLayer, createProject, parseProjectFile, setCanvasSize } from "@pien-studio/editor-core"; +import type { Project } from "@pien-studio/types"; +import { deleteProject, duplicateProject, loadProjects, upsertProject } from "@pien-studio/storage"; +import { useEditorStore } from "../store/editor-store"; +import { useUiStore } from "../store/ui-store"; +import { accentButtonClass, cx, mutedSurfaceClass, subtleButtonClass, surfaceClass } from "../lib/theme"; +import { UiPreferences } from "../components/ui-preferences"; +import { useAssetCleanupJob } from "../hooks/use-asset-cleanup-job"; +import { useTranslations } from "../hooks/use-translations"; + +export default function HomePage() { + useAssetCleanupJob(); + const router = useRouter(); + const setProject = useEditorStore((s) => s.setProject); + const { theme, hydrate } = useUiStore((s) => s); + const { t } = useTranslations(); + const [projects, setProjects] = React.useState([]); + const [showWipModal, setShowWipModal] = React.useState(true); + const [projectPendingDelete, setProjectPendingDelete] = React.useState(null); + const projectInputRef = React.useRef(null); + const imageInputRef = React.useRef(null); + const isDark = theme === "dark"; + + const refreshProjects = React.useCallback(async () => { + setProjects(await loadProjects()); + }, []); + + React.useEffect(() => { + hydrate(); + void refreshProjects(); + }, [hydrate, refreshProjects]); + + function openProject(project: Project) { + setProject(project); + router.push(`/editor/${project.id}`); + } + + async function confirmDeleteProject() { + if (!projectPendingDelete) return; + await deleteProject(projectPendingDelete.id); + await refreshProjects(); + setProjectPendingDelete(null); + } + + async function handleNewProject() { + const project = createProject(t("home.untitledProject")); + await upsertProject(project); + openProject(project); + } + + async function handleImportProjectFile(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + const raw = await file.text(); + try { + const parsed = parseProjectFile(raw); + if (!parsed.ok) return; + await upsertProject(parsed.project); + await refreshProjects(); + openProject(parsed.project); + } finally { + event.target.value = ""; + } + } + + function handleOpenImage(event: React.ChangeEvent) { + const file = event.target.files?.[0]; + if (!file) return; + const title = file.name.replace(/\.[^/.]+$/, "") || t("home.imageProject"); + const reader = new FileReader(); + reader.onload = async () => { + try { + const sourceUri = typeof reader.result === "string" ? reader.result : undefined; + if (!sourceUri) return; + + const imageSize = await new Promise<{ width: number; height: number }>((resolve) => { + const image = new Image(); + image.onload = () => { + resolve({ + width: Math.max(1, Math.round(image.naturalWidth)), + height: Math.max(1, Math.round(image.naturalHeight)), + }); + }; + image.onerror = () => resolve({ width: 1, height: 1 }); + image.src = sourceUri; + }); + + const base = createProject(title, "free"); + const projectWithImageCanvas = setCanvasSize(base, imageSize.width, imageSize.height); + + const project = addLayer(projectWithImageCanvas, createLayer("image", { + name: file.name, + sourceUri, + x: 0, + y: 0, + width: imageSize.width, + height: imageSize.height, + })); + + await upsertProject(project); + await refreshProjects(); + openProject(project); + } finally { + event.target.value = ""; + } + }; + reader.readAsDataURL(file); + } + + return ( + <> + {showWipModal ? ( +
setShowWipModal(false)}> +
event.stopPropagation()} + > +

{t("home.wipTitle")}

+

{t("home.wipBody")}

+

+ {t("home.wipSupportPrefix")} {" "} + + github.com/sponsors/YuzuZensai + +

+
+ +
+
+
+ ) : null} + + {projectPendingDelete ? ( +
setProjectPendingDelete(null)}> +
event.stopPropagation()} + > +

Delete project?

+

+ This will permanently delete {projectPendingDelete.title} from local storage. +

+
+ + +
+
+
+ ) : null} + +
+
+
+
+

pien.studio

+

{t("home.projectHub")}

+

{t("home.createOpenManage")}

+
+ +
+
+ +
+ + + + + +
+ +
+

{t("home.myProjects")}

+ {projects.length === 0 ?

{t("home.noProjectsYet")}

: null} +
+ {projects.map((project) => ( +
+

{project.title}

+

{new Date(project.updatedAt).toLocaleString()}

+
+ + + +
+
+ ))} +
+
+
+ + ); +} diff --git a/apps/web/components/canvas-renderer.tsx b/apps/web/components/canvas-renderer.tsx new file mode 100644 index 0000000..28fd263 --- /dev/null +++ b/apps/web/components/canvas-renderer.tsx @@ -0,0 +1,335 @@ +"use client"; + +import React from "react"; +import NextImage from "next/image"; +import { RotateCw } from "lucide-react"; +import { CANVAS_HANDLE_BASE_SIZE, CANVAS_ROTATE_HANDLE_BASE_SIZE } from "../lib/editor-constants"; +import { buildFaceLabelOverlays } from "../lib/canvas-geometry"; +import { renderFaceBlurRegions } from "../lib/face-blur-renderer"; +import { useCanvasInteractions } from "../hooks/use-canvas-interactions"; +import { useTranslations } from "../hooks/use-translations"; +import type { FaceBlurMethod, Layer } from "@pien-studio/types"; + +interface CanvasRendererProps { + layers: Layer[]; + canvasWidth: number; + canvasHeight: number; + selectedLayerId: string | null; + onSelectLayer: (id: string | null) => void; + onMoveLayer: (id: string, x: number, y: number) => void; + onMoveLayerEnd?: (id: string, x: number, y: number) => void; + onResizeLayer?: (id: string, width: number, height: number) => void; + onResizeLayerEnd?: (id: string, width: number, height: number) => void; + onRotateLayer?: (id: string, rotation: number) => void; + onRotateLayerEnd?: (id: string, rotation: number) => void; + onInteractionStart?: () => void; + onInteractionEnd?: () => void; + onContextMenu?: (x: number, y: number) => void; + isDark: boolean; + tool?: "pointer" | "hand" | "face"; + faceDetections?: { x: number; y: number; width: number; height: number; label?: string }[]; + faceOverlayLayerId?: string | null; + faceBlurPreview?: { + layerId: string; + method: FaceBlurMethod; + amount: number; + regions: { x: number; y: number; width: number; height: number }[]; + } | null; +} + +function BlurredImageLayer({ + layer, + width, + height, + faceBlurOverride, +}: { + layer: Layer; + width: number; + height: number; + faceBlurOverride?: { + method: FaceBlurMethod; + amount: number; + regions: { x: number; y: number; width: number; height: number; censorColor?: string }[]; + censorColor?: string; + } | null; +}) { + const canvasRef = React.useRef(null); + const imageRef = React.useRef(null); + + const draw = React.useCallback(() => { + const canvas = canvasRef.current; + const image = imageRef.current; + if (!canvas || !image) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + const cw = canvas.width; + const ch = canvas.height; + ctx.clearRect(0, 0, cw, ch); + ctx.drawImage(image, 0, 0, cw, ch); + const blur = faceBlurOverride ?? layer.faceBlur; + if (!blur || blur.regions.length === 0) return; + renderFaceBlurRegions(ctx, image, blur, cw, ch); + }, [faceBlurOverride, layer.faceBlur]); + + React.useEffect(() => { + if (!layer.sourceUri) return; + let canceled = false; + const image = new Image(); + image.crossOrigin = "anonymous"; + image.onload = () => { + if (canceled) return; + imageRef.current = image; + draw(); + }; + image.src = layer.sourceUri; + return () => { + canceled = true; + }; + }, [draw, layer.sourceUri]); + + React.useEffect(() => { + draw(); + }, [draw, width, height]); + + return ; +} + +export function CanvasRenderer({ + layers, + canvasWidth, + canvasHeight, + selectedLayerId, + onSelectLayer, + onMoveLayer, + onMoveLayerEnd, + onResizeLayer, + onResizeLayerEnd, + onRotateLayer, + onRotateLayerEnd, + onInteractionStart, + onInteractionEnd, + onContextMenu, + isDark, + tool = "pointer", + faceDetections = [], + faceOverlayLayerId = null, + faceBlurPreview = null, +}: CanvasRendererProps) { + const { t } = useTranslations(); + const { + containerRef, + viewport, + isSpacePan, + onContainerPointerDown, + onContainerPointerMove, + onContainerPointerUp, + onLayerPointerDown, + onResizeHandleDown, + onRotateHandleDown, + onTouchStart, + onTouchMove, + onTouchEnd, + onContextMenuOpen, + } = useCanvasInteractions({ + canvasWidth, + canvasHeight, + tool, + onSelectLayer, + onMoveLayer, + onMoveLayerEnd, + onResizeLayer, + onResizeLayerEnd, + onRotateLayer, + onRotateLayerEnd, + onInteractionStart, + onInteractionEnd, + onContextMenu, + }); + + const faceLabelOverlays = React.useMemo(() => { + if (tool !== "face" || !faceOverlayLayerId || faceDetections.length === 0) return []; + return buildFaceLabelOverlays(layers, faceOverlayLayerId, faceDetections, viewport); + }, [faceDetections, faceOverlayLayerId, layers, tool, viewport.scale, viewport.x, viewport.y]); + + return ( +
{ + if (tool === "pointer" && e.button === 0) onSelectLayer(null); + onContainerPointerDown(e); + }} + onMouseDown={(e) => e.preventDefault()} + onPointerMove={onContainerPointerMove} + onPointerUp={onContainerPointerUp} + onPointerCancel={onContainerPointerUp} + onDoubleClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + onContextMenu={onContextMenuOpen} + onTouchStart={onTouchStart} + onTouchMove={onTouchMove} + onTouchEnd={onTouchEnd} + > +
+ {layers.map((layer, idx) => { + const isSelected = layer.id === selectedLayerId; + const isImage = layer.type === "image"; + const layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined); + const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined); + const handleSize = CANVAS_HANDLE_BASE_SIZE / viewport.scale; + const handleSizePx = `${handleSize}px`; + const largeHandleSize = CANVAS_ROTATE_HANDLE_BASE_SIZE / viewport.scale; + const largeHandleSizePx = `${largeHandleSize}px`; + + return ( +
onLayerPointerDown(e, layer)} + onClick={() => onSelectLayer(layer.id)} + > + {isImage && layer.sourceUri ? ( + (faceBlurPreview && faceBlurPreview.layerId === layer.id && faceBlurPreview.regions.length > 0) || + (layer.faceBlur && layer.faceBlur.regions.length > 0) ? ( + + ) : ( + + ) + ) : ( +
+ {layer.type} +
+ )} + {tool === "face" && faceOverlayLayerId === layer.id + ? faceDetections.map((face, index) => ( +
+ )) + : null} + {isSelected && tool === "pointer" && isImage && onResizeLayer ? ( + <> + +
+ + ) : null} +
+ ); + })} +
+ {faceLabelOverlays.map((label) => ( + + {label.text} + + ))} +
+ ); +} diff --git a/apps/web/components/canvas-size-modal.tsx b/apps/web/components/canvas-size-modal.tsx new file mode 100644 index 0000000..f85fb33 --- /dev/null +++ b/apps/web/components/canvas-size-modal.tsx @@ -0,0 +1,173 @@ +"use client"; + +import React from "react"; +import type { AspectRatio } from "@pien-studio/types"; +import { useTranslations } from "../hooks/use-translations"; + +interface CanvasSizeModalProps { + isOpen: boolean; + onClose: () => void; + currentWidth: number; + currentHeight: number; + currentAspect: AspectRatio; + onApply: (width: number, height: number, aspect: AspectRatio) => void; + isDark: boolean; +} + +const PRESETS = [ + { labelKey: "editor.presetSquare", sublabel: "1080 × 1080", aspect: "1:1" as AspectRatio, width: 1080, height: 1080 }, + { labelKey: "editor.presetPortrait45", sublabel: "1080 × 1350", aspect: "4:5" as AspectRatio, width: 1080, height: 1350 }, + { labelKey: "editor.presetStory916", sublabel: "1080 × 1920", aspect: "9:16" as AspectRatio, width: 1080, height: 1920 }, + { labelKey: "editor.presetWidescreen", sublabel: "1920 × 1080", aspect: "16:9" as AspectRatio, width: 1920, height: 1080 }, + { labelKey: "editor.presetPhoto43", sublabel: "1440 × 1080", aspect: "4:3" as AspectRatio, width: 1440, height: 1080 }, + { labelKey: "editor.presetClassic32", sublabel: "1620 × 1080", aspect: "3:2" as AspectRatio, width: 1620, height: 1080 }, +]; + +export function CanvasSizeModal({ + isOpen, + onClose, + currentWidth, + currentHeight, + currentAspect, + onApply, + isDark, +}: CanvasSizeModalProps) { + const { t } = useTranslations(); + const [mode, setMode] = React.useState<"preset" | "custom">( + PRESETS.some((p) => p.aspect === currentAspect) ? "preset" : "custom", + ); + const [customWidth, setCustomWidth] = React.useState(currentWidth.toString()); + const [customHeight, setCustomHeight] = React.useState(currentHeight.toString()); + const [selectedPreset, setSelectedPreset] = React.useState(currentAspect); + + if (!isOpen) return null; + + function handleApply() { + if (mode === "preset") { + const preset = PRESETS.find((p) => p.aspect === selectedPreset)!; + onApply(preset.width, preset.height, preset.aspect); + } else { + const w = parseInt(customWidth, 10); + const h = parseInt(customHeight, 10); + if (!isNaN(w) && !isNaN(h) && w > 0 && h > 0) { + onApply(w, h, "free"); + } + } + onClose(); + } + + const overlay = "fixed inset-0 z-50 flex items-center justify-center bg-black/40"; + const panel = `w-full max-w-sm rounded-2xl border p-5 shadow-2xl ${ + isDark ? "border-white/15 bg-[#2b2d31]" : "border-black/15 bg-white" + }`; + + return ( +
+
e.stopPropagation()}> +
+

+ {t("editor.canvasSizeTitle")} +

+ +
+ +
+ {(["preset", "custom"] as const).map((m) => ( + + ))} +
+ + {mode === "preset" ? ( +
+ {PRESETS.map((p) => ( + + ))} +
+ ) : ( +
+
+ + setCustomWidth(e.target.value)} + min={1} + className={`flex-1 rounded border px-2 py-1.5 text-sm ${ + isDark ? "border-white/20 bg-[#25272b] text-[#e8eaed]" : "border-black/20 bg-[#f6f8fb] text-[#1f2430]" + }`} + /> + px +
+
+ + setCustomHeight(e.target.value)} + min={1} + className={`flex-1 rounded border px-2 py-1.5 text-sm ${ + isDark ? "border-white/20 bg-[#25272b] text-[#e8eaed]" : "border-black/20 bg-[#f6f8fb] text-[#1f2430]" + }`} + /> + px +
+
+ + {parseInt(customWidth) || 0} × {parseInt(customHeight) || 0} px + +
+
+ )} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/components/editor/canvas-context-menu.tsx b/apps/web/components/editor/canvas-context-menu.tsx new file mode 100644 index 0000000..2268ca5 --- /dev/null +++ b/apps/web/components/editor/canvas-context-menu.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { hoverSubtleClass } from "../../lib/theme"; + +type CanvasContextMenuProps = { + isDark: boolean; + x: number; + y: number; + labels: { + copy: string; + cut: string; + paste: string; + }; + onCopy: () => void; + onCut: () => void; + onPaste: () => void; +}; + +export function CanvasContextMenu({ isDark, x, y, labels, onCopy, onCut, onPaste }: CanvasContextMenuProps) { + return ( +
event.stopPropagation()} + > + + + +
+ ); +} diff --git a/apps/web/components/editor/editor-canvas-stage.test.tsx b/apps/web/components/editor/editor-canvas-stage.test.tsx new file mode 100644 index 0000000..0e9a16f --- /dev/null +++ b/apps/web/components/editor/editor-canvas-stage.test.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { EditorCanvasStage } from "./editor-canvas-stage"; + +vi.mock("../canvas-renderer", () => ({ + CanvasRenderer: ({ onContextMenu }: { onContextMenu?: (x: number, y: number) => void }) => ( + + ), +})); + +describe("EditorCanvasStage", () => { + it("wires context menu actions", () => { + const onCopy = vi.fn(); + const onCloseContextMenu = vi.fn(); + + render( + undefined} + onMoveLayer={() => undefined} + onMoveLayerEnd={() => undefined} + onResizeLayer={() => undefined} + onResizeLayerEnd={() => undefined} + onRotateLayer={() => undefined} + onRotateLayerEnd={() => undefined} + onInteractionStart={() => undefined} + onInteractionEnd={() => undefined} + onContextMenu={() => undefined} + onCloseContextMenu={onCloseContextMenu} + onCopy={onCopy} + onCut={() => undefined} + onPaste={() => undefined} + />, + ); + + fireEvent.click(screen.getByText("Copy")); + expect(onCopy).toHaveBeenCalledTimes(1); + expect(onCloseContextMenu).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/components/editor/editor-canvas-stage.tsx b/apps/web/components/editor/editor-canvas-stage.tsx new file mode 100644 index 0000000..2297f28 --- /dev/null +++ b/apps/web/components/editor/editor-canvas-stage.tsx @@ -0,0 +1,126 @@ +import React from "react"; +import type { FaceBlurMethod, Layer } from "@pien-studio/types"; +import type { FaceDetectionOverlay } from "../../hooks/use-face-detection"; +import { CanvasRenderer } from "../canvas-renderer"; +import { CanvasContextMenu } from "./canvas-context-menu"; + +type EditorCanvasStageProps = { + isDark: boolean; + layers: Layer[]; + canvasWidth: number; + canvasHeight: number; + selectedLayerId: string | null; + tool: "pointer" | "hand" | "face"; + faceDetections: FaceDetectionOverlay[]; + faceOverlayLayerId: string | null; + faceBlurPreview: { + layerId: string; + method: FaceBlurMethod; + amount: number; + regions: { x: number; y: number; width: number; height: number }[]; + } | null; + contextMenu: { x: number; y: number } | null; + labels: { copy: string; cut: string; paste: string }; + onSelectLayer: (id: string | null) => void; + onMoveLayer: (id: string, x: number, y: number) => void; + onMoveLayerEnd: (id: string, x: number, y: number) => void; + onResizeLayer: (id: string, width: number, height: number) => void; + onResizeLayerEnd: (id: string, width: number, height: number) => void; + onRotateLayer: (id: string, rotation: number) => void; + onRotateLayerEnd: (id: string, rotation: number) => void; + onInteractionStart: () => void; + onInteractionEnd: () => void; + onContextMenu: (x: number, y: number) => void; + onCloseContextMenu: () => void; + onCopy: () => void; + onCut: () => void; + onPaste: () => void; +}; + +export function EditorCanvasStage(props: EditorCanvasStageProps) { + const { + isDark, + layers, + canvasWidth, + canvasHeight, + selectedLayerId, + tool, + faceDetections, + faceOverlayLayerId, + faceBlurPreview, + contextMenu, + labels, + onSelectLayer, + onMoveLayer, + onMoveLayerEnd, + onResizeLayer, + onResizeLayerEnd, + onRotateLayer, + onRotateLayerEnd, + onInteractionStart, + onInteractionEnd, + onContextMenu, + onCloseContextMenu, + onCopy, + onCut, + onPaste, + } = props; + + return ( +
+
+
+ + {contextMenu ? ( + { + onCopy(); + onCloseContextMenu(); + }} + onCut={() => { + onCut(); + onCloseContextMenu(); + }} + onPaste={() => { + onPaste(); + onCloseContextMenu(); + }} + /> + ) : null} +
+
+
+ ); +} diff --git a/apps/web/components/editor/editor-header.tsx b/apps/web/components/editor/editor-header.tsx new file mode 100644 index 0000000..da40ccc --- /dev/null +++ b/apps/web/components/editor/editor-header.tsx @@ -0,0 +1,239 @@ +import React from "react"; +import Link from "next/link"; +import { Redo2, Undo2 } from "lucide-react"; +import { UiPreferences } from "../ui-preferences"; +import { dividerClass, hoverSubtleClass } from "../../lib/theme"; + +type EditorHeaderProps = { + isDark: boolean; + projectTitle: string; + canvasWidth: number; + canvasHeight: number; + canUndo: boolean; + canRedo: boolean; + isDirty: boolean; + labels: { + file: string; + edit: string; + view: string; + settings: string; + save: string; + exportPng: string; + exportProjectFile: string; + importImage: string; + canvasSize: string; + undo: string; + redo: string; + copy: string; + cut: string; + paste: string; + preferences: string; + panTool: string; + pointerTool: string; + unsavedChanges: string; + saved: string; + }; + onSave: () => void; + onExportPng: () => void; + onExportProjectFile: () => void; + onImportImage: () => void; + onOpenCanvasSize: () => void; + onUndo: () => void; + onRedo: () => void; + onCopy: () => void; + onCut: () => void; + onPaste: () => void; + onSetHandTool: () => void; + onSetPointerTool: () => void; +}; + +export function EditorHeader({ + isDark, + projectTitle, + canvasWidth, + canvasHeight, + canUndo, + canRedo, + isDirty, + labels, + onSave, + onExportPng, + onExportProjectFile, + onImportImage, + onOpenCanvasSize, + onUndo, + onRedo, + onCopy, + onCut, + onPaste, + onSetHandTool, + onSetPointerTool, +}: EditorHeaderProps) { + const menuClass = `absolute left-0 top-full z-30 min-w-[180px] rounded border p-1 text-[11px] opacity-0 shadow-xl transition-opacity pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 hover:pointer-events-auto hover:opacity-100 ${ + isDark ? "border-white/10 bg-[#2a2c31] text-[#e2e5ea]" : "border-black/10 bg-white text-[#1f2430]" + }`; + + return ( +
+
+ +

+ pien.studio +

+

{projectTitle}

+ + + +
+ +
+ + + + {isDirty ? labels.unsavedChanges : labels.saved} + +
+
+ ); +} + +function MenuShell({ isDark, label, menuClass, children }: { isDark: boolean; label: string; menuClass: string; children: React.ReactNode }) { + return ( +
+ +
event.stopPropagation()}> + {children} +
+
+ ); +} + +function FileMenu({ + isDark, + labels, + canvasWidth, + canvasHeight, + onSave, + onExportPng, + onExportProjectFile, + onImportImage, + onOpenCanvasSize, +}: { + isDark: boolean; + labels: EditorHeaderProps["labels"]; + canvasWidth: number; + canvasHeight: number; + onSave: () => void; + onExportPng: () => void; + onExportProjectFile: () => void; + onImportImage: () => void; + onOpenCanvasSize: () => void; +}) { + return ( +
+ +
+ + +
+ + +
+ ); +} + +function EditMenu({ + isDark, + labels, + canUndo, + canRedo, + onUndo, + onRedo, + onCopy, + onCut, + onPaste, +}: { + isDark: boolean; + labels: EditorHeaderProps["labels"]; + canUndo: boolean; + canRedo: boolean; + onUndo: () => void; + onRedo: () => void; + onCopy: () => void; + onCut: () => void; + onPaste: () => void; +}) { + return ( +
+ + +
+ + + +
+ ); +} + +function ViewMenu({ isDark, labels, onSetHandTool, onSetPointerTool }: { isDark: boolean; labels: EditorHeaderProps["labels"]; onSetHandTool: () => void; onSetPointerTool: () => void }) { + return ( +
+ + +
+ ); +} + +function SettingsMenu({ isDark, preferences }: { isDark: boolean; preferences: string }) { + return ( +
+

{preferences}

+ +
+ ); +} diff --git a/apps/web/components/editor/editor-mobile-section.test.tsx b/apps/web/components/editor/editor-mobile-section.test.tsx new file mode 100644 index 0000000..6afd277 --- /dev/null +++ b/apps/web/components/editor/editor-mobile-section.test.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { EditorMobileSection } from "./editor-mobile-section"; + +vi.mock("../canvas-renderer", () => ({ + CanvasRenderer: () =>
, +})); + +describe("EditorMobileSection", () => { + it("shows face detection helper message in face mode", () => { + render( + `Faces ${count}`, + import: "Import", + mood: "Mood", + quick: "Quick", + face: "Face", + decor: "Decor", + }} + onOpenCanvasSize={() => undefined} + onImportImage={() => undefined} + onSelectLayer={() => undefined} + onMoveLayer={() => undefined} + onMoveLayerEnd={() => undefined} + onResizeLayer={() => undefined} + onResizeLayerEnd={() => undefined} + onRotateLayer={() => undefined} + onRotateLayerEnd={() => undefined} + onInteractionStart={() => undefined} + onInteractionEnd={() => undefined} + />, + ); + + expect(screen.getByText("Faces 1")).toBeInTheDocument(); + expect(screen.getByText("Import")).toBeInTheDocument(); + }); +}); diff --git a/apps/web/components/editor/editor-mobile-section.tsx b/apps/web/components/editor/editor-mobile-section.tsx new file mode 100644 index 0000000..759673c --- /dev/null +++ b/apps/web/components/editor/editor-mobile-section.tsx @@ -0,0 +1,149 @@ +import React from "react"; +import { CanvasRenderer } from "../canvas-renderer"; +import type { FaceBlurMethod, Layer } from "@pien-studio/types"; +import type { FaceDetectionOverlay } from "../../hooks/use-face-detection"; + +type EditorMobileSectionProps = { + isDark: boolean; + canvasWidth: number; + canvasHeight: number; + layers: Layer[]; + selectedLayerId: string | null; + tool: "pointer" | "hand" | "face"; + faceDetections: FaceDetectionOverlay[]; + faceOverlayLayerId: string | null; + faceStatus: "idle" | "detecting" | "unsupported"; + faceBlurPreview: { + layerId: string; + method: FaceBlurMethod; + amount: number; + regions: { x: number; y: number; width: number; height: number }[]; + } | null; + labels: { + resize: string; + faceMlFailedShort: string; + detectingFacesShort: string; + faceDetectionTip: (count: number) => string; + import: string; + mood: string; + quick: string; + face: string; + decor: string; + }; + onOpenCanvasSize: () => void; + onImportImage: () => void; + onSelectLayer: (id: string | null) => void; + onMoveLayer: (id: string, x: number, y: number) => void; + onMoveLayerEnd: (id: string, x: number, y: number) => void; + onResizeLayer: (id: string, width: number, height: number) => void; + onResizeLayerEnd: (id: string, width: number, height: number) => void; + onRotateLayer: (id: string, rotation: number) => void; + onRotateLayerEnd: (id: string, rotation: number) => void; + onInteractionStart: () => void; + onInteractionEnd: () => void; +}; + +export function EditorMobileSection(props: EditorMobileSectionProps) { + const { + isDark, + canvasWidth, + canvasHeight, + layers, + selectedLayerId, + tool, + faceDetections, + faceOverlayLayerId, + faceStatus, + faceBlurPreview, + labels, + onOpenCanvasSize, + onImportImage, + onSelectLayer, + onMoveLayer, + onMoveLayerEnd, + onResizeLayer, + onResizeLayerEnd, + onRotateLayer, + onRotateLayerEnd, + onInteractionStart, + onInteractionEnd, + } = props; + + return ( +
+
+
+

+ {canvasWidth} x {canvasHeight}px +

+ +
+
+ +
+ {tool === "face" ? ( +

+ {faceStatus === "unsupported" + ? labels.faceMlFailedShort + : faceStatus === "detecting" + ? labels.detectingFacesShort + : labels.faceDetectionTip(faceDetections.length)} +

+ ) : null} +
+ +
+
+ + {[labels.mood, labels.quick, labels.face, labels.decor].map((toolLabel) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/web/components/editor/editor-sidebar.tsx b/apps/web/components/editor/editor-sidebar.tsx new file mode 100644 index 0000000..065fd8c --- /dev/null +++ b/apps/web/components/editor/editor-sidebar.tsx @@ -0,0 +1,115 @@ +import React from "react"; +import type { FaceBlurMethod, Layer, Project } from "@pien-studio/types"; +import { FacePanel } from "./face-panel"; +import { HistoryPanel } from "./history-panel"; +import { LayersPanel } from "./layers-panel"; +import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-detection"; + +type EditorSidebarProps = { + isDark: boolean; + tool: "pointer" | "hand" | "face"; + layers: Layer[]; + selectedLayerId: string | null; + selectedLayer: Layer | null; + history: { past: Project[]; future: Project[] }; + canUndo: boolean; + canRedo: boolean; + faceDetections: FaceDetectionOverlay[]; + facePreviews: FacePreview[]; + faceStatus: "idle" | "detecting" | "unsupported"; + blurMethod: FaceBlurMethod; + blurAmount: number; + censorColor: string; + selectedFaceIndices: number[]; + onSelectLayer: (layerId: string | null) => void; + onMoveLayerOrder: (direction: "up" | "down") => void; + onRemoveSelectedLayer: () => void; + onUndo: () => void; + onRedo: () => void; + onJumpToPast: (idx: number) => void; + onJumpToFuture: (idx: number) => void; + onSetBlurMethod: (method: FaceBlurMethod) => void; + onSetBlurAmount: (amount: number) => void; + onSetCensorColor: (color: string) => void; + onToggleFaceIndex: (index: number) => void; + onBlur: (indices: number[]) => void; + onClearBlur: () => void; +}; + +export function EditorSidebar({ + isDark, + tool, + layers, + selectedLayerId, + selectedLayer, + history, + canUndo, + canRedo, + faceDetections, + facePreviews, + faceStatus, + blurMethod, + blurAmount, + censorColor, + selectedFaceIndices, + onSelectLayer, + onMoveLayerOrder, + onRemoveSelectedLayer, + onUndo, + onRedo, + onJumpToPast, + onJumpToFuture, + onSetBlurMethod, + onSetBlurAmount, + onSetCensorColor, + onToggleFaceIndex, + onBlur, + onClearBlur, +}: EditorSidebarProps) { + return ( + + ); +} diff --git a/apps/web/components/editor/face-panel.tsx b/apps/web/components/editor/face-panel.tsx new file mode 100644 index 0000000..0f3f58d --- /dev/null +++ b/apps/web/components/editor/face-panel.tsx @@ -0,0 +1,184 @@ +"use client"; + +import type { Layer } from "@pien-studio/types"; +import Image from "next/image"; +import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-detection"; +import { useTranslations } from "../../hooks/use-translations"; +import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme"; + +type Props = { + isDark: boolean; + selectedLayer: Layer | null; + faceDetections: FaceDetectionOverlay[]; + facePreviews: FacePreview[]; + faceStatus: "idle" | "detecting" | "unsupported"; + blurMethod: "gaussian" | "pixelate" | "censor"; + blurAmount: number; + censorColor: string; + selectedFaceIndices: number[]; + hasActiveBlur: boolean; + onSetBlurMethod: (method: "gaussian" | "pixelate" | "censor") => void; + onSetBlurAmount: (amount: number) => void; + onSetCensorColor: (color: string) => void; + onToggleFaceIndex: (index: number) => void; + onBlur: (indices: number[]) => void; + onClearBlur: () => void; +}; + +export function FacePanel({ + isDark, + selectedLayer, + faceDetections, + facePreviews, + faceStatus, + blurMethod, + blurAmount, + censorColor, + selectedFaceIndices, + hasActiveBlur, + onSetBlurMethod, + onSetBlurAmount, + onSetCensorColor, + onToggleFaceIndex, + onBlur, + onClearBlur, +}: Props) { + const { t } = useTranslations(); + const hasFaces = faceDetections.length > 0; + + return ( +
+
+

{t("editor.faceTool")}

+ {faceDetections.length} +
+

+ {faceStatus === "unsupported" + ? t("editor.faceMlFailed") + : faceStatus === "detecting" + ? t("editor.detectingFaces") + : selectedLayer?.type !== "image" + ? t("editor.selectImageLayer") + : faceDetections.length === 0 + ? t("editor.noFacesFound") + : t("editor.facesDetected")} +

+ {hasFaces ? ( +
+ {faceDetections.map((face, index) => ( + + ))} +
+ ) : null} + +
+ +
+ {[ + { id: "gaussian", label: t("editor.soft") }, + { id: "pixelate", label: t("editor.pixelate") }, + { id: "censor", label: t("editor.censor") }, + ].map((option) => ( + + ))} +
+ {blurMethod !== "censor" ? ( +
+
+ {t("editor.strength")} + {blurAmount} +
+ onSetBlurAmount(Number(event.target.value))} + className="w-full" + /> +
+ ) : null} + {blurMethod === "censor" ? ( +
+
+ {t("editor.color")} +
+
+ onSetCensorColor(event.target.value)} + className="h-6 w-6 cursor-pointer rounded border-none" + /> + onSetCensorColor(event.target.value)} + className={`flex-1 rounded border px-1 py-0.5 text-[11px] ${isDark ? "border-white/20 bg-white/10 text-white" : "border-black/20 bg-white text-black"}`} + /> +
+
+ ) : null} + + {hasActiveBlur ? ( + + ) : null} +
+
+ ); +} diff --git a/apps/web/components/editor/history-panel.tsx b/apps/web/components/editor/history-panel.tsx new file mode 100644 index 0000000..bcd4c2e --- /dev/null +++ b/apps/web/components/editor/history-panel.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Redo2, Undo2 } from "lucide-react"; +import type { Project } from "@pien-studio/types"; +import { useTranslations } from "../../hooks/use-translations"; +import { panelClass, panelTitleClass } from "../../lib/theme"; + +type Props = { + history: { past: Project[]; future: Project[] }; + isDark: boolean; + canUndo: boolean; + canRedo: boolean; + onUndo: () => void; + onRedo: () => void; + onJumpToPast: (idx: number) => void; + onJumpToFuture: (idx: number) => void; +}; + +export function HistoryPanel({ history, isDark, canUndo, canRedo, onUndo, onRedo, onJumpToPast, onJumpToFuture }: Props) { + const { t } = useTranslations(); + + return ( +
+
+

{t("editor.history")}

+
+ + +
+
+
+ {[...history.past].reverse().map((_, i) => { + const idx = history.past.length - i; + return ( + + ); + })} + + {[...history.future].map((_, i) => ( + + ))} + {history.past.length === 0 && history.future.length === 0 ?

{t("editor.noHistoryYet")}

: null} +
+
+ ); +} diff --git a/apps/web/components/editor/layers-panel.tsx b/apps/web/components/editor/layers-panel.tsx new file mode 100644 index 0000000..151b5c5 --- /dev/null +++ b/apps/web/components/editor/layers-panel.tsx @@ -0,0 +1,74 @@ +"use client"; + +import type { Layer } from "@pien-studio/types"; +import Image from "next/image"; +import { useTranslations } from "../../hooks/use-translations"; +import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme"; + +type Props = { + layers: Layer[]; + selectedLayerId: string | null; + isDark: boolean; + onSelectLayer: (layerId: string) => void; + onMoveLayerOrder: (direction: "up" | "down") => void; + onRemoveSelectedLayer: () => void; +}; + +export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, onMoveLayerOrder, onRemoveSelectedLayer }: Props) { + const { t } = useTranslations(); + + return ( +
+
+

{t("editor.layers")}

+ + {layers.length} + +
+
+ {layers.slice().reverse().map((layer, idx) => { + const isSelected = layer.id === selectedLayerId; + const isImageLayer = layer.type === "image" || layer.type === "sticker"; + return ( + + ); + })} + {layers.length === 0 ?
{t("editor.noLayersYet")}
: null} +
+
+ + + +
+
+ ); +} diff --git a/apps/web/components/editor/tool-rail.tsx b/apps/web/components/editor/tool-rail.tsx new file mode 100644 index 0000000..974c6dc --- /dev/null +++ b/apps/web/components/editor/tool-rail.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { MousePointer2 } from "lucide-react"; +import type { EditorToolController } from "../../lib/editor-tool-controller"; +import type { EditorToolId } from "../../store/editor-store"; + +type Props = { + controllers: EditorToolController[]; + selectedTool: EditorToolId; + isDark: boolean; + icons: Record>; + onSetTool: (tool: EditorToolId) => void; +}; + +export function ToolRail({ controllers, selectedTool, isDark, icons, onSetTool }: Props) { + return ( + + ); +} diff --git a/apps/web/components/ui-preferences.tsx b/apps/web/components/ui-preferences.tsx new file mode 100644 index 0000000..811b777 --- /dev/null +++ b/apps/web/components/ui-preferences.tsx @@ -0,0 +1,43 @@ +"use client"; + +import React from "react"; +import { useUiStore } from "../store/ui-store"; +import { cx, subtleButtonClass } from "../lib/theme"; +import { useTranslations } from "../hooks/use-translations"; + +export function UiPreferences({ compact = false }: { compact?: boolean }) { + const { theme, locale, setTheme, setLocale } = useUiStore((s) => s); + const { t } = useTranslations(); + const isDark = theme === "dark"; + const wrapperHeight = compact ? "h-7" : "h-9"; + const textSize = compact ? "text-[10px]" : "text-xs"; + + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/apps/web/hooks/use-asset-cleanup-job.ts b/apps/web/hooks/use-asset-cleanup-job.ts new file mode 100644 index 0000000..54679c2 --- /dev/null +++ b/apps/web/hooks/use-asset-cleanup-job.ts @@ -0,0 +1,11 @@ +"use client"; + +import React from "react"; +import { startAssetCleanupJob } from "@pien-studio/storage"; + +export function useAssetCleanupJob() { + React.useEffect(() => { + const stop = startAssetCleanupJob(); + return () => stop(); + }, []); +} diff --git a/apps/web/hooks/use-canvas-interactions.ts b/apps/web/hooks/use-canvas-interactions.ts new file mode 100644 index 0000000..cdd2a2c --- /dev/null +++ b/apps/web/hooks/use-canvas-interactions.ts @@ -0,0 +1,497 @@ +import React from "react"; +import type { Layer } from "@pien-studio/types"; + +type Viewport = { + x: number; + y: number; + scale: number; +}; + +type UseCanvasInteractionsOptions = { + canvasWidth: number; + canvasHeight: number; + tool: "pointer" | "hand" | "face"; + onSelectLayer: (id: string | null) => void; + onMoveLayer: (id: string, x: number, y: number) => void; + onMoveLayerEnd?: (id: string, x: number, y: number) => void; + onResizeLayer?: (id: string, width: number, height: number) => void; + onResizeLayerEnd?: (id: string, width: number, height: number) => void; + onRotateLayer?: (id: string, rotation: number) => void; + onRotateLayerEnd?: (id: string, rotation: number) => void; + onInteractionStart?: () => void; + onInteractionEnd?: () => void; + onContextMenu?: (x: number, y: number) => void; +}; + +const MIN_SCALE = 0.1; +const MAX_SCALE = 10; +const ZOOM_FACTOR = 0.001; +const FIT_PADDING = 32; + +export function useCanvasInteractions(options: UseCanvasInteractionsOptions) { + const { + canvasWidth, + canvasHeight, + tool, + onSelectLayer, + onMoveLayer, + onMoveLayerEnd, + onResizeLayer, + onResizeLayerEnd, + onRotateLayer, + onRotateLayerEnd, + onInteractionStart, + onInteractionEnd, + onContextMenu, + } = options; + const [viewport, setViewport] = React.useState({ x: 0, y: 0, scale: 1 }); + const containerRef = React.useRef(null); + const isPanning = React.useRef(false); + const lastPos = React.useRef({ x: 0, y: 0 }); + const isMiddleMousePan = React.useRef(false); + const [isSpacePan, setIsSpacePan] = React.useState(false); + const [, setIsShiftPressed] = React.useState(false); + const didFitRef = React.useRef(false); + const interactionActiveRef = React.useRef(false); + const dragMoveRafRef = React.useRef(null); + const resizeMoveRafRef = React.useRef(null); + const resizeMovePendingRef = React.useRef<{ width: number; height: number; x: number; y: number } | null>(null); + const dragRef = React.useRef<{ + id: string; + startLayerX: number; + startLayerY: number; + startEventX: number; + startEventY: number; + lastX: number; + lastY: number; + } | null>(null); + const resizeRef = React.useRef<{ + id: string; + startWidth: number; + startHeight: number; + startX: number; + startY: number; + aspect: number; + lastWidth: number; + lastHeight: number; + corner: string; + startLayerX: number; + startLayerY: number; + } | null>(null); + const rotateRef = React.useRef<{ + id: string; + centerX: number; + centerY: number; + startAngle: number; + startRotation: number; + lastRotation: number; + } | null>(null); + const pinchRef = React.useRef<{ + active: boolean; + initialPinchPx: number; + initialScale: number; + initialX: number; + initialY: number; + pivotX: number; + pivotY: number; + } | null>(null); + + function beginInteraction() { + if (interactionActiveRef.current) return; + interactionActiveRef.current = true; + onInteractionStart?.(); + } + + function endInteraction() { + if (!interactionActiveRef.current) return; + interactionActiveRef.current = false; + onInteractionEnd?.(); + } + + function clientDist(t0: Pick, t1: Pick) { + const dx = t0.clientX - t1.clientX; + const dy = t0.clientY - t1.clientY; + return Math.sqrt(dx * dx + dy * dy); + } + + function wheelZoom(e: WheelEvent) { + e.preventDefault(); + e.stopPropagation(); + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + const factor = e.ctrlKey || e.metaKey ? 1 - e.deltaY * 0.01 : 1 - e.deltaY * ZOOM_FACTOR; + if (!Number.isFinite(factor) || factor === 0) return; + const pivotX = e.clientX - rect.left; + const pivotY = e.clientY - rect.top; + setViewport((vp) => { + const nextScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, vp.scale * factor)); + const scaleChange = nextScale / vp.scale; + return { + x: pivotX - (pivotX - vp.x) * scaleChange, + y: pivotY - (pivotY - vp.y) * scaleChange, + scale: nextScale, + }; + }); + } + + function zoomBy(factor: number, pivotX: number, pivotY: number) { + setViewport((vp) => { + const newScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, vp.scale * factor)); + const scaleChange = newScale / vp.scale; + return { + x: pivotX - (pivotX - vp.x) * scaleChange, + y: pivotY - (pivotY - vp.y) * scaleChange, + scale: newScale, + }; + }); + } + + React.useEffect(() => { + const el = containerRef.current; + if (!el) return; + const handler = (event: WheelEvent) => { + wheelZoom(event); + }; + el.addEventListener("wheel", handler, { passive: false }); + return () => el.removeEventListener("wheel", handler); + }, []); + + React.useLayoutEffect(() => { + didFitRef.current = false; + }, [canvasWidth, canvasHeight]); + + React.useLayoutEffect(() => { + if (!containerRef.current) return; + const observer = new ResizeObserver(() => { + if (!containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) return; + if (didFitRef.current) return; + const maxW = Math.max(1, rect.width - FIT_PADDING * 2); + const maxH = Math.max(1, rect.height - FIT_PADDING * 2); + const fitScale = Math.min(maxW / canvasWidth, maxH / canvasHeight, 1); + const nextX = (rect.width - canvasWidth * fitScale) / 2; + const nextY = (rect.height - canvasHeight * fitScale) / 2; + setViewport({ x: nextX, y: nextY, scale: fitScale }); + didFitRef.current = true; + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [canvasWidth, canvasHeight]); + + React.useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.code === "Space") { + if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return; + if (!isSpacePan) setIsSpacePan(true); + e.preventDefault(); + return; + } + if (e.key === "Shift") { + if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return; + setIsShiftPressed(true); + return; + } + if (!e.ctrlKey && !e.metaKey) return; + if (e.key === "-" || e.key === "_") { + e.preventDefault(); + const rect = containerRef.current?.getBoundingClientRect(); + const cx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2; + const cy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2; + zoomBy(0.8, cx, cy); + } else if (e.key === "+" || e.key === "=") { + e.preventDefault(); + const rect = containerRef.current?.getBoundingClientRect(); + const cx = rect ? rect.left + rect.width / 2 : window.innerWidth / 2; + const cy = rect ? rect.top + rect.height / 2 : window.innerHeight / 2; + zoomBy(1.25, cx, cy); + } else if (e.key === "0") { + e.preventDefault(); + setViewport({ x: 0, y: 0, scale: 1 }); + } + } + function handleKeyUp(e: KeyboardEvent) { + if (e.code === "Space") setIsSpacePan(false); + if (e.key === "Shift") setIsShiftPressed(false); + } + function handleWheelCaptured(e: WheelEvent) { + if (!e.ctrlKey) return; + e.preventDefault(); + } + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("wheel", handleWheelCaptured, { capture: true, passive: false }); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("wheel", handleWheelCaptured, { capture: true }); + }; + }, [isSpacePan]); + + function onContainerPointerDown(e: React.PointerEvent) { + if (e.button === 1) { + e.preventDefault(); + e.currentTarget.setPointerCapture(e.pointerId); + isMiddleMousePan.current = true; + lastPos.current = { x: e.clientX, y: e.clientY }; + return; + } + if (e.button !== 0) return; + if (tool !== "hand" && tool !== "face" && !isSpacePan) return; + e.currentTarget.setPointerCapture(e.pointerId); + isPanning.current = true; + lastPos.current = { x: e.clientX, y: e.clientY }; + } + + function onContainerPointerMove(e: React.PointerEvent) { + if (isMiddleMousePan.current || isPanning.current) { + const dx = e.clientX - lastPos.current.x; + const dy = e.clientY - lastPos.current.y; + lastPos.current = { x: e.clientX, y: e.clientY }; + setViewport((vp) => ({ ...vp, x: vp.x + dx, y: vp.y + dy })); + return; + } + if (tool === "pointer" && rotateRef.current && onRotateLayer) { + const { centerX, centerY, startAngle, startRotation, id } = rotateRef.current; + const currentAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX); + const delta = currentAngle - startAngle; + const nextRotation = startRotation + (delta * 180) / Math.PI; + rotateRef.current.lastRotation = nextRotation; + onRotateLayer(id, nextRotation); + return; + } + if (tool === "pointer" && resizeRef.current && onResizeLayer) { + const dx = (e.clientX - resizeRef.current.startX) / viewport.scale; + const dy = (e.clientY - resizeRef.current.startY) / viewport.scale; + const corner = resizeRef.current.corner; + let nextWidth = resizeRef.current.startWidth; + let nextHeight = resizeRef.current.startHeight; + let offsetX = 0; + let offsetY = 0; + + if (corner === "br") { + nextWidth = Math.max(8, resizeRef.current.startWidth + dx); + nextHeight = Math.max(8, resizeRef.current.startHeight + dy); + } else if (corner === "bl") { + nextWidth = Math.max(8, resizeRef.current.startWidth - dx); + nextHeight = Math.max(8, resizeRef.current.startHeight + dy); + } else if (corner === "tr") { + nextWidth = Math.max(8, resizeRef.current.startWidth + dx); + nextHeight = Math.max(8, resizeRef.current.startHeight - dy); + } else if (corner === "tl") { + nextWidth = Math.max(8, resizeRef.current.startWidth - dx); + nextHeight = Math.max(8, resizeRef.current.startHeight - dy); + } + + if (!e.shiftKey && (corner === "br" || corner === "bl" || corner === "tr" || corner === "tl")) { + const aspect = resizeRef.current.aspect || 1; + if (Math.abs(dx) > Math.abs(dy)) { + nextHeight = Math.max(8, nextWidth / aspect); + } else { + nextWidth = Math.max(8, nextHeight * aspect); + } + } + + if (corner === "bl" || corner === "tl") offsetX = resizeRef.current.startWidth - nextWidth; + if (corner === "tr" || corner === "tl") offsetY = resizeRef.current.startHeight - nextHeight; + + resizeRef.current.lastWidth = nextWidth; + resizeRef.current.lastHeight = nextHeight; + resizeMovePendingRef.current = { + width: nextWidth, + height: nextHeight, + x: resizeRef.current.startLayerX + offsetX, + y: resizeRef.current.startLayerY + offsetY, + }; + if (resizeMoveRafRef.current !== null) return; + resizeMoveRafRef.current = window.requestAnimationFrame(() => { + resizeMoveRafRef.current = null; + if (!resizeRef.current || !resizeMovePendingRef.current) return; + const pending = resizeMovePendingRef.current; + onResizeLayer(resizeRef.current.id, pending.width, pending.height); + if ((pending.x !== resizeRef.current.startLayerX || pending.y !== resizeRef.current.startLayerY) && onMoveLayer) { + onMoveLayer(resizeRef.current.id, pending.x, pending.y); + } + }); + return; + } + if (tool === "pointer" && dragRef.current) { + const dx = (e.clientX - dragRef.current.startEventX) / viewport.scale; + const dy = (e.clientY - dragRef.current.startEventY) / viewport.scale; + const nextX = dragRef.current.startLayerX + dx; + const nextY = dragRef.current.startLayerY + dy; + if (dragRef.current.lastX === nextX && dragRef.current.lastY === nextY) return; + dragRef.current.lastX = nextX; + dragRef.current.lastY = nextY; + if (dragMoveRafRef.current !== null) return; + dragMoveRafRef.current = window.requestAnimationFrame(() => { + dragMoveRafRef.current = null; + if (!dragRef.current) return; + onMoveLayer(dragRef.current.id, dragRef.current.lastX, dragRef.current.lastY); + }); + } + } + + function onContainerPointerUp() { + if (dragMoveRafRef.current !== null) { + window.cancelAnimationFrame(dragMoveRafRef.current); + dragMoveRafRef.current = null; + } + if (resizeMoveRafRef.current !== null) { + window.cancelAnimationFrame(resizeMoveRafRef.current); + resizeMoveRafRef.current = null; + if (resizeRef.current && resizeMovePendingRef.current && onResizeLayer) { + const pending = resizeMovePendingRef.current; + onResizeLayer(resizeRef.current.id, pending.width, pending.height); + if ((pending.x !== resizeRef.current.startLayerX || pending.y !== resizeRef.current.startLayerY) && onMoveLayer) { + onMoveLayer(resizeRef.current.id, pending.x, pending.y); + } + } + } + isPanning.current = false; + isMiddleMousePan.current = false; + if (dragRef.current && onMoveLayerEnd) { + const { id, lastX, lastY } = dragRef.current; + if (typeof lastX === "number" && typeof lastY === "number") onMoveLayerEnd(id, lastX, lastY); + } + if (resizeRef.current && onResizeLayerEnd) { + const { id, lastWidth, lastHeight } = resizeRef.current; + if (typeof lastWidth === "number" && typeof lastHeight === "number") onResizeLayerEnd(id, lastWidth, lastHeight); + } + if (rotateRef.current && onRotateLayerEnd) { + const { id, lastRotation } = rotateRef.current; + if (typeof lastRotation === "number") onRotateLayerEnd(id, lastRotation); + } + dragRef.current = null; + resizeRef.current = null; + resizeMovePendingRef.current = null; + rotateRef.current = null; + endInteraction(); + } + + function onLayerPointerDown(e: React.PointerEvent, layer: Layer) { + if (isSpacePan) return; + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + dragRef.current = { + id: layer.id, + startLayerX: layer.x, + startLayerY: layer.y, + startEventX: e.clientX, + startEventY: e.clientY, + lastX: layer.x, + lastY: layer.y, + }; + beginInteraction(); + onSelectLayer(layer.id); + } + + function onResizeHandleDown(e: React.PointerEvent, layer: Layer, corner: string) { + if (tool !== "pointer" || !onResizeLayer) return; + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + rotateRef.current = null; + const width = layer.width ?? Math.round(200 * layer.scale); + const height = layer.height ?? Math.round(150 * layer.scale); + resizeRef.current = { + id: layer.id, + startWidth: width, + startHeight: height, + startX: e.clientX, + startY: e.clientY, + aspect: width > 0 && height > 0 ? width / height : 1, + lastWidth: width, + lastHeight: height, + corner, + startLayerX: layer.x, + startLayerY: layer.y, + }; + beginInteraction(); + onSelectLayer(layer.id); + } + + function onRotateHandleDown(e: React.PointerEvent, layer: Layer) { + if (tool !== "pointer" || !onRotateLayer) return; + e.stopPropagation(); + e.currentTarget.setPointerCapture(e.pointerId); + resizeRef.current = null; + const rect = containerRef.current?.getBoundingClientRect(); + const width = layer.width ?? Math.round(200 * layer.scale); + const height = layer.height ?? Math.round(150 * layer.scale); + const centerX = (rect?.left ?? 0) + viewport.x + layer.x * viewport.scale + (width * viewport.scale) / 2; + const centerY = (rect?.top ?? 0) + viewport.y + layer.y * viewport.scale + (height * viewport.scale) / 2; + const startAngle = Math.atan2(e.clientY - centerY, e.clientX - centerX); + rotateRef.current = { + id: layer.id, + centerX, + centerY, + startAngle, + startRotation: layer.rotation, + lastRotation: layer.rotation, + }; + beginInteraction(); + onSelectLayer(layer.id); + } + + function onTouchStart(e: React.TouchEvent) { + if (e.touches.length === 2) { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left; + const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top; + pinchRef.current = { + active: true, + initialPinchPx: clientDist(e.touches[0], e.touches[1]), + initialScale: viewport.scale, + initialX: viewport.x, + initialY: viewport.y, + pivotX: midX, + pivotY: midY, + }; + } + } + + function onTouchMove(e: React.TouchEvent) { + if (pinchRef.current?.active && e.touches.length === 2) { + e.preventDefault(); + const px = clientDist(e.touches[0], e.touches[1]); + const ratio = px / pinchRef.current.initialPinchPx; + const nextScale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, pinchRef.current.initialScale * ratio)); + const scaleChange = nextScale / pinchRef.current.initialScale; + const { pivotX, pivotY, initialX, initialY } = pinchRef.current; + setViewport({ + x: pivotX - (pivotX - initialX) * scaleChange, + y: pivotY - (pivotY - initialY) * scaleChange, + scale: nextScale, + }); + } + } + + function onTouchEnd() { + if (pinchRef.current) pinchRef.current.active = false; + } + + function onContextMenuOpen(e: React.MouseEvent) { + if (!onContextMenu) return; + e.preventDefault(); + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + onContextMenu(e.clientX - rect.left, e.clientY - rect.top); + } + + return { + containerRef, + viewport, + isSpacePan, + onContainerPointerDown, + onContainerPointerMove, + onContainerPointerUp, + onLayerPointerDown, + onResizeHandleDown, + onRotateHandleDown, + onTouchStart, + onTouchMove, + onTouchEnd, + onContextMenuOpen, + }; +} diff --git a/apps/web/hooks/use-editor-autosave.ts b/apps/web/hooks/use-editor-autosave.ts new file mode 100644 index 0000000..825218b --- /dev/null +++ b/apps/web/hooks/use-editor-autosave.ts @@ -0,0 +1,30 @@ +import React from "react"; +import { AUTOSAVE_DELAY_MS } from "../lib/editor-constants"; + +type UseEditorAutosaveOptions = { + isDirty: boolean; + projectUpdatedAt: string; + saveCurrentProject: () => Promise; +}; + +export function useEditorAutosave(options: UseEditorAutosaveOptions) { + const { isDirty, projectUpdatedAt, saveCurrentProject } = options; + const autosaveInFlightRef = React.useRef(false); + const autosaveVersionRef = React.useRef(null); + + React.useEffect(() => { + if (!isDirty) return; + if (autosaveVersionRef.current === projectUpdatedAt) return; + + const timer = window.setTimeout(() => { + if (autosaveInFlightRef.current) return; + autosaveInFlightRef.current = true; + autosaveVersionRef.current = projectUpdatedAt; + void saveCurrentProject().finally(() => { + autosaveInFlightRef.current = false; + }); + }, AUTOSAVE_DELAY_MS); + + return () => window.clearTimeout(timer); + }, [isDirty, projectUpdatedAt, saveCurrentProject]); +} diff --git a/apps/web/hooks/use-editor-bindings.test.tsx b/apps/web/hooks/use-editor-bindings.test.tsx new file mode 100644 index 0000000..fddce32 --- /dev/null +++ b/apps/web/hooks/use-editor-bindings.test.tsx @@ -0,0 +1,17 @@ +import { renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { useEditorStore } from "../store/editor-store"; +import { useEditorBindings } from "./use-editor-bindings"; + +describe("useEditorBindings", () => { + it("returns selected layer derived from store state", () => { + useEditorStore.getState().resetProject(); + useEditorStore.getState().addLayerByType("text"); + + const { result } = renderHook(() => useEditorBindings()); + + expect(result.current.state.project.layers.length).toBe(1); + expect(result.current.state.selectedLayer?.id).toBe(result.current.state.selectedLayerId); + expect(typeof result.current.actions.undo).toBe("function"); + }); +}); diff --git a/apps/web/hooks/use-editor-bindings.ts b/apps/web/hooks/use-editor-bindings.ts new file mode 100644 index 0000000..5f45e30 --- /dev/null +++ b/apps/web/hooks/use-editor-bindings.ts @@ -0,0 +1,56 @@ +import React from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useEditorStore } from "../store/editor-store"; + +export function useEditorBindings() { + const state = useEditorStore( + useShallow((s) => ({ + project: s.project, + selectedLayerId: s.selectedLayerId, + canUndo: s.canUndo, + canRedo: s.canRedo, + history: s.history, + isDirty: s.isDirty, + tool: s.tool, + })), + ); + + const bindings = useEditorStore( + useShallow((s) => ({ + loadProjectById: s.loadProjectById, + setProject: s.setProject, + saveCurrentProject: s.saveCurrentProject, + selectLayer: s.selectLayer, + setSelectedLayerPosition: s.setSelectedLayerPosition, + setSelectedLayerPositionDraft: s.setSelectedLayerPositionDraft, + setSelectedLayerSize: s.setSelectedLayerSize, + setSelectedLayerSizeDraft: s.setSelectedLayerSizeDraft, + setSelectedLayerRotation: s.setSelectedLayerRotation, + setSelectedLayerRotationDraft: s.setSelectedLayerRotationDraft, + startTransaction: s.startTransaction, + commitTransaction: s.commitTransaction, + removeSelectedLayer: s.removeSelectedLayer, + addLayerByType: s.addLayerByType, + moveSelectedLayerOrder: s.moveSelectedLayerOrder, + importImageFromFile: s.importImageFromFile, + setImageLayerFaceBlur: s.setImageLayerFaceBlur, + setCanvasSize: s.setCanvasSize, + setTool: s.setTool, + undo: s.undo, + redo: s.redo, + exportProjectToJson: s.exportProjectToJson, + jumpToPast: s.jumpToPast, + jumpToFuture: s.jumpToFuture, + copySelectedLayer: s.copySelectedLayer, + cutSelectedLayer: s.cutSelectedLayer, + pasteLayer: s.pasteLayer, + })), + ); + + const selectedLayer = React.useMemo( + () => state.project.layers.find((layer) => layer.id === state.selectedLayerId) ?? null, + [state.project.layers, state.selectedLayerId], + ); + + return { state: { ...state, selectedLayer }, actions: bindings }; +} diff --git a/apps/web/hooks/use-editor-context-menu.ts b/apps/web/hooks/use-editor-context-menu.ts new file mode 100644 index 0000000..9fba128 --- /dev/null +++ b/apps/web/hooks/use-editor-context-menu.ts @@ -0,0 +1,22 @@ +import React from "react"; +import { CONTEXT_MENU_SIZE } from "../lib/editor-constants"; + +type ContextMenuPosition = { x: number; y: number }; + +export function useEditorContextMenu() { + const [contextMenu, setContextMenu] = React.useState(null); + + const openContextMenu = React.useCallback((x: number, y: number) => { + const rect = document.documentElement.getBoundingClientRect(); + const { width: menuWidth, height: menuHeight, viewportPadding: pad } = CONTEXT_MENU_SIZE; + const maxX = rect.width - menuWidth - pad; + const maxY = rect.height - menuHeight - pad; + setContextMenu({ x: Math.max(pad, Math.min(x, maxX)), y: Math.max(pad, Math.min(y, maxY)) }); + }, []); + + const closeContextMenu = React.useCallback(() => { + setContextMenu(null); + }, []); + + return { contextMenu, openContextMenu, closeContextMenu }; +} diff --git a/apps/web/hooks/use-editor-labels.ts b/apps/web/hooks/use-editor-labels.ts new file mode 100644 index 0000000..faa749a --- /dev/null +++ b/apps/web/hooks/use-editor-labels.ts @@ -0,0 +1,52 @@ +import React from "react"; + +type Translator = (key: string, params?: Record) => string; + +export function useEditorLabels(t: Translator) { + const headerLabels = React.useMemo( + () => ({ + file: t("editor.file"), + edit: t("editor.edit"), + view: t("editor.view"), + settings: t("editor.settings"), + save: t("editor.save"), + exportPng: t("editor.exportPng"), + exportProjectFile: t("editor.exportProjectFile"), + importImage: t("editor.importImage"), + canvasSize: t("editor.canvasSize"), + undo: t("editor.undo"), + redo: t("editor.redo"), + copy: t("editor.copy"), + cut: t("editor.cut"), + paste: t("editor.paste"), + preferences: t("editor.preferences"), + panTool: t("editor.panTool"), + pointerTool: t("editor.pointerTool"), + unsavedChanges: t("editor.unsavedChanges"), + saved: t("editor.saved"), + }), + [t], + ); + + const contextMenuLabels = React.useMemo( + () => ({ copy: t("editor.copy"), cut: t("editor.cut"), paste: t("editor.paste") }), + [t], + ); + + const mobileLabels = React.useMemo( + () => ({ + resize: t("editor.resize"), + faceMlFailedShort: t("editor.faceMlFailedShort"), + detectingFacesShort: t("editor.detectingFacesShort"), + faceDetectionTip: (count: number) => t("editor.faceDetectionTip", { count }), + import: t("editor.import"), + mood: t("editor.mood"), + quick: t("editor.quick"), + face: t("editor.face"), + decor: t("editor.decor"), + }), + [t], + ); + + return { headerLabels, contextMenuLabels, mobileLabels }; +} diff --git a/apps/web/hooks/use-editor-project-lifecycle.ts b/apps/web/hooks/use-editor-project-lifecycle.ts new file mode 100644 index 0000000..1d3e569 --- /dev/null +++ b/apps/web/hooks/use-editor-project-lifecycle.ts @@ -0,0 +1,35 @@ +import React from "react"; +import { createProject } from "@pien-studio/editor-core"; +import { upsertProject } from "@pien-studio/storage"; + +type Translator = (key: string, params?: Record) => string; + +type UseEditorProjectLifecycleOptions = { + projectId: string; + hydrate: () => void; + loadProjectById: (projectId: string) => Promise; + setProject: (project: ReturnType) => void; + t: Translator; +}; + +export function useEditorProjectLifecycle(options: UseEditorProjectLifecycleOptions) { + const { projectId, hydrate, loadProjectById, setProject, t } = options; + const initializedProjectId = React.useRef(null); + + React.useEffect(() => { + hydrate(); + }, [hydrate]); + + React.useEffect(() => { + if (initializedProjectId.current === projectId) return; + initializedProjectId.current = projectId; + + if (projectId === "new") { + const nextProject = createProject(t("home.untitledProject")); + setProject(nextProject); + void upsertProject(nextProject); + return; + } + void loadProjectById(projectId); + }, [loadProjectById, projectId, setProject, t]); +} diff --git a/apps/web/hooks/use-editor-shortcuts.ts b/apps/web/hooks/use-editor-shortcuts.ts new file mode 100644 index 0000000..246cf75 --- /dev/null +++ b/apps/web/hooks/use-editor-shortcuts.ts @@ -0,0 +1,49 @@ +import React from "react"; + +type UseEditorShortcutsOptions = { + onSave: () => void; + onCopy: () => void; + onCut: () => void; + onPaste: () => void; + onDelete: () => void; +}; + +export function useEditorShortcuts(options: UseEditorShortcutsOptions) { + const { onSave, onCopy, onCut, onPaste, onDelete } = options; + + React.useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.ctrlKey || e.metaKey) { + if (e.key.toLowerCase() === "s") { + e.preventDefault(); + onSave(); + return; + } + if (e.key.toLowerCase() === "c") { + e.preventDefault(); + onCopy(); + return; + } + if (e.key.toLowerCase() === "x") { + e.preventDefault(); + onCut(); + return; + } + if (e.key.toLowerCase() === "v") { + e.preventDefault(); + onPaste(); + return; + } + } + + if (e.key === "Delete" || e.key === "Backspace") { + if (!(e.target instanceof HTMLElement) || /^(input|textarea|select)$/i.test(e.target.tagName)) return; + e.preventDefault(); + onDelete(); + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onCopy, onCut, onDelete, onPaste, onSave]); +} diff --git a/apps/web/hooks/use-face-blur-workflow.test.tsx b/apps/web/hooks/use-face-blur-workflow.test.tsx new file mode 100644 index 0000000..36bb47e --- /dev/null +++ b/apps/web/hooks/use-face-blur-workflow.test.tsx @@ -0,0 +1,110 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { Layer } from "@pien-studio/types"; +import { useFaceBlurWorkflow } from "./use-face-blur-workflow"; + +function makeImageLayer(overrides: Partial = {}): Layer { + return { + id: "layer-1", + type: "image", + sourceUri: "data:image/png;base64,abc", + x: 0, + y: 0, + scale: 1, + rotation: 0, + opacity: 1, + ...overrides, + }; +} + +describe("useFaceBlurWorkflow", () => { + it("selects all detected faces by default when no existing face blur", async () => { + const setImageLayerFaceBlur = vi.fn(); + const selectedLayer = makeImageLayer(); + const faceDetections = [ + { x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }, + { x: 5, y: 8, width: 7, height: 9, label: "b", sourceWidth: 100, sourceHeight: 100 }, + ]; + + const { result } = renderHook(() => + useFaceBlurWorkflow({ + selectedLayer, + faceDetectionsLayerId: "layer-1", + faceDetections, + setImageLayerFaceBlur, + }), + ); + + await waitFor(() => { + expect(result.current.selectedFaceIndices).toEqual([0, 1]); + expect(result.current.faceBlurPreview?.regions).toHaveLength(2); + }); + }); + + it("keeps selection empty when selected image already has blur", async () => { + const setImageLayerFaceBlur = vi.fn(); + const selectedLayer = makeImageLayer({ + faceBlur: { method: "gaussian", amount: 14, regions: [{ x: 0, y: 0, width: 4, height: 4 }] }, + }); + + const { result } = renderHook(() => + useFaceBlurWorkflow({ + selectedLayer, + faceDetectionsLayerId: "layer-1", + faceDetections: [{ x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }], + setImageLayerFaceBlur, + }), + ); + + await waitFor(() => { + expect(result.current.selectedFaceIndices).toEqual([]); + expect(result.current.faceBlurPreview).toBeNull(); + }); + }); + + it("toggles face selection and applies blur to selected regions", async () => { + const setImageLayerFaceBlur = vi.fn(); + const selectedLayer = makeImageLayer(); + const faceDetections = [ + { x: 1, y: 2, width: 10, height: 12, label: "a", sourceWidth: 100, sourceHeight: 100 }, + { x: 50, y: 60, width: 20, height: 22, label: "b", sourceWidth: 100, sourceHeight: 100 }, + ]; + + const { result } = renderHook(() => + useFaceBlurWorkflow({ + selectedLayer, + faceDetectionsLayerId: "layer-1", + faceDetections, + setImageLayerFaceBlur, + }), + ); + + await waitFor(() => { + expect(result.current.selectedFaceIndices).toEqual([0, 1]); + }); + + act(() => { + result.current.toggleFaceIndex(1); + }); + + expect(result.current.selectedFaceIndices).toEqual([0]); + + act(() => { + result.current.blurFaces(result.current.selectedFaceIndices); + }); + + expect(setImageLayerFaceBlur).toHaveBeenCalledTimes(1); + expect(setImageLayerFaceBlur).toHaveBeenCalledWith( + "layer-1", + expect.objectContaining({ + method: "gaussian", + amount: 14, + regions: expect.arrayContaining([ + expect.objectContaining({ x: 1, y: 2, width: 10, height: 12, censorColor: "#111111", sourceWidth: 100, sourceHeight: 100 }), + ]), + }), + ); + expect(result.current.selectedFaceIndices).toEqual([]); + expect(result.current.faceBlurPreview).toBeNull(); + }); +}); diff --git a/apps/web/hooks/use-face-blur-workflow.ts b/apps/web/hooks/use-face-blur-workflow.ts new file mode 100644 index 0000000..cf06531 --- /dev/null +++ b/apps/web/hooks/use-face-blur-workflow.ts @@ -0,0 +1,132 @@ +import React from "react"; +import type { FaceBlurMethod, Layer } from "@pien-studio/types"; +import type { FaceDetectionOverlay } from "./use-face-detection"; + +type FaceBlurPreview = { + layerId: string; + method: FaceBlurMethod; + amount: number; + regions: { x: number; y: number; width: number; height: number; sourceWidth: number; sourceHeight: number; censorColor?: string }[]; + censorColor?: string; +}; + +type UseFaceBlurWorkflowOptions = { + selectedLayer: Layer | null; + faceDetectionsLayerId: string | null; + faceDetections: FaceDetectionOverlay[]; + setImageLayerFaceBlur: (layerId: string, faceBlur: Layer["faceBlur"] | undefined) => void; +}; + +export function useFaceBlurWorkflow(options: UseFaceBlurWorkflowOptions) { + const { selectedLayer, faceDetectionsLayerId, faceDetections, setImageLayerFaceBlur } = options; + const [blurMethod, setBlurMethod] = React.useState("gaussian"); + const [blurAmount, setBlurAmount] = React.useState(14); + const [censorColor, setCensorColor] = React.useState("#111111"); + const [selectedFaceIndices, setSelectedFaceIndices] = React.useState([]); + const [faceBlurPreview, setFaceBlurPreview] = React.useState(null); + const hasDetectableSelection = Boolean(selectedLayer && selectedLayer.type === "image" && faceDetectionsLayerId === selectedLayer.id); + + const buildBlurRegions = React.useCallback( + (indices: number[]) => { + if (!selectedLayer || selectedLayer.type !== "image") return []; + if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return []; + const indexSet = new Set(indices); + return faceDetections + .filter((_, index) => indexSet.has(index)) + .map((face) => { + const baseWidth = Math.max(1, selectedLayer.width ?? face.sourceWidth); + const baseHeight = Math.max(1, selectedLayer.height ?? face.sourceHeight); + const scaleX = face.sourceWidth / baseWidth; + const scaleY = face.sourceHeight / baseHeight; + return { + x: Math.max(0, Math.floor(face.x * scaleX)), + y: Math.max(0, Math.floor(face.y * scaleY)), + width: Math.max(1, Math.floor(face.width * scaleX)), + height: Math.max(1, Math.floor(face.height * scaleY)), + sourceWidth: face.sourceWidth, + sourceHeight: face.sourceHeight, + censorColor, + }; + }); + }, + [censorColor, faceDetections, faceDetectionsLayerId, selectedLayer], + ); + + const toggleFaceIndex = React.useCallback((index: number) => { + setSelectedFaceIndices((prev) => (prev.includes(index) ? prev.filter((item) => item !== index) : [...prev, index])); + }, []); + + const clearBlur = React.useCallback(() => { + if (!selectedLayer || selectedLayer.type !== "image") return; + setImageLayerFaceBlur(selectedLayer.id, undefined); + setFaceBlurPreview(null); + }, [selectedLayer, setImageLayerFaceBlur]); + + const blurFaces = React.useCallback( + (indices: number[]) => { + if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) return; + if (faceDetectionsLayerId !== selectedLayer.id || faceDetections.length === 0) return; + const regions = buildBlurRegions(indices); + setImageLayerFaceBlur(selectedLayer.id, { + method: blurMethod, + amount: blurAmount, + regions, + censorColor, + }); + setSelectedFaceIndices([]); + setFaceBlurPreview(null); + }, + [blurAmount, blurMethod, buildBlurRegions, censorColor, faceDetections.length, faceDetectionsLayerId, selectedLayer, setImageLayerFaceBlur], + ); + + React.useEffect(() => { + if (!hasDetectableSelection) { + setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : [])); + setFaceBlurPreview(null); + return; + } + if (selectedLayer?.faceBlur) { + setSelectedFaceIndices((prev) => (prev.length === 0 ? prev : [])); + return; + } + setSelectedFaceIndices((prev) => { + const next = faceDetections.map((_, i) => i); + if (prev.length === next.length && prev.every((value, index) => value === next[index])) return prev; + return next; + }); + }, [faceDetections, hasDetectableSelection, selectedLayer?.faceBlur]); + + React.useEffect(() => { + if (!hasDetectableSelection || !selectedLayer || selectedLayer.type !== "image") { + setFaceBlurPreview(null); + return; + } + + if (selectedFaceIndices.length === 0) { + setFaceBlurPreview(null); + return; + } + + setFaceBlurPreview({ + layerId: selectedLayer.id, + method: blurMethod, + amount: blurAmount, + regions: buildBlurRegions(selectedFaceIndices), + censorColor, + }); + }, [blurAmount, blurMethod, buildBlurRegions, censorColor, hasDetectableSelection, selectedFaceIndices, selectedLayer]); + + return { + blurMethod, + setBlurMethod, + blurAmount, + setBlurAmount, + censorColor, + setCensorColor, + selectedFaceIndices, + faceBlurPreview, + toggleFaceIndex, + clearBlur, + blurFaces, + }; +} diff --git a/apps/web/hooks/use-face-detection.ts b/apps/web/hooks/use-face-detection.ts new file mode 100644 index 0000000..b8263c7 --- /dev/null +++ b/apps/web/hooks/use-face-detection.ts @@ -0,0 +1,134 @@ +import React from "react"; +import type { EditorToolId } from "../store/editor-store"; +import { buildFacePreviews, loadImageFromUri, toFaceDetectionOverlays } from "../lib/face-detection-utils"; + +export type FaceDetectionOverlay = { + x: number; + y: number; + width: number; + height: number; + sourceWidth: number; + sourceHeight: number; + label: string; + gender?: string; + genderScore?: number; +}; + +export type FacePreview = { + id: string; + src: string; +}; + +type SelectedImageLayer = { + id: string; + sourceUri: string; + width?: number; + height?: number; +}; + +type UseFaceDetectionOptions = { + tool: EditorToolId; + selectedLayerId: string | null; + selectedImageLayer: SelectedImageLayer | null; + activeLayerStillSelected: (layerId: string) => boolean; +}; + +export function useFaceDetection(options: UseFaceDetectionOptions) { + const { tool, selectedLayerId, selectedImageLayer, activeLayerStillSelected } = options; + const selectedImageLayerId = selectedImageLayer?.id ?? null; + const selectedImageSourceUri = selectedImageLayer?.sourceUri ?? null; + const selectedImageWidth = selectedImageLayer?.width; + const selectedImageHeight = selectedImageLayer?.height; + const [faceDetections, setFaceDetections] = React.useState([]); + const [faceDetectionsLayerId, setFaceDetectionsLayerId] = React.useState(null); + const [faceStatus, setFaceStatus] = React.useState<"idle" | "detecting" | "unsupported">("idle"); + const [facePreviews, setFacePreviews] = React.useState([]); + + const resetFaceState = React.useCallback((status: "idle" | "detecting" | "unsupported" = "idle") => { + setFaceDetections((prev) => (prev.length === 0 ? prev : [])); + setFaceDetectionsLayerId((prev) => (prev === null ? prev : null)); + setFaceStatus((prev) => (prev === status ? prev : status)); + }, []); + + React.useEffect(() => { + let canceled = false; + + async function detectFaces() { + const layerId = selectedImageLayerId; + if (tool !== "face" || !layerId || !selectedImageSourceUri) { + resetFaceState("idle"); + return; + } + + resetFaceState("detecting"); + const image = await loadImageFromUri(selectedImageSourceUri); + if (!image) { + if (!canceled) { + resetFaceState("idle"); + } + return; + } + + try { + const { detectFaceBoxes } = await import("../lib/face-ml"); + const faces = await detectFaceBoxes(image); + if (canceled) return; + if (!activeLayerStillSelected(layerId) || tool !== "face") return; + const overlays = toFaceDetectionOverlays(faces, image, selectedImageWidth, selectedImageHeight); + setFaceDetections(overlays); + setFaceDetectionsLayerId(layerId); + setFaceStatus((prev) => (prev === "idle" ? prev : "idle")); + } catch (err) { + console.error("[face-detection] detectFaces error:", err); + if (!canceled) { + resetFaceState("unsupported"); + } + } + } + + detectFaces(); + return () => { + canceled = true; + }; + }, [activeLayerStillSelected, resetFaceState, selectedImageHeight, selectedImageLayerId, selectedImageSourceUri, selectedImageWidth, tool]); + + React.useEffect(() => { + if (tool !== "face") { + setFaceDetections([]); + setFaceDetectionsLayerId(null); + setFacePreviews([]); + return; + } + setFaceDetections([]); + setFaceDetectionsLayerId(null); + setFacePreviews([]); + }, [tool, selectedLayerId]); + + React.useEffect(() => { + let canceled = false; + + async function generateFacePreviews() { + if (tool !== "face" || !selectedImageSourceUri || faceDetections.length === 0) { + setFacePreviews([]); + return; + } + + const image = await loadImageFromUri(selectedImageSourceUri); + if (!image || canceled) { + setFacePreviews([]); + return; + } + + const previews = buildFacePreviews(image, faceDetections, selectedImageWidth, selectedImageHeight); + + if (!canceled) setFacePreviews(previews); + } + + generateFacePreviews(); + return () => { + canceled = true; + }; + }, [faceDetections, selectedImageHeight, selectedImageSourceUri, selectedImageWidth, tool]); + + return { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus }; +} diff --git a/apps/web/hooks/use-translations.ts b/apps/web/hooks/use-translations.ts new file mode 100644 index 0000000..42c2c21 --- /dev/null +++ b/apps/web/hooks/use-translations.ts @@ -0,0 +1,42 @@ +"use client"; + +import { useUiStore } from "../store/ui-store"; +import en from "../messages/en.json"; +import ja from "../messages/ja.json"; +import th from "../messages/th.json"; + +type Messages = typeof en; + +const messages: Record = { en, ja, th }; + +type TranslationKey = string; + +function getNestedValue(obj: Record, path: string): string { + const keys = path.split("."); + let result: unknown = obj; + for (const key of keys) { + if (result && typeof result === "object" && key in result) { + result = (result as Record)[key]; + } else { + return path; + } + } + return typeof result === "string" ? result : path; +} + +export function useTranslations() { + const locale = useUiStore((s) => s.locale); + const msg = messages[locale] ?? messages.en; + + function t(key: TranslationKey, params?: Record): string { + let value = getNestedValue(msg as unknown as Record, key); + if (params) { + Object.entries(params).forEach(([k, v]) => { + value = value.replace(`{${k}}`, String(v)); + }); + } + return value; + } + + return { t, locale }; +} \ No newline at end of file diff --git a/apps/web/i18n/request.ts b/apps/web/i18n/request.ts new file mode 100644 index 0000000..d7cb748 --- /dev/null +++ b/apps/web/i18n/request.ts @@ -0,0 +1,9 @@ +import { getRequestConfig } from "next-intl/server"; + +export default getRequestConfig(async () => { + const locale = "en"; + return { + locale, + messages: (await import(`../messages/${locale}.json`)).default, + }; +}); diff --git a/apps/web/lib/canvas-geometry.test.ts b/apps/web/lib/canvas-geometry.test.ts new file mode 100644 index 0000000..338a23f --- /dev/null +++ b/apps/web/lib/canvas-geometry.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { buildFaceLabelOverlays } from "./canvas-geometry"; + +describe("buildFaceLabelOverlays", () => { + it("returns no overlays when target layer is missing", () => { + const overlays = buildFaceLabelOverlays([], "missing", [{ x: 10, y: 10, width: 20, height: 20 }], { x: 0, y: 0, scale: 1 }); + expect(overlays).toEqual([]); + }); + + it("stacks overlapping labels to avoid collisions", () => { + const layers = [ + { + id: "layer-1", + type: "image" as const, + x: 20, + y: 30, + width: 180, + height: 120, + scale: 1, + rotation: 0, + opacity: 1, + }, + ]; + + const overlays = buildFaceLabelOverlays( + layers, + "layer-1", + [ + { x: 16, y: 20, width: 30, height: 30, label: "A" }, + { x: 17, y: 21, width: 30, height: 30, label: "B" }, + ], + { x: 0, y: 0, scale: 1 }, + ); + + expect(overlays).toHaveLength(2); + expect(overlays[0]?.top).toBeGreaterThan(overlays[1]?.top ?? 0); + }); +}); diff --git a/apps/web/lib/canvas-geometry.ts b/apps/web/lib/canvas-geometry.ts new file mode 100644 index 0000000..be65d5e --- /dev/null +++ b/apps/web/lib/canvas-geometry.ts @@ -0,0 +1,55 @@ +import type { Layer } from "@pien-studio/types"; + +type FaceDetection = { x: number; y: number; width: number; height: number; label?: string }; + +type Viewport = { x: number; y: number; scale: number }; + +export function buildFaceLabelOverlays( + layers: Layer[], + faceOverlayLayerId: string, + faceDetections: FaceDetection[], + viewport: Viewport, +) { + const layer = layers.find((item) => item.id === faceOverlayLayerId); + if (!layer) return []; + + const isImage = layer.type === "image"; + const layerWidth = layer.width ?? (isImage ? Math.round(200 * layer.scale) : undefined); + const layerHeight = layer.height ?? (isImage ? Math.round(150 * layer.scale) : undefined); + if (!layerWidth || !layerHeight) return []; + + const centerX = layer.x + layerWidth / 2; + const centerY = layer.y + layerHeight / 2; + const radians = (layer.rotation * Math.PI) / 180; + const cos = Math.cos(radians); + const sin = Math.sin(radians); + const placed: Array<{ left: number; top: number; width: number; height: number }> = []; + + return faceDetections.map((face, index) => { + const worldX = layer.x + face.x; + const worldY = layer.y + face.y; + const dx = worldX - centerX; + const dy = worldY - centerY; + const rotatedWorldX = centerX + dx * cos - dy * sin; + const rotatedWorldY = centerY + dx * sin + dy * cos; + + const text = face.label ?? `Person ${index + 1}`; + const estimatedWidth = Math.max(72, Math.min(260, text.length * 6 + 14)); + const estimatedHeight = 18; + const left = viewport.x + rotatedWorldX * viewport.scale; + let top = viewport.y + rotatedWorldY * viewport.scale - 22; + + while ( + placed.some((rect) => { + const intersectsX = left < rect.left + rect.width && left + estimatedWidth > rect.left; + const intersectsY = top < rect.top + rect.height && top + estimatedHeight > rect.top; + return intersectsX && intersectsY; + }) + ) { + top -= estimatedHeight + 4; + } + + placed.push({ left, top, width: estimatedWidth, height: estimatedHeight }); + return { id: `${layer.id}-face-label-${index}`, text, left, top }; + }); +} diff --git a/apps/web/lib/editor-constants.ts b/apps/web/lib/editor-constants.ts new file mode 100644 index 0000000..9307e96 --- /dev/null +++ b/apps/web/lib/editor-constants.ts @@ -0,0 +1,19 @@ +export const AUTOSAVE_DELAY_MS = 1200; + +export const CONTEXT_MENU_SIZE = { + width: 170, + height: 110, + viewportPadding: 8, +} as const; + +export const CANVAS_HANDLE_BASE_SIZE = 12; +export const CANVAS_ROTATE_HANDLE_BASE_SIZE = 18; + +export const MIN_LAYER_SIZE = 8; + +export const DEFAULT_IMAGE_IMPORT = { + fallbackWidth: 200, + fallbackHeight: 150, + offsetX: 60, + offsetY: 60, +} as const; diff --git a/apps/web/lib/editor-tool-controller.ts b/apps/web/lib/editor-tool-controller.ts new file mode 100644 index 0000000..5bad4c3 --- /dev/null +++ b/apps/web/lib/editor-tool-controller.ts @@ -0,0 +1,38 @@ +import type { EditorToolId } from "../store/editor-store"; + +export type ToolModeController = { + kind: "mode"; + id: EditorToolId; + label: string; +}; + +export type ToolActionController = { + kind: "action"; + id: string; + label: string; + run: () => void; +}; + +export type EditorToolController = ToolModeController | ToolActionController; + +type CreateEditorToolControllersOptions = { + onAddTextLayer: () => void; + onImportImage: () => void; + labels: { + pointer: string; + pan: string; + face: string; + text: string; + image: string; + }; +}; + +export function createEditorToolControllers(options: CreateEditorToolControllersOptions): EditorToolController[] { + return [ + { kind: "mode", id: "pointer", label: options.labels.pointer }, + { kind: "mode", id: "hand", label: options.labels.pan }, + { kind: "mode", id: "face", label: options.labels.face }, + { kind: "action", id: "add-text", label: options.labels.text, run: options.onAddTextLayer }, + { kind: "action", id: "import-image", label: options.labels.image, run: options.onImportImage }, + ]; +} diff --git a/apps/web/lib/export-png.ts b/apps/web/lib/export-png.ts new file mode 100644 index 0000000..525c785 --- /dev/null +++ b/apps/web/lib/export-png.ts @@ -0,0 +1,106 @@ +import type { Layer, Project } from "@pien-studio/types"; +import { renderFaceBlurRegions } from "./face-blur-renderer"; + +type ExportOptions = { + isDark: boolean; + pixelRatio?: number; +}; + +function clampOpacity(value: number | undefined) { + if (typeof value !== "number" || Number.isNaN(value)) return 1; + return Math.max(0, Math.min(1, value)); +} + +function loadImage(src: string) { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = reject; + image.src = src; + }); +} + +function drawFallbackLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) { + const text = layer.name ?? layer.type; + const width = Math.max(80, layer.width ?? 120); + const height = Math.max(34, layer.height ?? 40); + const radius = 8; + + ctx.beginPath(); + ctx.moveTo(radius, 0); + ctx.lineTo(width - radius, 0); + ctx.quadraticCurveTo(width, 0, width, radius); + ctx.lineTo(width, height - radius); + ctx.quadraticCurveTo(width, height, width - radius, height); + ctx.lineTo(radius, height); + ctx.quadraticCurveTo(0, height, 0, height - radius); + ctx.lineTo(0, radius); + ctx.quadraticCurveTo(0, 0, radius, 0); + ctx.closePath(); + + ctx.fillStyle = isDark ? "#2d3036" : "#ffffff"; + ctx.strokeStyle = isDark ? "rgba(255,255,255,0.2)" : "rgba(0,0,0,0.15)"; + ctx.lineWidth = 1; + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = isDark ? "#d7dae0" : "#1f2430"; + ctx.font = "600 12px ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(text, width / 2, height / 2); +} + +async function drawLayer(ctx: CanvasRenderingContext2D, layer: Layer, isDark: boolean) { + const width = layer.width ?? (layer.type === "image" ? Math.round(200 * layer.scale) : 120); + const height = layer.height ?? (layer.type === "image" ? Math.round(150 * layer.scale) : 40); + + ctx.save(); + ctx.globalAlpha = clampOpacity(layer.opacity); + ctx.translate(layer.x + width / 2, layer.y + height / 2); + ctx.rotate((layer.rotation * Math.PI) / 180); + ctx.translate(-width / 2, -height / 2); + + if ((layer.type === "image" || layer.type === "sticker") && layer.sourceUri) { + try { + const image = await loadImage(layer.sourceUri); + ctx.drawImage(image, 0, 0, width, height); + if (layer.faceBlur && layer.faceBlur.regions.length > 0) { + renderFaceBlurRegions(ctx, image, layer.faceBlur, width, height); + } + } catch { + drawFallbackLayer(ctx, layer, isDark); + } + } else { + drawFallbackLayer(ctx, layer, isDark); + } + + ctx.restore(); +} + +export async function exportProjectAsPng(project: Project, options: ExportOptions) { + const pixelRatio = Math.max(1, Math.floor(options.pixelRatio ?? window.devicePixelRatio ?? 1)); + const { width, height } = project.canvas; + const canvas = document.createElement("canvas"); + canvas.width = width * pixelRatio; + canvas.height = height * pixelRatio; + + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Cannot create export canvas context"); + + ctx.scale(pixelRatio, pixelRatio); + ctx.fillStyle = options.isDark ? "#17181b" : "#ffffff"; + ctx.fillRect(0, 0, width, height); + + for (const layer of project.layers) { + await drawLayer(ctx, layer, options.isDark); + } + + const dataUrl = canvas.toDataURL("image/png"); + const link = document.createElement("a"); + link.href = dataUrl; + link.download = `${project.title || "pien-project"}.png`; + document.body.appendChild(link); + link.click(); + link.remove(); +} diff --git a/apps/web/lib/face-blur-renderer.test.ts b/apps/web/lib/face-blur-renderer.test.ts new file mode 100644 index 0000000..9fda8d4 --- /dev/null +++ b/apps/web/lib/face-blur-renderer.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderFaceBlurRegions } from "./face-blur-renderer"; + +function makeContext() { + return { + fillStyle: "", + filter: "none", + imageSmoothingEnabled: true, + fillRect: vi.fn(), + drawImage: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + } as unknown as CanvasRenderingContext2D; +} + +function makeImage(width = 1200, height = 800) { + return { naturalWidth: width, naturalHeight: height } as HTMLImageElement; +} + +describe("renderFaceBlurRegions", () => { + it("renders gaussian blur region with source dimensions", () => { + const ctx = makeContext(); + const image = makeImage(); + renderFaceBlurRegions( + ctx, + image, + { + method: "gaussian", + amount: 24, + regions: [{ x: 120, y: 80, width: 300, height: 200, sourceWidth: 1200, sourceHeight: 800 }], + }, + 600, + 400, + ); + + expect(ctx.save).toHaveBeenCalledOnce(); + expect(ctx.filter).toBe("blur(24px)"); + expect(ctx.drawImage).toHaveBeenCalledWith(image, 120, 80, 300, 200, 60, 40, 150, 100); + expect(ctx.restore).toHaveBeenCalledOnce(); + }); + + it("renders pixelate using sampled offscreen canvas", () => { + const doc = globalThis.document; + expect(doc).toBeDefined(); + if (!doc) return; + const ctx = makeContext(); + const image = makeImage(); + const sampleDrawImage = vi.fn(); + const sampleCtx = { imageSmoothingEnabled: true, drawImage: sampleDrawImage } as unknown as CanvasRenderingContext2D; + const sampleCanvas = { width: 0, height: 0, getContext: vi.fn(() => sampleCtx) } as unknown as HTMLCanvasElement; + const nativeCreateElement = doc.createElement.bind(doc); + const createElement = vi.spyOn(doc, "createElement").mockImplementation((tagName: string) => { + if (tagName === "canvas") return sampleCanvas; + return nativeCreateElement(tagName); + }); + + renderFaceBlurRegions( + ctx, + image, + { + method: "pixelate", + amount: 10, + regions: [{ x: 200, y: 100, width: 160, height: 120, sourceWidth: 1200, sourceHeight: 800 }], + }, + 600, + 400, + ); + + expect(sampleCanvas.width).toBe(16); + expect(sampleCanvas.height).toBe(12); + expect(sampleDrawImage).toHaveBeenCalledWith(image, 200, 100, 160, 120, 0, 0, 16, 12); + expect(ctx.drawImage).toHaveBeenCalledWith(sampleCanvas, 0, 0, 16, 12, 100, 50, 80, 60); + createElement.mockRestore(); + }); + + it("renders censor with region color priority", () => { + const ctx = makeContext(); + const image = makeImage(); + renderFaceBlurRegions( + ctx, + image, + { + method: "censor", + amount: 20, + censorColor: "#ff0000", + regions: [{ x: 20, y: 30, width: 40, height: 50, sourceWidth: 1200, sourceHeight: 800, censorColor: "#00ff00" }], + }, + 600, + 400, + ); + + expect(ctx.fillStyle).toBe("#00ff00"); + expect(ctx.fillRect).toHaveBeenCalledWith(10, 15, 20, 25); + }); + + it("falls back to legacy region scaling when source dimensions are missing", () => { + const ctx = makeContext(); + const image = makeImage(2400, 1600); + renderFaceBlurRegions( + ctx, + image, + { + method: "gaussian", + amount: 16, + regions: [{ x: 100, y: 120, width: 300, height: 200 }], + }, + 600, + 400, + ); + + expect(ctx.drawImage).toHaveBeenCalledWith(image, 400, 480, 1200, 800, 100, 120, 300, 200); + }); +}); diff --git a/apps/web/lib/face-blur-renderer.ts b/apps/web/lib/face-blur-renderer.ts new file mode 100644 index 0000000..d808325 --- /dev/null +++ b/apps/web/lib/face-blur-renderer.ts @@ -0,0 +1,72 @@ +import type { FaceBlurSettings } from "@pien-studio/types"; + +type BlurRegion = FaceBlurSettings["regions"][number]; + +function drawPixelatedRegion( + ctx: CanvasRenderingContext2D, + source: CanvasImageSource, + sourceX: number, + sourceY: number, + sourceWidth: number, + sourceHeight: number, + targetX: number, + targetY: number, + targetWidth: number, + targetHeight: number, + blockSize: number, +) { + const sampleCanvas = document.createElement("canvas"); + sampleCanvas.width = Math.max(1, Math.round(targetWidth / blockSize)); + sampleCanvas.height = Math.max(1, Math.round(targetHeight / blockSize)); + const sampleCtx = sampleCanvas.getContext("2d"); + if (!sampleCtx) return; + sampleCtx.imageSmoothingEnabled = false; + sampleCtx.drawImage(source, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sampleCanvas.width, sampleCanvas.height); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(sampleCanvas, 0, 0, sampleCanvas.width, sampleCanvas.height, targetX, targetY, targetWidth, targetHeight); + ctx.imageSmoothingEnabled = true; +} + +export function renderFaceBlurRegions( + ctx: CanvasRenderingContext2D, + image: HTMLImageElement, + blur: { method: FaceBlurSettings["method"]; amount: number; regions: BlurRegion[]; censorColor?: string }, + targetWidth: number, + targetHeight: number, +): void { + if (!blur.regions.length) return; + const legacyScaleX = image.naturalWidth / Math.max(1, targetWidth); + const legacyScaleY = image.naturalHeight / Math.max(1, targetHeight); + + for (const region of blur.regions) { + const hasSourceDims = typeof region.sourceWidth === "number" && typeof region.sourceHeight === "number" && region.sourceWidth > 0 && region.sourceHeight > 0; + const scaleX = hasSourceDims ? targetWidth / region.sourceWidth : 1; + const scaleY = hasSourceDims ? targetHeight / region.sourceHeight : 1; + + const x = Math.max(0, Math.floor(region.x * scaleX)); + const y = Math.max(0, Math.floor(region.y * scaleY)); + const w = Math.max(1, Math.floor(region.width * scaleX)); + const h = Math.max(1, Math.floor(region.height * scaleY)); + const sx0 = hasSourceDims ? region.x : Math.max(0, Math.floor(region.x * legacyScaleX)); + const sy0 = hasSourceDims ? region.y : Math.max(0, Math.floor(region.y * legacyScaleY)); + const sw = hasSourceDims ? region.width : Math.max(1, Math.floor(region.width * legacyScaleX)); + const sh = hasSourceDims ? region.height : Math.max(1, Math.floor(region.height * legacyScaleY)); + + if (blur.method === "censor") { + ctx.fillStyle = region.censorColor ?? blur.censorColor ?? "#111111"; + ctx.fillRect(x, y, w, h); + continue; + } + + if (blur.method === "pixelate") { + const pixelSize = Math.max(4, Math.round(blur.amount / 2)); + drawPixelatedRegion(ctx, image, sx0, sy0, sw, sh, x, y, w, h, pixelSize); + continue; + } + + ctx.save(); + ctx.filter = `blur(${blur.amount}px)`; + ctx.drawImage(image, sx0, sy0, sw, sh, x, y, w, h); + ctx.restore(); + } +} diff --git a/apps/web/lib/face-detection-utils.ts b/apps/web/lib/face-detection-utils.ts new file mode 100644 index 0000000..1ed7060 --- /dev/null +++ b/apps/web/lib/face-detection-utils.ts @@ -0,0 +1,77 @@ +import type { FaceDetectionOverlay, FacePreview } from "../hooks/use-face-detection"; + +export async function loadImageFromUri(uri: string): Promise { + const image = new Image(); + image.crossOrigin = "anonymous"; + await new Promise((resolve) => { + image.onload = () => resolve(); + image.onerror = () => resolve(); + image.src = uri; + }); + if (!image.naturalWidth || !image.naturalHeight) return null; + return image; +} + +export function toFaceDetectionOverlays( + faces: Array<{ x: number; y: number; width: number; height: number; gender?: string; genderScore?: number }>, + image: HTMLImageElement, + layerWidth?: number, + layerHeight?: number, +): FaceDetectionOverlay[] { + const width = layerWidth ?? image.naturalWidth; + const height = layerHeight ?? image.naturalHeight; + const scaleX = width / image.naturalWidth; + const scaleY = height / image.naturalHeight; + + return faces.map((face, index) => { + const genderLabel = face.gender ?? "unknown"; + const scoreLabel = face.genderScore != null ? `${Math.round(face.genderScore * 100)}%` : ""; + const label = scoreLabel ? `Person ${index + 1} - ${genderLabel} ${scoreLabel}` : `Person ${index + 1} - ${genderLabel}`; + + return { + x: face.x * scaleX, + y: face.y * scaleY, + width: face.width * scaleX, + height: face.height * scaleY, + sourceWidth: image.naturalWidth, + sourceHeight: image.naturalHeight, + label, + gender: face.gender, + genderScore: face.genderScore, + }; + }); +} + +export function buildFacePreviews( + image: HTMLImageElement, + faceDetections: FaceDetectionOverlay[], + layerWidth?: number, + layerHeight?: number, +): FacePreview[] { + const width = layerWidth ?? image.naturalWidth; + const height = layerHeight ?? image.naturalHeight; + const toImageScaleX = image.naturalWidth / Math.max(1, width); + const toImageScaleY = image.naturalHeight / Math.max(1, height); + + return faceDetections + .map((face, index) => { + const sx = Math.max(0, Math.floor(face.x * toImageScaleX)); + const sy = Math.max(0, Math.floor(face.y * toImageScaleY)); + const sw = Math.max(1, Math.floor(face.width * toImageScaleX)); + const sh = Math.max(1, Math.floor(face.height * toImageScaleY)); + const ex = Math.min(image.naturalWidth, sx + sw); + const ey = Math.min(image.naturalHeight, sy + sh); + const cw = Math.max(1, ex - sx); + const ch = Math.max(1, ey - sy); + const canvas = document.createElement("canvas"); + const targetWidth = 84; + const scale = targetWidth / cw; + canvas.width = targetWidth; + canvas.height = Math.max(1, Math.round(ch * scale)); + const ctx = canvas.getContext("2d"); + if (!ctx) return { id: `face-${index + 1}`, src: "" }; + ctx.drawImage(image, sx, sy, cw, ch, 0, 0, canvas.width, canvas.height); + return { id: `face-${index + 1}`, src: canvas.toDataURL("image/jpeg", 0.9) }; + }) + .filter((preview) => preview.src); +} \ No newline at end of file diff --git a/apps/web/lib/face-ml.ts b/apps/web/lib/face-ml.ts new file mode 100644 index 0000000..ed998a3 --- /dev/null +++ b/apps/web/lib/face-ml.ts @@ -0,0 +1,115 @@ +export type FaceBox = { + x: number; + y: number; + width: number; + height: number; + gender?: "male" | "female"; + genderScore?: number; + score?: number; +}; + +export async function detectFaceBoxes(image: HTMLImageElement): Promise { + const tf = await import("@tensorflow/tfjs"); + await tf.ready(); + + const faceapi = await import("@vladmandic/face-api"); + await Promise.all([ + faceapi.nets.tinyFaceDetector.loadFromUri("https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights"), + faceapi.nets.faceLandmark68TinyNet.loadFromUri("https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights"), + faceapi.nets.ageGenderNet.loadFromUri("https://cdn.jsdelivr.net/gh/justadudewhohacks/face-api.js@master/weights"), + ]); + + const TinyFaceDetectorOptions = (faceapi as unknown as { TinyFaceDetectorOptions: new (o: object) => object }).TinyFaceDetectorOptions; + + const passes = [ + { inputSize: 320, scoreThreshold: 0.5 }, + { inputSize: 416, scoreThreshold: 0.45 }, + { inputSize: 512, scoreThreshold: 0.5 }, + { inputSize: 608, scoreThreshold: 0.45 }, + { inputSize: 736, scoreThreshold: 0.4 }, + { inputSize: 864, scoreThreshold: 0.35 }, + ]; + + type FaceApiDet = { + gender: string; + genderProbability: number; + detection: { box: { x: number; y: number; width: number; height: number } }; + }; + + const allDets = await Promise.all( + passes.map(({ inputSize, scoreThreshold }) => + (faceapi as unknown as { + detectAllFaces: ( + img: HTMLImageElement, + options: { inputSize: number; scoreThreshold: number } + ) => Promise + }).detectAllFaces(image, new TinyFaceDetectorOptions({ inputSize, scoreThreshold })) + .withFaceLandmarks(true) + .withAgeAndGender() + ) + ); + + const flat = allDets.flat(); + if (flat.length === 0) return []; + + function iou(a: { x: number; y: number; width: number; height: number }, b: { x: number; y: number; width: number; height: number }) { + const ix = Math.max(a.x, b.x); + const iy = Math.max(a.y, b.y); + const ix2 = Math.min(a.x + a.width, b.x + b.width); + const iy2 = Math.min(a.y + b.height, b.y + b.height); + const inter = Math.max(0, ix2 - ix) * Math.max(0, iy2 - iy); + const union = a.width * a.height + b.width * b.height - inter; + return union > 0 ? inter / union : 0; + } + + function avgGender(dets: FaceApiDet[]): { gender: "male" | "female" | undefined; score: number } { + let maleScore = 0; + let femaleScore = 0; + let count = 0; + for (const det of dets) { + if (det.gender === "male") maleScore += det.genderProbability; + else if (det.gender === "female") femaleScore += det.genderProbability; + count++; + } + if (count === 0) return { gender: undefined, score: 0 }; + const avgMale = maleScore / count; + const avgFemale = femaleScore / count; + if (avgMale > avgFemale) return { gender: "male", score: avgMale }; + if (avgFemale > avgMale) return { gender: "female", score: avgFemale }; + return { gender: undefined, score: 0 }; + } + + const clusters: FaceApiDet[][] = []; + for (const det of flat) { + const b = det.detection?.box; + if (!b) continue; + let matched = false; + for (const cluster of clusters) { + if (cluster.some((c) => iou(c.detection.box, b) > 0.4)) { + cluster.push(det); + matched = true; + break; + } + } + if (!matched) clusters.push([det]); + } + + return clusters.map((group) => { + const largest = [...group].sort( + (a, b) => + (b.detection?.box?.width ?? 0) * (b.detection?.box?.height ?? 0) - + (a.detection?.box?.width ?? 0) * (a.detection?.box?.height ?? 0) + )[0]; + const box = largest.detection.box; + const { gender, score: genderScore } = avgGender(group); + + return { + x: box.x, + y: box.y, + width: box.width, + height: box.height, + gender, + genderScore, + }; + }); +} \ No newline at end of file diff --git a/apps/web/lib/project-equality.test.ts b/apps/web/lib/project-equality.test.ts new file mode 100644 index 0000000..54dfbf1 --- /dev/null +++ b/apps/web/lib/project-equality.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; +import type { Project } from "@pien-studio/types"; +import { hasProjectChanged } from "./project-equality"; + +function makeProject(): Project { + return { + id: "p1", + title: "Project", + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", + aspectRatio: "1:1", + canvas: { width: 100, height: 100, unit: "px" }, + layers: [{ id: "l1", type: "text", x: 0, y: 0, scale: 1, rotation: 0, opacity: 1 }], + }; +} + +describe("hasProjectChanged", () => { + it("returns false for equal projects", () => { + const a = makeProject(); + const b = makeProject(); + expect(hasProjectChanged(a, b)).toBe(false); + }); + + it("detects canvas changes", () => { + const a = makeProject(); + const b = makeProject(); + b.canvas.width = 101; + expect(hasProjectChanged(a, b)).toBe(true); + }); + + it("detects face blur region changes", () => { + const a = makeProject(); + const b = makeProject(); + a.layers[0].type = "image"; + b.layers[0].type = "image"; + a.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }; + b.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 11, height: 10 }] }; + expect(hasProjectChanged(a, b)).toBe(true); + }); + + it("detects layer order changes", () => { + const a = makeProject(); + const b = makeProject(); + a.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1 }); + b.layers.push({ id: "l2", type: "text", x: 3, y: 4, scale: 1, rotation: 0, opacity: 1 }); + b.layers = [b.layers[1], b.layers[0]]; + expect(hasProjectChanged(a, b)).toBe(true); + }); + + it("treats missing optional fields and undefined as equal", () => { + const a = makeProject(); + const b = makeProject(); + a.layers[0].name = undefined; + b.layers[0].name = undefined; + expect(hasProjectChanged(a, b)).toBe(false); + }); + + it("detects face blur removal", () => { + const a = makeProject(); + const b = makeProject(); + a.layers[0].type = "image"; + b.layers[0].type = "image"; + a.layers[0].faceBlur = { method: "gaussian", amount: 14, regions: [{ x: 1, y: 1, width: 10, height: 10 }] }; + expect(hasProjectChanged(a, b)).toBe(true); + }); +}); diff --git a/apps/web/lib/project-equality.ts b/apps/web/lib/project-equality.ts new file mode 100644 index 0000000..7bcb736 --- /dev/null +++ b/apps/web/lib/project-equality.ts @@ -0,0 +1,58 @@ +import type { Project } from "@pien-studio/types"; + +export function hasProjectChanged(left: Project, right: Project): boolean { + if (left.id !== right.id) return true; + if (left.title !== right.title) return true; + if (left.createdAt !== right.createdAt) return true; + if (left.updatedAt !== right.updatedAt) return true; + if (left.aspectRatio !== right.aspectRatio) return true; + if (left.canvas.width !== right.canvas.width || left.canvas.height !== right.canvas.height || left.canvas.unit !== right.canvas.unit) { + return true; + } + if (left.layers.length !== right.layers.length) return true; + + for (let index = 0; index < left.layers.length; index += 1) { + const a = left.layers[index]; + const b = right.layers[index]; + if (!a || !b) return true; + if ( + a.id !== b.id || + a.type !== b.type || + a.name !== b.name || + a.assetId !== b.assetId || + a.sourceUri !== b.sourceUri || + a.x !== b.x || + a.y !== b.y || + a.width !== b.width || + a.height !== b.height || + a.scale !== b.scale || + a.rotation !== b.rotation || + a.opacity !== b.opacity + ) { + return true; + } + + const blurA = a.faceBlur; + const blurB = b.faceBlur; + if (!blurA && !blurB) continue; + if (!blurA || !blurB) return true; + if (blurA.method !== blurB.method || blurA.amount !== blurB.amount || blurA.censorColor !== blurB.censorColor) return true; + if (blurA.regions.length !== blurB.regions.length) return true; + for (let regionIndex = 0; regionIndex < blurA.regions.length; regionIndex += 1) { + const regionA = blurA.regions[regionIndex]; + const regionB = blurB.regions[regionIndex]; + if (!regionA || !regionB) return true; + if ( + regionA.x !== regionB.x || + regionA.y !== regionB.y || + regionA.width !== regionB.width || + regionA.height !== regionB.height || + regionA.censorColor !== regionB.censorColor + ) { + return true; + } + } + } + + return false; +} diff --git a/apps/web/lib/theme.ts b/apps/web/lib/theme.ts new file mode 100644 index 0000000..fe2ece4 --- /dev/null +++ b/apps/web/lib/theme.ts @@ -0,0 +1,45 @@ +export function cx(...parts: Array): string { + return parts.filter(Boolean).join(" "); +} + +export function surfaceClass(isDark: boolean): string { + return isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"; +} + +export function mutedSurfaceClass(isDark: boolean): string { + return isDark ? "border-white/10 bg-[#23252a]" : "border-black/10 bg-[#f7f8fa]"; +} + +export function subtleButtonClass(isDark: boolean): string { + return isDark + ? "border-white/15 bg-[#25272b] text-[#e8eaed]" + : "border-black/15 bg-[#f2f4f8] text-[#1f2430]"; +} + +export function accentButtonClass(): string { + return "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"; +} + +export function hoverSubtleClass(isDark: boolean): string { + return isDark ? "hover:bg-white/10" : "hover:bg-black/5"; +} + +export function dividerClass(isDark: boolean): string { + return isDark ? "bg-white/10" : "bg-black/10"; +} + +export function panelClass(isDark: boolean): string { + return cx("rounded border p-3", isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"); +} + +export function panelTitleClass(isDark: boolean): string { + return isDark ? "text-[#dfe3ea]" : "text-[#1f2430]"; +} + +export function panelCounterClass(isDark: boolean): string { + return isDark ? "bg-white/10 text-[#cfd4dd]" : "bg-black/5 text-[#586071]"; +} + +export function panelInsetClass(isDark: boolean): string { + return isDark ? "border-white/10 bg-[#24262b]" : "border-black/10 bg-[#f6f7f9]"; +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json new file mode 100644 index 0000000..55064e6 --- /dev/null +++ b/apps/web/messages/en.json @@ -0,0 +1,114 @@ +{ + "home": { + "title": "Make your mood visible", + "subtitle": "Local-first editing, expressive presets, share when you want.", + "start": "Start New Project", + "moods": "Mood presets", + "soft": "Soft", + "pien": "Pien", + "darkCute": "Dark Cute", + "stage": "Stage", + "custom": "Custom", + "open": "Open", + "duplicate": "Duplicate", + "delete": "Delete", + "projectHub": "Project Hub", + "createOpenManage": "Create, open, and manage local projects.", + "newProject": "New Project", + "openProjectFile": "Open Project File", + "openImage": "Open Image", + "myProjects": "My Projects", + "noProjectsYet": "No projects yet", + "untitledProject": "Untitled Project", + "imageProject": "Image Project", + "wipTitle": "Heavy WIP Warning", + "wipBody": "This app is in very early development and heavily work in progress. Features may be broken, missing, unstable, or changed at any time.", + "wipSupportPrefix": "If you want to support development, you can sponsor the project on GitHub:", + "wipAcknowledge": "I Understand" + }, + "editor": { + "file": "File", + "edit": "Edit", + "view": "View", + "settings": "Settings", + "save": "Save", + "exportPng": "Export PNG", + "exportProjectFile": "Export Project File", + "importImage": "Import image", + "canvasSize": "Canvas size", + "undo": "Undo", + "redo": "Redo", + "copy": "Copy", + "cut": "Cut", + "paste": "Paste", + "preferences": "Preferences", + "panTool": "Pan tool", + "pointerTool": "Pointer tool", + "unsavedChanges": "Unsaved changes", + "saved": "Saved", + "layers": "Layers", + "up": "Up", + "down": "Down", + "delete": "Delete", + "noLayersYet": "No layers yet. Add text or image.", + "faceTool": "Face Tool", + "faceMlFailed": "Face ML model failed to load on this device/browser.", + "detectingFaces": "Detecting faces on selected image...", + "selectImageLayer": "Select an image layer to scan faces.", + "noFacesFound": "No faces found in selected image.", + "facesDetected": "Faces detected and highlighted on canvas.", + "person": "Person", + "unknown": "unknown", + "neutral": "neutral", + "blurMethod": "Blur method", + "soft": "Soft", + "pixelate": "Pixelate", + "censor": "Censor", + "strength": "Strength", + "color": "Color", + "selectFacesToBlur": "Select faces to blur", + "blurFaces": "Blur {count} face(s)", + "clearBlur": "Clear blur", + "history": "History", + "beforeLastAction": "Before last action", + "step": "Step", + "now": "Now", + "undoneStep": "Undone step", + "noHistoryYet": "No history yet", + "resize": "Resize", + "import": "Import", + "mood": "Mood", + "quick": "Quick", + "face": "Face", + "decor": "Decor", + "faceMlFailedShort": "Face ML model failed to load on this device/browser.", + "detectingFacesShort": "Detecting faces on-device...", + "faceDetectionTip": "{count} face(s) found. Select an image layer with a clear, front-facing face for best results.", + "toolPointer": "Pointer", + "toolPan": "Pan", + "toolFace": "Face", + "toolText": "Text", + "toolImage": "Image", + "canvasSizeTitle": "Canvas Size", + "modePreset": "Preset", + "modeCustom": "Custom", + "presetSquare": "Square", + "presetPortrait45": "Portrait 4:5", + "presetStory916": "Story 9:16", + "presetWidescreen": "Widescreen", + "presetPhoto43": "Photo 4:3", + "presetClassic32": "Classic 3:2", + "widthShort": "W", + "heightShort": "H", + "cancel": "Cancel", + "dismiss": "Dismiss", + "apply": "Apply", + "rotate": "Rotate", + "layer": "layer" + }, + "ui": { + "locale": "Locale", + "darkMode": "Dark mode", + "lightMode": "Light mode" + } +} diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json new file mode 100644 index 0000000..f9e52f1 --- /dev/null +++ b/apps/web/messages/ja.json @@ -0,0 +1,114 @@ +{ + "home": { + "title": "気分をビジュアルに", + "subtitle": "ローカル優先で編集。感情に合うプリセット。共有は好きな時に。", + "start": "新しいプロジェクトを開始", + "moods": "ムードプリセット", + "soft": "Soft", + "pien": "Pien", + "darkCute": "Dark Cute", + "stage": "Stage", + "custom": "Custom", + "open": "開く", + "duplicate": "複製", + "delete": "削除", + "projectHub": "プロジェクトハブ", + "createOpenManage": "ローカルプロジェクトの作成・読み込み・管理", + "newProject": "新規プロジェクト", + "openProjectFile": "プロジェクトファイルを開く", + "openImage": "画像を開く", + "myProjects": "マイプロジェクト", + "noProjectsYet": "プロジェクトがありません", + "untitledProject": "無題のプロジェクト", + "imageProject": "画像プロジェクト", + "wipTitle": "開発初期の警告", + "wipBody": "このアプリは開発のかなり初期段階で、現在も大幅に作業中です。機能が壊れていたり、未実装だったり、不安定だったり、予告なく変更される場合があります。", + "wipSupportPrefix": "開発を支援したい場合は、GitHub Sponsors からスポンサーできます:", + "wipAcknowledge": "理解しました" + }, + "editor": { + "file": "ファイル", + "edit": "編集", + "view": "表示", + "settings": "設定", + "save": "保存", + "exportPng": "PNGでエクスポート", + "exportProjectFile": "プロジェクトファイルをエクスポート", + "importImage": "画像をインポート", + "canvasSize": "キャンバスサイズ", + "undo": "元に戻す", + "redo": "やり直す", + "copy": "コピー", + "cut": "切り取り", + "paste": "貼り付け", + "preferences": "環境設定", + "panTool": "パン工具", + "pointerTool": "ポインター工具", + "unsavedChanges": "未保存の変更", + "saved": "保存済み", + "layers": "レイヤー", + "up": "上へ", + "down": "下へ", + "delete": "削除", + "noLayersYet": "レイヤーがありません。テキストまたは画像を追加してください。", + "faceTool": "顔ツール", + "faceMlFailed": "このデバイス/ブラウザでは顔認識MLモデルの読み込みに失敗しました。", + "detectingFaces": "選択した画像から顔を検出中...", + "selectImageLayer": "顔をスキャンするには画像レイヤーを選択してください。", + "noFacesFound": "選択した画像内で顔が見つかりませんでした。", + "facesDetected": "顔が検出されキャンバスにハイライトされました。", + "person": "人物", + "unknown": "不明", + "neutral": "中立", + "blurMethod": "ぼかし方法", + "soft": "ソフト", + "pixelate": "モザイク", + "censor": "センソル", + "strength": "強度", + "color": "色", + "selectFacesToBlur": "ぼかす顔を選択してください", + "blurFaces": "{count}件の顔をぼかす", + "clearBlur": "ぼかしをクリア", + "history": "履歴", + "beforeLastAction": "最後の操作の前", + "step": "ステップ", + "now": "現在", + "undoneStep": "取り消したステップ", + "noHistoryYet": "履歴がありません", + "resize": "サイズ変更", + "import": "インポート", + "mood": "ムード", + "quick": "クイック", + "face": "顔", + "decor": "デコル", + "faceMlFailedShort": "このデバイス/ブラウザでは顔MLモデルの読み込みに失敗しました。", + "detectingFacesShort": "デバイス上で顔を検出中...", + "faceDetectionTip": "{count}件の顔が見つかりました。正面を向いた顔がはっきり写っている画像レイヤーを選ぶと、より良い結果になります。", + "toolPointer": "ポインター", + "toolPan": "パン", + "toolFace": "顔", + "toolText": "テキスト", + "toolImage": "画像", + "canvasSizeTitle": "キャンバスサイズ", + "modePreset": "プリセット", + "modeCustom": "カスタム", + "presetSquare": "正方形", + "presetPortrait45": "縦長 4:5", + "presetStory916": "ストーリー 9:16", + "presetWidescreen": "ワイド 16:9", + "presetPhoto43": "写真 4:3", + "presetClassic32": "クラシック 3:2", + "widthShort": "幅", + "heightShort": "高", + "cancel": "キャンセル", + "dismiss": "閉じる", + "apply": "適用", + "rotate": "回転", + "layer": "レイヤー" + }, + "ui": { + "locale": "言語", + "darkMode": "ダークモード", + "lightMode": "ライトモード" + } +} diff --git a/apps/web/messages/th.json b/apps/web/messages/th.json new file mode 100644 index 0000000..e738eec --- /dev/null +++ b/apps/web/messages/th.json @@ -0,0 +1,114 @@ +{ + "home": { + "title": "ทำให้อารมณ์ของคุณมองเห็นได้", + "subtitle": "แต่งภาพแบบ local-first พร้อมพรีเซ็ตสายอารมณ์ แชร์เมื่อพร้อม", + "start": "เริ่มโปรเจกต์ใหม่", + "moods": "พรีเซ็ตอารมณ์", + "soft": "Soft", + "pien": "Pien", + "darkCute": "Dark Cute", + "stage": "Stage", + "custom": "Custom", + "open": "เปิด", + "duplicate": "ทำซ้ำ", + "delete": "ลบ", + "projectHub": "ศูนย์รวมโปรเจกต์", + "createOpenManage": "สร้าง เปิด และจัดการโปรเจกต์ในเครื่อง", + "newProject": "โปรเจกต์ใหม่", + "openProjectFile": "เปิดไฟล์โปรเจกต์", + "openImage": "เปิดรูปภาพ", + "myProjects": "โปรเจกต์ของฉัน", + "noProjectsYet": "ยังไม่มีโปรเจกต์", + "untitledProject": "โปรเจกต์ไม่มีชื่อ", + "imageProject": "โปรเจกต์รูปภาพ", + "wipTitle": "คำเตือน: ยังอยู่ระหว่างพัฒนาอย่างหนัก", + "wipBody": "แอปนี้ยังอยู่ในช่วงพัฒนาเริ่มต้นมาก ๆ และยังอยู่ระหว่างทำงานอย่างต่อเนื่อง ฟีเจอร์บางส่วนอาจใช้งานไม่ได้ หายไป ไม่เสถียร หรือเปลี่ยนแปลงได้ตลอดเวลา", + "wipSupportPrefix": "ถ้าคุณอยากสนับสนุนการพัฒนา สามารถสปอนเซอร์ผ่าน GitHub ได้ที่:", + "wipAcknowledge": "เข้าใจแล้ว" + }, + "editor": { + "file": "ไฟล์", + "edit": "แก้ไข", + "view": "มุมมอง", + "settings": "การตั้งค่า", + "save": "บันทึก", + "exportPng": "ส่งออก PNG", + "exportProjectFile": "ส่งออกไฟล์โปรเจกต์", + "importImage": "นำเข้ารูปภาพ", + "canvasSize": "ขนาด Canvas", + "undo": "เลิกทำ", + "redo": "ทำซ้ำ", + "copy": "คัดลอก", + "cut": "ตัด", + "paste": "วาง", + "preferences": "การตั้งค่า", + "panTool": "เครื่องมือเลื่อน", + "pointerTool": "เครื่องมือชี้", + "unsavedChanges": "การเปลี่ยนแปลงที่ยังไม่บันทึก", + "saved": "บันทึกแล้ว", + "layers": "เลเยอร์", + "up": "ขึ้น", + "down": "ลง", + "delete": "ลบ", + "noLayersYet": "ยังไม่มีเลเยอร์ เพิ่มข้อความหรือรูปภาพ", + "faceTool": "เครื่องมือใบหน้า", + "faceMlFailed": "โมเดล ML ใบหน้าไม่สามารถโหลดได้ในอุปกรณ์/เบราว์เซอร์นี้", + "detectingFaces": "กำลังตรวจจับใบหน้าในรูปภาพที่เลือก...", + "selectImageLayer": "เลือกเลเยอร์รูปภาพเพื่อสแกนใบหน้า", + "noFacesFound": "ไม่พบใบหน้าในรูปภาพที่เลือก", + "facesDetected": "ตรวจพบใบหน้าและไฮไลท์บน Canvas", + "person": "บุคคล", + "unknown": "ไม่รู้จัก", + "neutral": "เป็นกลาง", + "blurMethod": "วิธีเบลอ", + "soft": "นุ่ม", + "pixelate": "ปิกเซล", + "censor": "เซ็นเซอร์", + "strength": "ความแรง", + "color": "สี", + "selectFacesToBlur": "เลือกใบหน้าที่จะเบลอ", + "blurFaces": "เบลอ {count} ใบหน้า", + "clearBlur": "ล้างการเบลอ", + "history": "ประวัติ", + "beforeLastAction": "ก่อนการทำงานล่าสุด", + "step": "ขั้นตอน", + "now": "ตอนนี้", + "undoneStep": "ขั้นตอนที่เลิกทำ", + "noHistoryYet": "ยังไม่มีประวัติ", + "resize": "ปรับขนาด", + "import": "นำเข้า", + "mood": "อารมณ์", + "quick": "ด่วน", + "face": "ใบหน้า", + "decor": "ตกแต่ง", + "faceMlFailedShort": "โมเดล ML ใบหน้าไม่สามารถโหลดได้ในอุปกรณ์/เบราว์เซอร์นี้", + "detectingFacesShort": "กำลังตรวจจับใบหน้าบนอุปกรณ์...", + "faceDetectionTip": "พบ {count} ใบหน้า เลือกเลเยอร์รูปภาพที่มีใบหน้าหันตรงและชัดเจนเพื่อผลลัพธ์ที่ดีที่สุด", + "toolPointer": "ตัวชี้", + "toolPan": "เลื่อน", + "toolFace": "ใบหน้า", + "toolText": "ข้อความ", + "toolImage": "รูปภาพ", + "canvasSizeTitle": "ขนาดแคนวาส", + "modePreset": "พรีเซ็ต", + "modeCustom": "กำหนดเอง", + "presetSquare": "สี่เหลี่ยมจัตุรัส", + "presetPortrait45": "แนวตั้ง 4:5", + "presetStory916": "สตอรี่ 9:16", + "presetWidescreen": "จอกว้าง 16:9", + "presetPhoto43": "ภาพถ่าย 4:3", + "presetClassic32": "คลาสสิก 3:2", + "widthShort": "กว้าง", + "heightShort": "สูง", + "cancel": "ยกเลิก", + "dismiss": "ปิด", + "apply": "นำไปใช้", + "rotate": "หมุน", + "layer": "เลเยอร์" + }, + "ui": { + "locale": "ภาษา", + "darkMode": "โหมดมืด", + "lightMode": "โหมดสว่าง" + } +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000..c4b7818 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 0000000..5ed4a3d --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,7 @@ +import createNextIntlPlugin from "next-intl/plugin"; + +const withNextIntl = createNextIntlPlugin(); + +export default withNextIntl({ + reactStrictMode: true, +}); diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..46ce1ba --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,35 @@ +{ + "name": "@pien-studio/web", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --webpack -p 3000", + "build": "next build", + "start": "next start -p 3000", + "lint": "next lint", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, +"dependencies": { + "@mediapipe/face_detection": "~0.4.0", + "@pien-studio/editor-core": "workspace:*", + "@pien-studio/storage": "workspace:*", + "@pien-studio/types": "workspace:*", + "@radix-ui/react-select": "^2.2.6", + "@tensorflow-models/face-detection": "^1.0.3", + "@tensorflow/tfjs": "^4.22.0", + "@tensorflow/tfjs-backend-cpu": "^4.22.0", + "@tensorflow/tfjs-backend-webgl": "^4.22.0", + "@tensorflow/tfjs-converter": "^4.22.0", + "@tensorflow/tfjs-core": "^4.22.0", + "@vladmandic/face-api": "^1.7.15", + "class-variance-authority": "^0.7.1", + "lucide-react": "^1.14.0", + "next": "^16.2.4", + "next-intl": "^4.11.0", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "tailwindcss": "^4.2.4", + "zustand": "^5.0.13" + } +} diff --git a/apps/web/postcss.config.cjs b/apps/web/postcss.config.cjs new file mode 100644 index 0000000..de8ec71 --- /dev/null +++ b/apps/web/postcss.config.cjs @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + "@tailwindcss/postcss": {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/store/editor-store-helpers.ts b/apps/web/store/editor-store-helpers.ts new file mode 100644 index 0000000..74f4a1a --- /dev/null +++ b/apps/web/store/editor-store-helpers.ts @@ -0,0 +1,42 @@ +import type { Layer, Project } from "@pien-studio/types"; + +export const HISTORY_LIMIT = 120; + +export type HistoryState = { + past: Project[]; + present: Project; + future: Project[]; +}; + +export function deepClone(value: T): T { + if (typeof globalThis.structuredClone === "function") { + return globalThis.structuredClone(value); + } + return JSON.parse(JSON.stringify(value)) as T; +} + +export function cloneProject(project: Project): Project { + return deepClone(project); +} + +export function cloneLayer(layer: Layer): Layer { + return deepClone(layer); +} + +export function makeHistory(project: Project): HistoryState { + return { past: [], present: cloneProject(project), future: [] }; +} + +export function computeHistoryFlags(history: HistoryState) { + return { canUndo: history.past.length > 0, canRedo: history.future.length > 0 }; +} + +export function capHistory(items: Project[]): Project[] { + if (items.length <= HISTORY_LIMIT) return items; + return items.slice(items.length - HISTORY_LIMIT); +} + +export function resolveSelectedLayerId(project: Project, preferred: string | null): string | null { + if (preferred && project.layers.some((layer) => layer.id === preferred)) return preferred; + return project.layers[0]?.id ?? null; +} diff --git a/apps/web/store/editor-store.test.ts b/apps/web/store/editor-store.test.ts new file mode 100644 index 0000000..46713d4 --- /dev/null +++ b/apps/web/store/editor-store.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { useEditorStore } from "./editor-store"; + +describe("editor store", () => { + it("saves and loads project from indexeddb", async () => { + useEditorStore.getState().resetProject(); + useEditorStore.getState().addLayerByType("sticker"); + const savedId = useEditorStore.getState().project.id; + + await useEditorStore.getState().saveCurrentProject(); + useEditorStore.getState().resetProject(); + expect(useEditorStore.getState().project.id).not.toBe(savedId); + + await useEditorStore.getState().loadProjectById(savedId); + expect(useEditorStore.getState().project.id).toBe(savedId); + }); + + it("imports and exports project json", () => { + useEditorStore.getState().resetProject(); + useEditorStore.getState().addLayerByType("image"); + const json = useEditorStore.getState().exportProjectToJson(); + + useEditorStore.getState().resetProject(); + const result = useEditorStore.getState().importProjectFromJson(json); + + expect(result.ok).toBe(true); + expect(useEditorStore.getState().project.layers.length).toBe(1); + }); + + it("adds layer by type and removes selected layer", () => { + useEditorStore.getState().resetProject(); + useEditorStore.getState().addLayerByType("text"); + expect(useEditorStore.getState().project.layers[0]?.type).toBe("text"); + useEditorStore.getState().removeSelectedLayer(); + expect(useEditorStore.getState().project.layers).toHaveLength(0); + }); + + it("updates selected layer position directly", () => { + useEditorStore.getState().resetProject(); + useEditorStore.getState().addLayerByType("text"); + useEditorStore.getState().setSelectedLayerPosition(220, 140); + const layer = useEditorStore.getState().project.layers[0]; + expect(layer?.x).toBe(220); + expect(layer?.y).toBe(140); + }); + + it("records pointer draft transforms in undo history on commit", () => { + useEditorStore.getState().resetProject(); + useEditorStore.getState().addLayerByType("image"); + const initial = useEditorStore.getState().project.layers[0]; + expect(initial).toBeDefined(); + + useEditorStore.getState().setSelectedLayerPositionDraft(180, 150); + useEditorStore.getState().setSelectedLayerPosition(180, 150); + expect(useEditorStore.getState().canUndo).toBe(true); + + useEditorStore.getState().undo(); + const movedBack = useEditorStore.getState().project.layers[0]; + expect(movedBack?.x).toBe(initial?.x); + expect(movedBack?.y).toBe(initial?.y); + }); + + it("reorders layers up and down", () => { + useEditorStore.getState().resetProject(); + useEditorStore.getState().addLayerByType("text"); + useEditorStore.getState().addLayerByType("image"); + const layers = useEditorStore.getState().project.layers; + const [first, second] = layers; + expect(first.id).not.toBe(second.id); + + useEditorStore.getState().selectLayer(first.id); + useEditorStore.getState().moveSelectedLayerOrder("up"); + const after = useEditorStore.getState().project.layers; + expect(after[0].id).toBe(second.id); + expect(after[1].id).toBe(first.id); + }); +}); diff --git a/apps/web/store/editor-store.ts b/apps/web/store/editor-store.ts new file mode 100644 index 0000000..b945fd8 --- /dev/null +++ b/apps/web/store/editor-store.ts @@ -0,0 +1,470 @@ +"use client"; + +import { create } from "zustand"; +import { + addLayer, + createLayer, + createProject, + parseProjectFile, + removeLayer, + reorderLayer, + serializeProjectFile, + setCanvasSize as applyCanvasSize, + updateLayerTransform, + normalizeProject, +} from "@pien-studio/editor-core"; +import type { FaceBlurSettings, Layer, Project } from "@pien-studio/types"; +import { getProjectById, releaseProjectObjectUrls, upsertProject } from "@pien-studio/storage"; +import { DEFAULT_IMAGE_IMPORT, MIN_LAYER_SIZE } from "../lib/editor-constants"; +import { hasProjectChanged } from "../lib/project-equality"; +import { + capHistory, + cloneLayer, + cloneProject, + computeHistoryFlags, + makeHistory, + resolveSelectedLayerId, + type HistoryState, +} from "./editor-store-helpers"; + +const DRAFT_TRANSFORM_EPSILON = 0.01; + +export const EDITOR_TOOLS = { + pointer: { id: "pointer", allowsSelection: true, allowsLayerEditing: true }, + hand: { id: "hand", allowsSelection: false, allowsLayerEditing: false }, + face: { id: "face", allowsSelection: true, allowsLayerEditing: false }, +} as const; + +export type EditorToolId = keyof typeof EDITOR_TOOLS; + +type TransactionState = { + baselineProject: Project; + baselineSelectedLayerId: string | null; +}; + +type EditorState = { + project: Project; + selectedLayerId: string | null; + tool: EditorToolId; + history: HistoryState; + canUndo: boolean; + canRedo: boolean; + clipboardLayer: Layer | null; + isDirty: boolean; + transaction: TransactionState | null; + tools: typeof EDITOR_TOOLS; + startTransaction: () => void; + commitTransaction: () => void; + cancelTransaction: () => void; + applyProjectDraft: (project: Project) => void; + setTool: (tool: EditorToolId) => void; + addLayerByType: (type: Layer["type"]) => void; + setSelectedLayerPosition: (x: number, y: number) => void; + setSelectedLayerPositionDraft: (x: number, y: number) => void; + setSelectedLayerSize: (width: number, height: number) => void; + setSelectedLayerSizeDraft: (width: number, height: number) => void; + setSelectedLayerRotation: (rotation: number) => void; + setSelectedLayerRotationDraft: (rotation: number) => void; + removeSelectedLayer: () => void; + moveSelectedLayerOrder: (direction: "up" | "down") => void; + selectLayer: (layerId: string | null) => void; + copySelectedLayer: () => void; + cutSelectedLayer: () => void; + pasteLayer: () => void; + resetProject: () => void; + saveCurrentProject: () => Promise; + loadProjectById: (projectId: string) => Promise; + setProject: (project: Project) => void; + importProjectFromJson: (raw: string) => { ok: boolean; error?: string }; + importImageFromFile: (file: File) => Promise; + updateImageLayerSource: (layerId: string, sourceUri: string) => void; + setImageLayerFaceBlur: (layerId: string, faceBlur: FaceBlurSettings | undefined) => void; + setCanvasSize: (width: number, height: number) => void; + exportProjectToJson: () => string; + undo: () => void; + redo: () => void; + jumpToPast: (idx: number) => void; + jumpToFuture: (idx: number) => void; +}; + +const initialProject = createProject("Untitled Project"); + +function withCommittedProject(state: EditorState, nextProject: Project, extras?: Partial) { + const past = capHistory([...state.history.past, state.history.present]); + const history = { past, present: cloneProject(nextProject), future: [] }; + return { + project: nextProject, + history, + selectedLayerId: resolveSelectedLayerId(nextProject, state.selectedLayerId), + transaction: null, + ...computeHistoryFlags(history), + isDirty: true, + ...extras, + } satisfies Partial; +} + +function makeStableProjectState(previousSelectedLayerId: string | null, project: Project) { + const history = makeHistory(project); + return { + project, + selectedLayerId: resolveSelectedLayerId(project, previousSelectedLayerId), + history, + ...computeHistoryFlags(history), + clipboardLayer: null, + transaction: null, + isDirty: false, + }; +} + +function getProjectAssetIds(project: Project): string[] { + return project.layers.map((layer) => layer.assetId).filter((assetId): assetId is string => Boolean(assetId)); +} + +export const useEditorStore = create((set, get) => ({ + project: initialProject, + selectedLayerId: null, + tool: "pointer", + history: makeHistory(initialProject), + ...computeHistoryFlags(makeHistory(initialProject)), + clipboardLayer: null, + isDirty: false, + transaction: null, + tools: EDITOR_TOOLS, + + startTransaction: () => + set((state) => { + if (state.transaction) return state; + return { + transaction: { + baselineProject: cloneProject(state.project), + baselineSelectedLayerId: state.selectedLayerId, + }, + }; + }), + + commitTransaction: () => + set((state) => { + if (!state.transaction) return state; + if (!hasProjectChanged(state.transaction.baselineProject, state.project)) { + return { transaction: null }; + } + + const past = capHistory([...state.history.past, cloneProject(state.transaction.baselineProject)]); + const history = { past, present: cloneProject(state.project), future: [] }; + return { + history, + transaction: null, + ...computeHistoryFlags(history), + isDirty: true, + }; + }), + + cancelTransaction: () => + set((state) => { + if (!state.transaction) return state; + return { + project: cloneProject(state.transaction.baselineProject), + selectedLayerId: resolveSelectedLayerId(state.transaction.baselineProject, state.transaction.baselineSelectedLayerId), + transaction: null, + }; + }), + + applyProjectDraft: (project) => set(() => ({ project })), + + setTool: (tool) => { + if (!(tool in EDITOR_TOOLS)) return; + set({ tool }); + }, + + addLayerByType: (type) => + set((state) => { + const layer = createLayer(type); + const nextProject = addLayer(state.project, layer); + return withCommittedProject(state, nextProject, { selectedLayerId: layer.id }); + }), + + setSelectedLayerPosition: (x, y) => + set((state) => { + if (!state.selectedLayerId) return state; + const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId); + if (committed && committed.x === x && committed.y === y) return state; + const nextProject = updateLayerTransform(state.project, state.selectedLayerId, { x, y }); + return withCommittedProject(state, nextProject); + }), + + setSelectedLayerPositionDraft: (x, y) => + set((state) => { + if (!state.selectedLayerId) return state; + if (!Number.isFinite(x) || !Number.isFinite(y)) return state; + const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId); + if (current && current.x === x && current.y === y) return state; + return { project: updateLayerTransform(state.project, state.selectedLayerId, { x, y }) }; + }), + + setSelectedLayerSize: (width, height) => + set((state) => { + if (!state.selectedLayerId) return state; + const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId); + if (committed && committed.width === width && committed.height === height) return state; + return withCommittedProject(state, updateLayerTransform(state.project, state.selectedLayerId, { width, height })); + }), + + setSelectedLayerSizeDraft: (width, height) => + set((state) => { + if (!state.selectedLayerId) return state; + if (!Number.isFinite(width) || !Number.isFinite(height)) return state; + const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId); + const nextWidth = Math.max(MIN_LAYER_SIZE, width); + const nextHeight = Math.max(MIN_LAYER_SIZE, height); + + if (current) { + const currentWidth = current.width ?? (current.type === "image" ? Math.round(200 * current.scale) : undefined); + const currentHeight = current.height ?? (current.type === "image" ? Math.round(150 * current.scale) : undefined); + + if ( + typeof currentWidth === "number" && + typeof currentHeight === "number" && + Math.abs(currentWidth - nextWidth) < DRAFT_TRANSFORM_EPSILON && + Math.abs(currentHeight - nextHeight) < DRAFT_TRANSFORM_EPSILON + ) { + return state; + } + } + return { project: updateLayerTransform(state.project, state.selectedLayerId, { width: nextWidth, height: nextHeight }) }; + }), + + removeSelectedLayer: () => + set((state) => { + if (!state.selectedLayerId) return state; + const nextProject = removeLayer(state.project, state.selectedLayerId); + return withCommittedProject(state, nextProject); + }), + + moveSelectedLayerOrder: (direction) => + set((state) => { + if (!state.selectedLayerId) return state; + const idx = state.project.layers.findIndex((layer) => layer.id === state.selectedLayerId); + if (idx < 0) return state; + const nextIndex = direction === "up" ? idx + 1 : idx - 1; + return withCommittedProject(state, reorderLayer(state.project, state.selectedLayerId, nextIndex)); + }), + + selectLayer: (layerId) => set((state) => ({ selectedLayerId: resolveSelectedLayerId(state.project, layerId) })), + + setSelectedLayerRotation: (rotation) => + set((state) => { + if (!state.selectedLayerId) return state; + const committed = state.history.present.layers.find((layer) => layer.id === state.selectedLayerId); + if (committed && committed.rotation === rotation) return state; + return withCommittedProject(state, updateLayerTransform(state.project, state.selectedLayerId, { rotation })); + }), + + setSelectedLayerRotationDraft: (rotation) => + set((state) => { + if (!state.selectedLayerId) return state; + const current = state.project.layers.find((layer) => layer.id === state.selectedLayerId); + if (current && current.rotation === rotation) return state; + return { project: updateLayerTransform(state.project, state.selectedLayerId, { rotation }) }; + }), + + copySelectedLayer: () => + set((state) => { + if (!state.selectedLayerId) return state; + const layer = state.project.layers.find((item) => item.id === state.selectedLayerId); + if (!layer) return state; + return { clipboardLayer: cloneLayer(layer) }; + }), + + cutSelectedLayer: () => + set((state) => { + if (!state.selectedLayerId) return state; + const layer = state.project.layers.find((item) => item.id === state.selectedLayerId); + if (!layer) return state; + const nextProject = removeLayer(state.project, state.selectedLayerId); + return withCommittedProject(state, nextProject, { clipboardLayer: cloneLayer(layer) }); + }), + + pasteLayer: () => + set((state) => { + if (!state.clipboardLayer) return state; + const base = state.clipboardLayer; + const pasted: Layer = { ...base, id: crypto.randomUUID(), x: base.x + 20, y: base.y + 20 }; + const nextProject = addLayer(state.project, pasted); + return withCommittedProject(state, nextProject, { selectedLayerId: pasted.id }); + }), + + resetProject: () => { + releaseProjectObjectUrls(get().project); + const project = createProject("Untitled Project"); + const history = makeHistory(project); + set({ + project, + selectedLayerId: null, + history, + ...computeHistoryFlags(history), + clipboardLayer: null, + transaction: null, + isDirty: false, + }); + }, + + saveCurrentProject: async () => { + await upsertProject(normalizeProject(get().project)); + set({ isDirty: false }); + }, + + loadProjectById: async (projectId) => { + const project = await getProjectById(projectId); + if (!project) return false; + const normalized = normalizeProject(project); + releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized)); + set(makeStableProjectState(get().selectedLayerId, normalized)); + return true; + }, + + setProject: (project) => { + const normalized = normalizeProject(project); + releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized)); + set(makeStableProjectState(get().selectedLayerId, normalized)); + }, + + importProjectFromJson: (raw) => { + const parsed = parseProjectFile(raw); + if (!parsed.ok) return { ok: false, error: parsed.error }; + const normalized = normalizeProject(parsed.project); + releaseProjectObjectUrls(get().project, getProjectAssetIds(normalized)); + set(makeStableProjectState(get().selectedLayerId, normalized)); + return { ok: true }; + }, + + exportProjectToJson: () => { + const project = normalizeProject(get().project); + const history = get().history; + return serializeProjectFile(project, { checkpointCount: history.past.length + history.future.length }); + }, + + importImageFromFile: async (file) => { + const reader = new FileReader(); + const dataUrl = await new Promise((resolve, reject) => { + reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : ""); + reader.onerror = reject; + reader.readAsDataURL(file); + }); + const name = file.name.replace(/\.[^/.]+$/, "") || "Image"; + const imageSize = await new Promise<{ width: number; height: number }>((resolve) => { + const image = new Image(); + image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }); + image.onerror = () => + resolve({ width: DEFAULT_IMAGE_IMPORT.fallbackWidth, height: DEFAULT_IMAGE_IMPORT.fallbackHeight }); + image.src = dataUrl; + }); + const layer = createLayer("image", { + name, + sourceUri: dataUrl, + x: DEFAULT_IMAGE_IMPORT.offsetX, + y: DEFAULT_IMAGE_IMPORT.offsetY, + width: Math.max(1, Math.round(imageSize.width)), + height: Math.max(1, Math.round(imageSize.height)), + }); + + set((state) => withCommittedProject(state, addLayer(state.project, layer), { selectedLayerId: layer.id })); + }, + + updateImageLayerSource: (layerId, sourceUri) => + set((state) => { + if (!sourceUri) return state; + const layer = state.project.layers.find((item) => item.id === layerId); + if (!layer || layer.type !== "image") return state; + if (layer.sourceUri === sourceUri) return state; + const nextProject = updateLayerTransform(state.project, layerId, { sourceUri }); + return withCommittedProject(state, nextProject); + }), + + setImageLayerFaceBlur: (layerId, faceBlur) => + set((state) => { + const layer = state.project.layers.find((item) => item.id === layerId); + if (!layer || layer.type !== "image") return state; + const nextProject = updateLayerTransform(state.project, layerId, { faceBlur }); + return withCommittedProject(state, nextProject); + }), + + setCanvasSize: (width, height) => set((state) => withCommittedProject(state, applyCanvasSize(state.project, width, height))), + + undo: () => + set((state) => { + if (state.history.past.length === 0) return state; + const past = [...state.history.past]; + const previous = past.pop(); + if (!previous) return state; + const future = [state.history.present, ...state.history.future]; + const history = { past, present: cloneProject(previous), future }; + return { + project: cloneProject(previous), + history, + selectedLayerId: resolveSelectedLayerId(previous, state.selectedLayerId), + transaction: null, + ...computeHistoryFlags(history), + isDirty: true, + }; + }), + + redo: () => + set((state) => { + if (state.history.future.length === 0) return state; + const future = [...state.history.future]; + const next = future.shift(); + if (!next) return state; + const past = capHistory([...state.history.past, state.history.present]); + const history = { past, present: cloneProject(next), future }; + return { + project: cloneProject(next), + history, + selectedLayerId: resolveSelectedLayerId(next, state.selectedLayerId), + transaction: null, + ...computeHistoryFlags(history), + isDirty: true, + }; + }), + + jumpToPast: (idx) => + set((state) => { + const targetPastLength = Math.max(0, Math.min(state.history.past.length, idx - 1)); + if (state.history.past.length === targetPastLength) return state; + const moved = state.history.past.slice(targetPastLength); + if (moved.length === 0) return state; + const previous = moved[0]; + if (!previous) return state; + const past = state.history.past.slice(0, targetPastLength); + const future = [state.history.present, ...moved.slice(1), ...state.history.future]; + const history = { past, present: cloneProject(previous), future }; + return { + project: cloneProject(previous), + history, + selectedLayerId: resolveSelectedLayerId(previous, state.selectedLayerId), + transaction: null, + ...computeHistoryFlags(history), + isDirty: true, + }; + }), + + jumpToFuture: (idx) => + set((state) => { + const targetFutureLength = Math.max(0, Math.min(state.history.future.length, idx)); + if (state.history.future.length === targetFutureLength) return state; + const redoCount = state.history.future.length - targetFutureLength; + const next = state.history.future[redoCount - 1]; + if (!next) return state; + const consumedFuture = state.history.future.slice(0, redoCount - 1); + const future = state.history.future.slice(redoCount); + const past = capHistory([...state.history.past, state.history.present, ...consumedFuture]); + const history = { past, present: cloneProject(next), future }; + return { + project: cloneProject(next), + history, + selectedLayerId: resolveSelectedLayerId(next, state.selectedLayerId), + transaction: null, + ...computeHistoryFlags(history), + isDirty: true, + }; + }), +})); diff --git a/apps/web/store/ui-store.test.ts b/apps/web/store/ui-store.test.ts new file mode 100644 index 0000000..76113a2 --- /dev/null +++ b/apps/web/store/ui-store.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useUiStore } from "./ui-store"; + +describe("ui store", () => { + beforeEach(() => { + window.localStorage.clear(); + document.documentElement.setAttribute("data-theme", "dark"); + useUiStore.setState({ theme: "dark", locale: "en" }); + }); + + it("hydrates from local storage", () => { + window.localStorage.setItem("pien.ui.theme", "light"); + window.localStorage.setItem("pien.ui.locale", "ja"); + useUiStore.getState().hydrate(); + expect(useUiStore.getState().theme).toBe("light"); + expect(useUiStore.getState().locale).toBe("ja"); + }); + + it("updates and persists theme", () => { + useUiStore.getState().setTheme("light"); + expect(useUiStore.getState().theme).toBe("light"); + expect(window.localStorage.getItem("pien.ui.theme")).toBe("light"); + expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + }); + + it("updates and persists locale", () => { + useUiStore.getState().setLocale("th"); + expect(useUiStore.getState().locale).toBe("th"); + expect(window.localStorage.getItem("pien.ui.locale")).toBe("th"); + }); +}); diff --git a/apps/web/store/ui-store.ts b/apps/web/store/ui-store.ts new file mode 100644 index 0000000..543d925 --- /dev/null +++ b/apps/web/store/ui-store.ts @@ -0,0 +1,62 @@ +"use client"; + +import { create } from "zustand"; + +export type AppTheme = "light" | "dark"; +export type AppLocale = "en" | "th" | "ja"; + +const THEME_KEY = "pien.ui.theme"; +const LOCALE_KEY = "pien.ui.locale"; + +function readTheme(): AppTheme { + if (typeof window === "undefined") return "light"; + const value = window.localStorage.getItem(THEME_KEY); + return value === "light" ? "light" : "dark"; +} + +function readLocale(): AppLocale { + if (typeof window === "undefined") return "en"; + const value = window.localStorage.getItem(LOCALE_KEY); + if (value === "th" || value === "ja" || value === "en") return value; + return "en"; +} + +function applyTheme(theme: AppTheme): void { + if (typeof document === "undefined") return; + document.documentElement.setAttribute("data-theme", theme); +} + +type UiState = { + theme: AppTheme; + locale: AppLocale; + hydrate: () => void; + setTheme: (theme: AppTheme) => void; + setLocale: (locale: AppLocale) => void; +}; + +export const useUiStore = create((set) => ({ + theme: "light", + locale: "en", + hydrate: () => { + const theme = readTheme(); + const locale = readLocale(); + applyTheme(theme); + set((state) => { + if (state.theme === theme && state.locale === locale) return state; + return { theme, locale }; + }); + }, + setTheme: (theme) => { + if (typeof window !== "undefined") { + window.localStorage.setItem(THEME_KEY, theme); + } + applyTheme(theme); + set({ theme }); + }, + setLocale: (locale) => { + if (typeof window !== "undefined") { + window.localStorage.setItem(LOCALE_KEY, locale); + } + set({ locale }); + }, +})); diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts new file mode 100644 index 0000000..dd0341a --- /dev/null +++ b/apps/web/tailwind.config.ts @@ -0,0 +1,28 @@ +import type { Config } from "tailwindcss"; + +export default { + content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"], + theme: { + extend: { + colors: { + cream: "var(--color-cream)", + berry: "var(--color-berry)", + wine: "var(--color-wine)", + mint: "var(--color-mint)", + ink: "var(--color-ink)", + accent: "var(--color-accent)", + "accent-strong": "var(--color-accent-strong)", + surface: "var(--color-surface)", + "surface-2": "var(--color-surface-2)", + }, + fontFamily: { + display: ["'M PLUS Rounded 1c'", "'Noto Sans Thai'", "sans-serif"], + body: ["'Zen Kaku Gothic New'", "'Noto Sans Thai'", "sans-serif"], + }, + boxShadow: { + float: "0 20px 55px rgba(48, 13, 22, 0.18)", + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..74cbd49 --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,32 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "plugins": [ + { + "name": "next" + } + ], + "types": [ + "node" + ], + "jsx": "preserve", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "noEmit": true, + "incremental": true, + "esModuleInterop": true, + "isolatedModules": true + }, + "include": [ + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..8ba7812 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + setupFiles: ["./vitest.setup.ts"], + include: ["**/*.test.ts", "**/*.test.tsx"], + }, +}); diff --git a/apps/web/vitest.setup.ts b/apps/web/vitest.setup.ts new file mode 100644 index 0000000..59976d6 --- /dev/null +++ b/apps/web/vitest.setup.ts @@ -0,0 +1,8 @@ +import "fake-indexeddb/auto"; +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(() => { + cleanup(); +}); diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..d5fab76 --- /dev/null +++ b/bun.lock @@ -0,0 +1,976 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "pien", + "devDependencies": { + "@playwright/test": "^1.59.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^25.6.0", + "prettier": "^3.8.3", + "turbo": "^2.9.9", + "typescript": "^6.0.3", + "vitest": "^4.1.5", + }, + }, + "apps/api": { + "name": "@pien-studio/api", + "dependencies": { + "@pien-studio/types": "workspace:*", + "elysia": "^1.1.25", + "zod": "^4.4.3", + }, + "devDependencies": { + "bun-types": "latest", + "typescript": "^6.0.3", + "vitest": "^4.1.5", + }, + }, + "apps/web": { + "name": "@pien-studio/web", + "dependencies": { + "@mediapipe/face_detection": "~0.4.0", + "@pien-studio/editor-core": "workspace:*", + "@pien-studio/storage": "workspace:*", + "@pien-studio/types": "workspace:*", + "@radix-ui/react-select": "^2.2.6", + "@tensorflow-models/face-detection": "^1.0.3", + "@tensorflow/tfjs": "^4.22.0", + "@tensorflow/tfjs-backend-cpu": "^4.22.0", + "@tensorflow/tfjs-backend-webgl": "^4.22.0", + "@tensorflow/tfjs-converter": "^4.22.0", + "@tensorflow/tfjs-core": "^4.22.0", + "@vladmandic/face-api": "^1.7.15", + "class-variance-authority": "^0.7.1", + "lucide-react": "^1.14.0", + "next": "^16.2.4", + "next-intl": "^4.11.0", + "react": "^19.2.5", + "react-dom": "^19.2.5", + "tailwindcss": "^4.2.4", + "zustand": "^5.0.13", + }, + }, + "packages/config": { + "name": "@pien-studio/config", + "version": "0.0.0", + }, + "packages/editor-core": { + "name": "@pien-studio/editor-core", + "version": "0.0.0", + "dependencies": { + "@pien-studio/types": "workspace:*", + }, + }, + "packages/storage": { + "name": "@pien-studio/storage", + "version": "0.0.0", + "dependencies": { + "@pien-studio/editor-core": "workspace:*", + "@pien-studio/types": "workspace:*", + }, + "devDependencies": { + "fake-indexeddb": "^6.2.2", + "vitest": "^4.1.5", + }, + }, + "packages/types": { + "name": "@pien-studio/types", + "version": "0.0.0", + "dependencies": { + "zod": "^4.4.3", + }, + }, + "packages/ui": { + "name": "@pien-studio/ui", + "version": "0.0.0", + "dependencies": { + "class-variance-authority": "^0.7.1", + }, + }, + }, + "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.1.11", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@csstools/css-calc": "^3.2.0", "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.1.1", "", { "dependencies": { "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1" } }, "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ=="], + + "@asamuzakjp/generational-cache": ["@asamuzakjp/generational-cache@1.0.1", "", {}, "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + + "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], + + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@6.0.2", "", {}, "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.2.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.1.0", "", { "dependencies": { "@csstools/color-helpers": "^6.0.2", "@csstools/css-calc": "^3.2.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.3", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="], + + "@formatjs/fast-memoize": ["@formatjs/fast-memoize@3.1.4", "", {}, "sha512-Lbke1aOrsygKKR09Ux0NrZgbTqpDmiwXOgzyDOJ8Owr1zd5qOKTauf62hH+Seeku3ju77rHWH9I5SfX2CN0vuA=="], + + "@formatjs/icu-messageformat-parser": ["@formatjs/icu-messageformat-parser@3.5.7", "", { "dependencies": { "@formatjs/icu-skeleton-parser": "2.1.7" } }, "sha512-wJxRZ+SiUCIMTL86bQlZU9bEKDQqqvgk2ezQ1BySUdWRfHqOzj4IKUVFeUZKS9w58M4e7wMSG0Sl86LAPb7Qww=="], + + "@formatjs/icu-skeleton-parser": ["@formatjs/icu-skeleton-parser@2.1.7", "", {}, "sha512-cIw1SFP0bi0CUBiJ2jzp99ws3OJNQDfStcHq9Z0iHWzItmiIikihFO+npR8C80yDlp7ZuBCLXCcKjgWjHicksA=="], + + "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.8.6", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.4" } }, "sha512-AZRgUxj0q93lyF7Z5lFS85bLINXuBLX4R3tCKicO6fSWo6cvh9GQfoR3B1WlsqQwefZ1QORTivhInx7gM6HUzQ=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@mediapipe/face_detection": ["@mediapipe/face_detection@0.4.1646425229", "", {}, "sha512-aeCN+fRAojv9ch3NXorP6r5tcGVLR3/gC1HmtqB0WEZBRXrdP6/3W/sGR0dHr1iT6ueiK95G9PVjbzFosf/hrg=="], + + "@next/env": ["@next/env@16.2.4", "", {}, "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.4", "", { "os": "win32", "cpu": "x64" }, "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw=="], + + "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + + "@pien-studio/api": ["@pien-studio/api@workspace:apps/api"], + + "@pien-studio/config": ["@pien-studio/config@workspace:packages/config"], + + "@pien-studio/editor-core": ["@pien-studio/editor-core@workspace:packages/editor-core"], + + "@pien-studio/storage": ["@pien-studio/storage@workspace:packages/storage"], + + "@pien-studio/types": ["@pien-studio/types@workspace:packages/types"], + + "@pien-studio/ui": ["@pien-studio/ui@workspace:packages/ui"], + + "@pien-studio/web": ["@pien-studio/web@workspace:apps/web"], + + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], + + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@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=="], + + "@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=="], + + "@radix-ui/react-compose-refs": ["@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-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@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-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@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-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-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-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=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@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-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@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-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-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-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@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-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "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-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-layout-effect": ["@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-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@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-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "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-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.3", "", { "os": "android", "cpu": "arm" }, "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.3", "", { "os": "android", "cpu": "arm64" }, "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.3", "", { "os": "linux", "cpu": "arm" }, "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.3", "", { "os": "linux", "cpu": "none" }, "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.3", "", { "os": "none", "cpu": "arm64" }, "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.3", "", { "os": "win32", "cpu": "x64" }, "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA=="], + + "@schummar/icu-type-parser": ["@schummar/icu-type-parser@1.21.5", "", {}, "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@swc/core": ["@swc/core@1.15.33", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.26" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.33", "@swc/core-darwin-x64": "1.15.33", "@swc/core-linux-arm-gnueabihf": "1.15.33", "@swc/core-linux-arm64-gnu": "1.15.33", "@swc/core-linux-arm64-musl": "1.15.33", "@swc/core-linux-ppc64-gnu": "1.15.33", "@swc/core-linux-s390x-gnu": "1.15.33", "@swc/core-linux-x64-gnu": "1.15.33", "@swc/core-linux-x64-musl": "1.15.33", "@swc/core-win32-arm64-msvc": "1.15.33", "@swc/core-win32-ia32-msvc": "1.15.33", "@swc/core-win32-x64-msvc": "1.15.33" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ=="], + + "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.33", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA=="], + + "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.33", "", { "os": "darwin", "cpu": "x64" }, "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA=="], + + "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.33", "", { "os": "linux", "cpu": "arm" }, "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ=="], + + "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.33", "", { "os": "linux", "cpu": "arm64" }, "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw=="], + + "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.33", "", { "os": "linux", "cpu": "arm64" }, "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og=="], + + "@swc/core-linux-ppc64-gnu": ["@swc/core-linux-ppc64-gnu@1.15.33", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog=="], + + "@swc/core-linux-s390x-gnu": ["@swc/core-linux-s390x-gnu@1.15.33", "", { "os": "linux", "cpu": "s390x" }, "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA=="], + + "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.33", "", { "os": "linux", "cpu": "x64" }, "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw=="], + + "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.33", "", { "os": "linux", "cpu": "x64" }, "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ=="], + + "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.33", "", { "os": "win32", "cpu": "arm64" }, "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g=="], + + "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.33", "", { "os": "win32", "cpu": "ia32" }, "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ=="], + + "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.33", "", { "os": "win32", "cpu": "x64" }, "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg=="], + + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + + "@swc/types": ["@swc/types@0.1.26", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw=="], + + "@tensorflow-models/face-detection": ["@tensorflow-models/face-detection@1.0.3", "", { "dependencies": { "rimraf": "^3.0.2", "tslib": "2.4.0" }, "peerDependencies": { "@mediapipe/face_detection": "~0.4.0", "@tensorflow/tfjs-backend-webgl": "^4.21.0", "@tensorflow/tfjs-converter": "^4.21.0", "@tensorflow/tfjs-core": "^4.21.0" } }, "sha512-4Ld/vFF8MrdFdrMWhlLKZD4hMW0PNY9OkYeqoCPNZ+LwFyenxAqVaNaWrR8JKp37vw9Nuzp4ILbkal5zPUnA0g=="], + + "@tensorflow/tfjs": ["@tensorflow/tfjs@4.22.0", "", { "dependencies": { "@tensorflow/tfjs-backend-cpu": "4.22.0", "@tensorflow/tfjs-backend-webgl": "4.22.0", "@tensorflow/tfjs-converter": "4.22.0", "@tensorflow/tfjs-core": "4.22.0", "@tensorflow/tfjs-data": "4.22.0", "@tensorflow/tfjs-layers": "4.22.0", "argparse": "^1.0.10", "chalk": "^4.1.0", "core-js": "3.29.1", "regenerator-runtime": "^0.13.5", "yargs": "^16.0.3" }, "bin": { "tfjs-custom-module": "dist/tools/custom_module/cli.js" } }, "sha512-0TrIrXs6/b7FLhLVNmfh8Sah6JgjBPH4mZ8JGb7NU6WW+cx00qK5BcAZxw7NCzxj6N8MRAIfHq+oNbPUNG5VAg=="], + + "@tensorflow/tfjs-backend-cpu": ["@tensorflow/tfjs-backend-cpu@4.22.0", "", { "dependencies": { "@types/seedrandom": "^2.4.28", "seedrandom": "^3.0.5" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-1u0FmuLGuRAi8D2c3cocHTASGXOmHc/4OvoVDENJayjYkS119fcTcQf4iHrtLthWyDIPy3JiPhRrZQC9EwnhLw=="], + + "@tensorflow/tfjs-backend-webgl": ["@tensorflow/tfjs-backend-webgl@4.22.0", "", { "dependencies": { "@tensorflow/tfjs-backend-cpu": "4.22.0", "@types/offscreencanvas": "~2019.3.0", "@types/seedrandom": "^2.4.28", "seedrandom": "^3.0.5" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-H535XtZWnWgNwSzv538czjVlbJebDl5QTMOth4RXr2p/kJ1qSIXE0vZvEtO+5EC9b00SvhplECny2yDewQb/Yg=="], + + "@tensorflow/tfjs-converter": ["@tensorflow/tfjs-converter@4.22.0", "", { "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-PT43MGlnzIo+YfbsjM79Lxk9lOq6uUwZuCc8rrp0hfpLjF6Jv8jS84u2jFb+WpUeuF4K33ZDNx8CjiYrGQ2trQ=="], + + "@tensorflow/tfjs-core": ["@tensorflow/tfjs-core@4.22.0", "", { "dependencies": { "@types/long": "^4.0.1", "@types/offscreencanvas": "~2019.7.0", "@types/seedrandom": "^2.4.28", "@webgpu/types": "0.1.38", "long": "4.0.0", "node-fetch": "~2.6.1", "seedrandom": "^3.0.5" } }, "sha512-LEkOyzbknKFoWUwfkr59vSB68DMJ4cjwwHgicXN0DUi3a0Vh1Er3JQqCI1Hl86GGZQvY8ezVrtDIvqR1ZFW55A=="], + + "@tensorflow/tfjs-data": ["@tensorflow/tfjs-data@4.22.0", "", { "dependencies": { "@types/node-fetch": "^2.1.2", "node-fetch": "~2.6.1", "string_decoder": "^1.3.0" }, "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0", "seedrandom": "^3.0.5" } }, "sha512-dYmF3LihQIGvtgJrt382hSRH4S0QuAp2w1hXJI2+kOaEqo5HnUPG0k5KA6va+S1yUhx7UBToUKCBHeLHFQRV4w=="], + + "@tensorflow/tfjs-layers": ["@tensorflow/tfjs-layers@4.22.0", "", { "peerDependencies": { "@tensorflow/tfjs-core": "4.22.0" } }, "sha512-lybPj4ZNj9iIAPUj7a8ZW1hg8KQGfqWLlCZDi9eM/oNKCCAgchiyzx8OrYoWmRrB+AM6VNEeIT+2gZKg5ReihA=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@turbo/darwin-64": ["@turbo/darwin-64@2.9.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-hTEiNu2ABZZOO1qbjnKASI8eF3BdOOzU6iKv5w5uGOK65DDMc10cS40N1kqM99YT0uSAGUwNu6GdFctRPeEeVA=="], + + "@turbo/darwin-arm64": ["@turbo/darwin-arm64@2.9.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MinO40EEcP5mJiTVpfjtEulsEBhVeryfq21QhYtJZ8hQJLHGgy459rcmDVAY8/JERe4dkVU4KW+zoLF22o01EA=="], + + "@turbo/linux-64": ["@turbo/linux-64@2.9.9", "", { "os": "linux", "cpu": "x64" }, "sha512-7JNLw88Isk+gMlbsC8pulLDkrqe2B827ZsKFEHilb17AC6Xn/62pzH7afjY7fEU6Ayp4XP/vGhlRWOzqBvBvIQ=="], + + "@turbo/linux-arm64": ["@turbo/linux-arm64@2.9.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-0pnXDwPw1rHii98JZPRg7SvsjIzy7jrhkwGU9Jy5fVYoMdYd3P2vbtLfII+OJ0Mm4Ar5yykdHDTz3RWiRI1o9g=="], + + "@turbo/windows-64": ["@turbo/windows-64@2.9.9", "", { "os": "win32", "cpu": "x64" }, "sha512-vjDQycz4gQVvIq4n2rPtiiIESwJlAc406qtkiZlqyL+fHZEd9SxYNlBIFYtc5cuMuwrk+sIKrhN7XvwjmvS9YQ=="], + + "@turbo/windows-arm64": ["@turbo/windows-arm64@2.9.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-V6NiH43oCctepbOdQFp7UjqLyK8p6Tt824QA+G4TE+B1BBHu80A0W8OCL+H7uBJ3XZjAj/hvPDw3k3l65DoDGw=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="], + + "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + + "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], + + "@types/offscreencanvas": ["@types/offscreencanvas@2019.3.0", "", {}, "sha512-esIJx9bQg+QYF0ra8GnvfianIY8qWB0GBx54PK5Eps6m+xTj86KLavHv6qDhzKcu5UUOgNfJ2pWaIIV7TRUd9Q=="], + + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/seedrandom": ["@types/seedrandom@2.4.34", "", {}, "sha512-ytDiArvrn/3Xk6/vtylys5tlY6eo7Ane0hvcx++TKo6RxQXuVfW0AF/oeWqAj9dN29SyhtawuXstgmPlwNcv/A=="], + + "@vitest/expect": ["@vitest/expect@4.1.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.5", "", { "dependencies": { "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.5", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g=="], + + "@vitest/runner": ["@vitest/runner@4.1.5", "", { "dependencies": { "@vitest/utils": "4.1.5", "pathe": "^2.0.3" } }, "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ=="], + + "@vitest/spy": ["@vitest/spy@4.1.5", "", {}, "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ=="], + + "@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="], + + "@vladmandic/face-api": ["@vladmandic/face-api@1.7.15", "", {}, "sha512-WDMmK3CfNLo8jylWqMoQgf4nIst3M0fzx1dnac96wv/dvMTN4DxC/Pq1DGtduDk1lktCamQ3MIDXFnvrdHTXDw=="], + + "@webgpu/types": ["@webgpu/types@0.1.38", "", {}, "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.28", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Ic44hnOtFIgravCunj1ifSoQPSUrkNiJuH9Mf6jr2jjoA74icqV8wU0KuadXeOR8zuIJMOoTv0GuQjZ9ZYNMeA=="], + + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + + "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001792", "", {}, "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + + "cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "core-js": ["core-js@3.29.1", "", {}, "sha512-+jwgnhg6cQxKYIIjGtAHq2nwUOolo9eoFZ4sHfUH09BLXBgxnH4gA0zEd+t+BO2cNB8idaBtZFcFTRjQJRJmAw=="], + + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "entities": ["entities@8.0.0", "", {}, "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-type": ["file-type@22.0.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + + "icu-minify": ["icu-minify@4.11.1", "", { "dependencies": { "@formatjs/icu-messageformat-parser": "^3.4.0" } }, "sha512-C0tsPVuvyNp+++qWJP+mty/KLLStjerOZqu3W1xWLJkChEDbDi9Taoj6blK7L/onxbuVzwgH6k9Sf+rOV6lOvw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "intl-messageformat": ["intl-messageformat@11.2.4", "", { "dependencies": { "@formatjs/fast-memoize": "3.1.4", "@formatjs/icu-messageformat-parser": "3.5.7" } }, "sha512-iKP6+uJXn+XcfRgYfGPE3+mqCoODV2vATrXDLo/YkYgIdelJHJPBEbc0GZThipAYPuk+8QJFiPgOfblU085ABg=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "jsdom": ["jsdom@29.1.1", "", { "dependencies": { "@asamuzakjp/css-color": "^5.1.11", "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.3", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.3.5", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "long": ["long@4.0.0", "", {}, "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="], + + "lru-cache": ["lru-cache@11.3.6", "", {}, "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A=="], + + "lucide-react": ["lucide-react@1.14.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "next": ["next@16.2.4", "", { "dependencies": { "@next/env": "16.2.4", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.4", "@next/swc-darwin-x64": "16.2.4", "@next/swc-linux-arm64-gnu": "16.2.4", "@next/swc-linux-arm64-musl": "16.2.4", "@next/swc-linux-x64-gnu": "16.2.4", "@next/swc-linux-x64-musl": "16.2.4", "@next/swc-win32-arm64-msvc": "16.2.4", "@next/swc-win32-x64-msvc": "16.2.4", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q=="], + + "next-intl": ["next-intl@4.11.1", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.8.1", "@parcel/watcher": "^2.4.1", "@swc/core": "^1.15.2", "icu-minify": "^4.11.1", "negotiator": "^1.0.0", "next-intl-swc-plugin-extractor": "^4.11.1", "po-parser": "^2.1.1", "use-intl": "^4.11.1" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-s32lFFLXkxrW6fy+4IVaGD5J8xPpbEDFLfBbXV73CTbHAGhOGMjYN4/rftdsKOQ44AnPhnZ5Et+ZNMr5tRpsqA=="], + + "next-intl-swc-plugin-extractor": ["next-intl-swc-plugin-extractor@4.11.1", "", {}, "sha512-jHKGij7NoYccy2y54+e/wHVMoRgNt4h/Kn0XS9c4GbKu3KgJyANLUN8sFcDixv6sqz4V2kh6CTWgrkIidQksUg=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-fetch": ["node-fetch@2.6.13", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + + "po-parser": ["po-parser@2.1.1", "", {}, "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ=="], + + "postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + + "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + + "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "regenerator-runtime": ["regenerator-runtime@0.13.11", "", {}, "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + + "rollup": ["rollup@4.60.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.3", "@rollup/rollup-android-arm64": "4.60.3", "@rollup/rollup-darwin-arm64": "4.60.3", "@rollup/rollup-darwin-x64": "4.60.3", "@rollup/rollup-freebsd-arm64": "4.60.3", "@rollup/rollup-freebsd-x64": "4.60.3", "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", "@rollup/rollup-linux-arm-musleabihf": "4.60.3", "@rollup/rollup-linux-arm64-gnu": "4.60.3", "@rollup/rollup-linux-arm64-musl": "4.60.3", "@rollup/rollup-linux-loong64-gnu": "4.60.3", "@rollup/rollup-linux-loong64-musl": "4.60.3", "@rollup/rollup-linux-ppc64-gnu": "4.60.3", "@rollup/rollup-linux-ppc64-musl": "4.60.3", "@rollup/rollup-linux-riscv64-gnu": "4.60.3", "@rollup/rollup-linux-riscv64-musl": "4.60.3", "@rollup/rollup-linux-s390x-gnu": "4.60.3", "@rollup/rollup-linux-x64-gnu": "4.60.3", "@rollup/rollup-linux-x64-musl": "4.60.3", "@rollup/rollup-openbsd-x64": "4.60.3", "@rollup/rollup-openharmony-arm64": "4.60.3", "@rollup/rollup-win32-arm64-msvc": "4.60.3", "@rollup/rollup-win32-ia32-msvc": "4.60.3", "@rollup/rollup-win32-x64-gnu": "4.60.3", "@rollup/rollup-win32-x64-msvc": "4.60.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "seedrandom": ["seedrandom@3.0.5", "", {}, "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg=="], + + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="], + + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="], + + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "tldts": ["tldts@7.0.30", "", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="], + + "tldts-core": ["tldts-core@7.0.30", "", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="], + + "token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="], + + "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + + "tslib": ["tslib@2.4.0", "", {}, "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ=="], + + "turbo": ["turbo@2.9.9", "", { "optionalDependencies": { "@turbo/darwin-64": "2.9.9", "@turbo/darwin-arm64": "2.9.9", "@turbo/linux-64": "2.9.9", "@turbo/linux-arm64": "2.9.9", "@turbo/windows-64": "2.9.9", "@turbo/windows-arm64": "2.9.9" }, "bin": { "turbo": "bin/turbo" } }, "sha512-3xfzXE/yTjhh0S5dIWlE+3E+J9A09REpLI1ZqVh2+HrNZoVzZn0pkvjiRgVK/Ev3PF9XnaTwCntTx+CADWXcyA=="], + + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + + "undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + + "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-intl": ["use-intl@4.11.1", "", { "dependencies": { "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", "icu-minify": "^4.11.1", "intl-messageformat": "^11.1.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" } }, "sha512-/dqWSqUSbVMzC+fdy7io8enhGYHeGeHK1bFhTLrp0ZblqdzY4FkE+tkffW6IfCauqaIA2/z4DQae4XEn93+raw=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "vite": ["vite@7.3.3", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA=="], + + "vitest": ["vitest@4.1.5", "", { "dependencies": { "@vitest/expect": "4.1.5", "@vitest/mocker": "4.1.5", "@vitest/pretty-format": "4.1.5", "@vitest/runner": "4.1.5", "@vitest/snapshot": "4.1.5", "@vitest/spy": "4.1.5", "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.5", "@vitest/browser-preview": "4.1.5", "@vitest/browser-webdriverio": "4.1.5", "@vitest/coverage-istanbul": "4.1.5", "@vitest/coverage-v8": "4.1.5", "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="], + + "yargs-parser": ["yargs-parser@20.2.9", "", {}, "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w=="], + + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zustand": ["zustand@5.0.13", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ=="], + + "@emnapi/runtime/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@swc/helpers/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@tensorflow/tfjs-core/@types/offscreencanvas": ["@types/offscreencanvas@2019.7.3", "", {}, "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A=="], + + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "@types/node-fetch/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="], + + "aria-hidden/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "bun-types/@types/node": ["@types/node@22.19.18", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ=="], + + "node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "react-remove-scroll/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "react-remove-scroll-bar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "react-style-singleton/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "use-callback-ref/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "use-sidecar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "vite/postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="], + + "@types/node-fetch/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "bun-types/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + } +} diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..55af992 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[install] +minimumReleaseAge = 259200 diff --git a/package.json b/package.json new file mode 100644 index 0000000..d166cf6 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "pien-studio", + "private": true, + "packageManager": "bun@1.1.38", + "workspaces": [ + "apps/*", + "packages/*" + ], + "scripts": { + "dev": "turbo run dev", + "build": "turbo run build", + "lint": "turbo run lint", + "test": "turbo run test", + "test:unit": "turbo run test", + "test:e2e": "playwright test", + "typecheck": "turbo run typecheck", + "format": "prettier --write ." + }, + "devDependencies": { + "@playwright/test": "^1.59.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^25.6.0", + "prettier": "^3.8.3", + "turbo": "^2.9.9", + "typescript": "^6.0.3", + "vitest": "^4.1.5" + } +} diff --git a/packages/config/package.json b/packages/config/package.json new file mode 100644 index 0000000..51da70a --- /dev/null +++ b/packages/config/package.json @@ -0,0 +1,6 @@ +{ + "name": "@pien-studio/config", + "version": "0.0.0", + "private": true, + "type": "module" +} diff --git a/packages/editor-core/package.json b/packages/editor-core/package.json new file mode 100644 index 0000000..c3d17af --- /dev/null +++ b/packages/editor-core/package.json @@ -0,0 +1,15 @@ +{ + "name": "@pien-studio/editor-core", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@pien-studio/types": "workspace:*" + } +} diff --git a/packages/editor-core/src/index.test.ts b/packages/editor-core/src/index.test.ts new file mode 100644 index 0000000..97f1974 --- /dev/null +++ b/packages/editor-core/src/index.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { + addLayer, + createProject, + moveLayer, + reorderLayer, + updateLayerTransform, +} from "./index"; + +describe("editor-core", () => { + it("creates project with defaults", () => { + const project = createProject("new"); + expect(project.title).toBe("new"); + expect(project.aspectRatio).toBe("4:5"); + expect(project.layers).toHaveLength(0); + }); + + it("adds a new layer and updates timestamp", () => { + const project = createProject("new"); + const updated = addLayer(project, { + id: "l1", + type: "image", + x: 0, + y: 0, + scale: 1, + rotation: 0, + opacity: 1, + }); + + expect(updated.layers).toHaveLength(1); + expect(updated.layers[0]?.id).toBe("l1"); + expect(updated.updatedAt >= project.updatedAt).toBe(true); + }); + + it("moves a layer by delta", () => { + const project = addLayer(createProject("move"), { + id: "l1", + type: "sticker", + x: 10, + y: 20, + scale: 1, + rotation: 0, + opacity: 1, + }); + + const moved = moveLayer(project, "l1", { dx: 15, dy: -5 }); + expect(moved.layers[0]?.x).toBe(25); + expect(moved.layers[0]?.y).toBe(15); + }); + + it("updates transform fields", () => { + const project = addLayer(createProject("transform"), { + id: "l1", + type: "text", + x: 0, + y: 0, + scale: 1, + rotation: 0, + opacity: 1, + }); + + const updated = updateLayerTransform(project, "l1", { scale: 1.35, rotation: 22 }); + expect(updated.layers[0]?.scale).toBe(1.35); + expect(updated.layers[0]?.rotation).toBe(22); + }); + + it("reorders layer to the front", () => { + const base = createProject("reorder"); + const withFirst = addLayer(base, { + id: "l1", + type: "text", + x: 0, + y: 0, + scale: 1, + rotation: 0, + opacity: 1, + }); + const withSecond = addLayer(withFirst, { + id: "l2", + type: "sticker", + x: 0, + y: 0, + scale: 1, + rotation: 0, + opacity: 1, + }); + + const reordered = reorderLayer(withSecond, "l1", 1); + expect(reordered.layers.map((layer) => layer.id)).toEqual(["l2", "l1"]); + }); +}); diff --git a/packages/editor-core/src/index.ts b/packages/editor-core/src/index.ts new file mode 100644 index 0000000..c8fb9df --- /dev/null +++ b/packages/editor-core/src/index.ts @@ -0,0 +1,212 @@ +import { + PRESET_CANVAS_SIZES, + ProjectFileSchema, + type AspectRatio, + type FaceBlurSettings, + type Layer, + type Project, + type ProjectFile, +} from "@pien-studio/types"; + +const DEFAULT_ASPECT: AspectRatio = "4:5"; + +function defaultCanvas(aspect: AspectRatio) { + const preset = PRESET_CANVAS_SIZES.find((p) => p.value === aspect) ?? PRESET_CANVAS_SIZES[1]; + return { width: preset.width, height: preset.height, unit: "px" as const }; +} + +export function createProject(title: string, aspect: AspectRatio = DEFAULT_ASPECT): Project { + const now = new Date().toISOString(); + return { + id: crypto.randomUUID(), + title, + createdAt: now, + updatedAt: now, + canvas: defaultCanvas(aspect), + aspectRatio: aspect, + layers: [], + }; +} + +function withUpdatedTimestamp(project: Project, layers: Layer[]): Project { + return { ...project, layers, updatedAt: new Date().toISOString() }; +} + +type Delta = { dx: number; dy: number }; +export type TransformPatch = { + x?: number; + y?: number; + width?: number; + height?: number; + scale?: number; + rotation?: number; + opacity?: number; + sourceUri?: string; + faceBlur?: FaceBlurSettings; +}; + +export type LayerFactoryOptions = { + id?: string; + name?: string; + sourceUri?: string; + x?: number; + y?: number; + width?: number; + height?: number; +}; + +export type EditorOperation = + | { type: "addLayer"; layer: Layer } + | { type: "removeLayer"; layerId: string } + | { type: "moveLayer"; layerId: string; delta: Delta } + | { type: "updateLayerTransform"; layerId: string; patch: TransformPatch } + | { type: "reorderLayer"; layerId: string; toIndex: number } + | { type: "setCanvasSize"; width: number; height: number; unit?: "px" | "in" | "cm" }; + +export function normalizeProject(project: Project): Project { + const normalizedLayers = project.layers.map((layer) => ({ + ...layer, + width: layer.width !== undefined ? Math.max(1, Math.round(layer.width)) : undefined, + height: layer.height !== undefined ? Math.max(1, Math.round(layer.height)) : undefined, + scale: Number.isFinite(layer.scale) ? layer.scale : 1, + rotation: Number.isFinite(layer.rotation) ? layer.rotation : 0, + opacity: Number.isFinite(layer.opacity) ? Math.max(0, Math.min(1, layer.opacity)) : 1, + faceBlur: + layer.faceBlur && Array.isArray(layer.faceBlur.regions) + ? { + method: layer.faceBlur.method, + amount: Math.max(4, Math.min(40, Math.round(layer.faceBlur.amount))), + regions: layer.faceBlur.regions.map((region) => ({ + x: Number.isFinite(region.x) ? region.x : 0, + y: Number.isFinite(region.y) ? region.y : 0, + width: Math.max(1, Number.isFinite(region.width) ? region.width : 1), + height: Math.max(1, Number.isFinite(region.height) ? region.height : 1), + sourceWidth: Number.isFinite(region.sourceWidth) ? region.sourceWidth : undefined, + sourceHeight: Number.isFinite(region.sourceHeight) ? region.sourceHeight : undefined, + censorColor: region.censorColor, + })), + censorColor: layer.faceBlur.censorColor, + } + : undefined, + })); + + return { + ...project, + canvas: { + width: Math.max(1, Math.round(project.canvas.width)), + height: Math.max(1, Math.round(project.canvas.height)), + unit: project.canvas.unit, + }, + layers: normalizedLayers, + }; +} + +export function addLayer(project: Project, layer: Layer): Project { + return withUpdatedTimestamp(project, [...project.layers, layer]); +} + +export function createLayer(type: Layer["type"], options: LayerFactoryOptions = {}): Layer { + return { + id: options.id ?? crypto.randomUUID(), + type, + name: options.name, + sourceUri: options.sourceUri, + x: options.x ?? 110, + y: options.y ?? 90, + width: options.width, + height: options.height, + scale: 1, + rotation: 0, + opacity: 1, + }; +} + +export function removeLayer(project: Project, layerId: string): Project { + const layers = project.layers.filter((layer) => layer.id !== layerId); + return withUpdatedTimestamp(project, layers); +} + +export function moveLayer(project: Project, layerId: string, delta: Delta): Project { + const layers = project.layers.map((layer) => { + if (layer.id !== layerId) return layer; + return { ...layer, x: layer.x + delta.dx, y: layer.y + delta.dy }; + }); + return withUpdatedTimestamp(project, layers); +} + +export function updateLayerTransform(project: Project, layerId: string, patch: TransformPatch): Project { + const layers = project.layers.map((layer) => (layer.id === layerId ? { ...layer, ...patch } : layer)); + return withUpdatedTimestamp(project, layers); +} + +export function reorderLayer(project: Project, layerId: string, toIndex: number): Project { + const fromIndex = project.layers.findIndex((l) => l.id === layerId); + if (fromIndex < 0) return project; + const layers = [...project.layers]; + const [picked] = layers.splice(fromIndex, 1); + if (!picked) return project; + const bounded = Math.max(0, Math.min(toIndex, layers.length)); + layers.splice(bounded, 0, picked); + return withUpdatedTimestamp(project, layers); +} + +export function setCanvasSize(project: Project, width: number, height: number, unit: "px" | "in" | "cm" = "px"): Project { + return { + ...project, + canvas: { + width: Math.max(1, Math.round(width)), + height: Math.max(1, Math.round(height)), + unit, + }, + updatedAt: new Date().toISOString(), + }; +} + +export function applyOperation(project: Project, operation: EditorOperation): Project { + switch (operation.type) { + case "addLayer": + return addLayer(project, operation.layer); + case "removeLayer": + return removeLayer(project, operation.layerId); + case "moveLayer": + return moveLayer(project, operation.layerId, operation.delta); + case "updateLayerTransform": + return updateLayerTransform(project, operation.layerId, operation.patch); + case "reorderLayer": + return reorderLayer(project, operation.layerId, operation.toIndex); + case "setCanvasSize": + return setCanvasSize(project, operation.width, operation.height, operation.unit); + default: + return project; + } +} + +export function serializeProjectFile(project: Project, options?: { checkpointCount?: number }): string { + const document: ProjectFile = { + format: "pien.project", + version: 1, + exportedAt: new Date().toISOString(), + app: { name: "pien.studio", platform: "web" }, + project, + assets: [], + history: { + checkpointCount: options?.checkpointCount ?? 0, + }, + }; + + return JSON.stringify(document, null, 2); +} + +export function parseProjectFile(raw: string): { ok: true; project: Project } | { ok: false; error: string } { + try { + const data = JSON.parse(raw) as unknown; + const parsedEnvelope = ProjectFileSchema.safeParse(data); + if (parsedEnvelope.success) { + return { ok: true, project: normalizeProject(parsedEnvelope.data.project) }; + } + + return { ok: false, error: "Invalid project format" }; + } catch { + return { ok: false, error: "Invalid JSON" }; + } +} diff --git a/packages/editor-core/tsconfig.json b/packages/editor-core/tsconfig.json new file mode 100644 index 0000000..9e25e6e --- /dev/null +++ b/packages/editor-core/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"] +} diff --git a/packages/editor-core/vitest.config.ts b/packages/editor-core/vitest.config.ts new file mode 100644 index 0000000..26892d6 --- /dev/null +++ b/packages/editor-core/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["**/*.test.ts"], + }, +}); diff --git a/packages/storage/package.json b/packages/storage/package.json new file mode 100644 index 0000000..8bfb83f --- /dev/null +++ b/packages/storage/package.json @@ -0,0 +1,20 @@ +{ + "name": "@pien-studio/storage", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@pien-studio/editor-core": "workspace:*", + "@pien-studio/types": "workspace:*" + }, + "devDependencies": { + "fake-indexeddb": "^6.2.2", + "vitest": "^4.1.5" + } +} diff --git a/packages/storage/src/index.test.ts b/packages/storage/src/index.test.ts new file mode 100644 index 0000000..6bc031a --- /dev/null +++ b/packages/storage/src/index.test.ts @@ -0,0 +1,168 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import "fake-indexeddb/auto"; +import { getProjectById, loadProjects } from "./index"; +import type { Project } from "@pien-studio/types"; + +const DB_NAME = "pien.db"; +const DB_VERSION = 4; +const PROJECTS_STORE = "projects"; +const ASSETS_STORE = "assets"; +const ASSETS_BY_HASH_INDEX = "byHash"; +const ASSET_LINKS_STORE = "assetLinks"; +const LINKS_BY_PROJECT_INDEX = "byProjectId"; +const LINKS_BY_ASSET_INDEX = "byAssetId"; + +function ensureSchema(db: IDBDatabase) { + if (!db.objectStoreNames.contains(PROJECTS_STORE)) { + db.createObjectStore(PROJECTS_STORE, { keyPath: "id" }); + } + + if (!db.objectStoreNames.contains(ASSETS_STORE)) { + const assetsStore = db.createObjectStore(ASSETS_STORE, { keyPath: "id" }); + assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", { unique: false }); + } + + if (!db.objectStoreNames.contains(ASSET_LINKS_STORE)) { + const linksStore = db.createObjectStore(ASSET_LINKS_STORE, { keyPath: "id" }); + linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", { unique: false }); + linksStore.createIndex(LINKS_BY_ASSET_INDEX, "assetId", { unique: false }); + } +} + +function requestToPromise(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function seedProject(project: Project): Promise { + const openRequest = indexedDB.open(DB_NAME, DB_VERSION); + openRequest.onupgradeneeded = () => ensureSchema(openRequest.result); + const db = await requestToPromise(openRequest); + const tx = db.transaction(PROJECTS_STORE, "readwrite"); + await requestToPromise(tx.objectStore(PROJECTS_STORE).put(project)); + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + db.close(); +} + +async function readRawProject(projectId: string): Promise { + const openRequest = indexedDB.open(DB_NAME, DB_VERSION); + openRequest.onupgradeneeded = () => ensureSchema(openRequest.result); + const db = await requestToPromise(openRequest); + const tx = db.transaction(PROJECTS_STORE, "readonly"); + const record = (await requestToPromise(tx.objectStore(PROJECTS_STORE).get(projectId))) as Project | undefined; + db.close(); + return record; +} + +function makeMalformedProject(id: string): Project { + const now = new Date().toISOString(); + return { + id, + title: "seed", + createdAt: now, + updatedAt: now, + aspectRatio: "4:5", + canvas: { width: 1200, height: 1200, unit: "px" }, + layers: [ + { + id: "layer-1", + type: "image", + x: 10, + y: 10, + width: 10.4, + height: 11.6, + scale: 0, + rotation: 720.4, + opacity: 0.75, + }, + ], + }; +} + +function makeLegacyFaceBlurProject(id: string): Project { + const now = new Date().toISOString(); + return { + id, + title: "legacy", + createdAt: now, + updatedAt: now, + aspectRatio: "4:5", + canvas: { width: 1200, height: 1500, unit: "px" }, + layers: [ + { + id: "image-legacy", + type: "image", + x: 30, + y: 40, + width: 600, + height: 800, + scale: 1, + rotation: 0, + opacity: 1, + faceBlur: { + method: "gaussian", + amount: 14, + regions: [{ x: 120, y: 160, width: 180, height: 200 }], + }, + }, + ], + }; +} + +describe("storage read flows", () => { + beforeAll(async () => { + await new Promise((resolve, reject) => { + const req = indexedDB.deleteDatabase(DB_NAME); + req.onsuccess = () => resolve(); + req.onerror = () => reject(req.error); + req.onblocked = () => resolve(); + }); + }); + + it("loadProjects hydrates normalized values without persisting writes", async () => { + const source = makeMalformedProject("project-load"); + await seedProject(source); + + const byId = await getProjectById(source.id); + expect(byId?.canvas.height).toBe(1200); + expect(byId?.layers[0]?.height).toBe(12); + + const rawAfterById = await readRawProject(source.id); + expect(rawAfterById?.layers[0]?.height).toBe(11.6); + + const loaded = await loadProjects(); + expect(loaded).toHaveLength(1); + expect(loaded[0]?.canvas.width).toBe(1200); + expect(loaded[0]?.layers[0]?.width).toBe(10); + expect(loaded[0]?.layers[0]?.height).toBe(12); + + const raw = await readRawProject(source.id); + expect(raw?.canvas.width).toBe(1200); + expect(raw?.layers[0]?.width).toBe(10.4); + expect(raw?.layers[0]?.height).toBe(11.6); + }); + + it("loads legacy projects without source dimensions through storage and normalize flow", async () => { + const source = makeLegacyFaceBlurProject("project-legacy-faceblur"); + await seedProject(source); + + const loaded = await getProjectById(source.id); + const region = loaded?.layers[0]?.faceBlur?.regions[0]; + expect(region).toBeDefined(); + expect(region?.x).toBe(120); + expect(region?.sourceWidth).toBeUndefined(); + expect(region?.sourceHeight).toBeUndefined(); + + const listed = await loadProjects(); + const listedRegion = listed[0]?.layers[0]?.faceBlur?.regions[0]; + expect(listedRegion?.width).toBe(180); + expect(listedRegion?.sourceWidth).toBeUndefined(); + expect(listedRegion?.sourceHeight).toBeUndefined(); + }, 15_000); +}); diff --git a/packages/storage/src/index.ts b/packages/storage/src/index.ts new file mode 100644 index 0000000..09c6eaa --- /dev/null +++ b/packages/storage/src/index.ts @@ -0,0 +1,442 @@ +import { normalizeProject } from "@pien-studio/editor-core"; +import { ProjectSchema, type Layer, type Project } from "@pien-studio/types"; + +const DB_NAME = "pien.db"; +const DB_VERSION = 4; +const PROJECTS_STORE = "projects"; +const ASSETS_STORE = "assets"; +const ASSETS_BY_HASH_INDEX = "byHash"; +const ASSET_LINKS_STORE = "assetLinks"; +const LINKS_BY_PROJECT_INDEX = "byProjectId"; +const LINKS_BY_ASSET_INDEX = "byAssetId"; + +type AssetRecord = { + id: string; + mimeType: string; + blob: Blob; + hash: string; + createdAt: string; + updatedAt: string; +}; + +type AssetLinkRecord = { + id: string; + projectId: string; + layerId: string; + assetId: string; + updatedAt: string; +}; + +const objectUrlByAssetId = new Map(); + +function getIndexedDb(): IDBFactory | null { + if (typeof indexedDB === "undefined") return null; + return indexedDB; +} + +function requestToPromise(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function openDatabase(): Promise { + const idb = getIndexedDb(); + if (!idb) return Promise.resolve(null); + + return new Promise((resolve, reject) => { + const request = idb.open(DB_NAME, DB_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + + if (!db.objectStoreNames.contains(PROJECTS_STORE)) { + db.createObjectStore(PROJECTS_STORE, { keyPath: "id" }); + } + + if (!db.objectStoreNames.contains(ASSETS_STORE)) { + const assetsStore = db.createObjectStore(ASSETS_STORE, { keyPath: "id" }); + assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", { unique: false }); + } else { + const tx = request.transaction; + if (tx) { + const assetsStore = tx.objectStore(ASSETS_STORE); + if (!assetsStore.indexNames.contains(ASSETS_BY_HASH_INDEX)) { + assetsStore.createIndex(ASSETS_BY_HASH_INDEX, "hash", { unique: false }); + } + } + } + + if (!db.objectStoreNames.contains(ASSET_LINKS_STORE)) { + const linksStore = db.createObjectStore(ASSET_LINKS_STORE, { keyPath: "id" }); + linksStore.createIndex(LINKS_BY_PROJECT_INDEX, "projectId", { unique: false }); + linksStore.createIndex(LINKS_BY_ASSET_INDEX, "assetId", { unique: false }); + } + }; + + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +async function dataUrlToBlob(dataUrl: string): Promise { + const response = await fetch(dataUrl); + return response.blob(); +} + +async function hashBlob(blob: Blob): Promise { + const buffer = await blob.arrayBuffer(); + if (typeof crypto !== "undefined" && crypto.subtle) { + const digest = await crypto.subtle.digest("SHA-256", buffer); + const bytes = Array.from(new Uint8Array(digest)); + return bytes.map((b) => b.toString(16).padStart(2, "0")).join(""); + } + return Array.from(new Uint8Array(buffer)).slice(0, 64).join("-"); +} + +function makeLinkId(projectId: string, layerId: string): string { + return `${projectId}:${layerId}`; +} + +function isBinaryLayer(layer: Layer): boolean { + return layer.type === "image" || layer.type === "sticker"; +} + +function inferMimeType(layer: Layer): string { + if (layer.sourceUri?.startsWith("data:image/png")) return "image/png"; + if (layer.sourceUri?.startsWith("data:image/webp")) return "image/webp"; + return "image/jpeg"; +} + +async function putAssetFromLayer(assetStore: IDBObjectStore, layer: Layer): Promise { + if (!isBinaryLayer(layer)) return layer.assetId; + + if (layer.sourceUri?.startsWith("data:image/")) { + const blob = await dataUrlToBlob(layer.sourceUri); + const hash = await hashBlob(blob); + const hashIndex = assetStore.index(ASSETS_BY_HASH_INDEX); + const matching = (await requestToPromise(hashIndex.getAll(hash))) as AssetRecord[]; + const reusable = matching.find((asset) => asset.blob.size === blob.size); + const now = new Date().toISOString(); + const id = layer.assetId ?? reusable?.id ?? crypto.randomUUID(); + + await requestToPromise( + assetStore.put({ + id, + mimeType: blob.type || inferMimeType(layer), + blob, + hash, + createdAt: reusable?.createdAt ?? now, + updatedAt: now, + } satisfies AssetRecord), + ); + + return id; + } + + return layer.assetId; +} + +type PreparedLayerAsset = { + layerIndex: number; + blob: Blob; + hash: string; + mimeType: string; +}; + +async function prepareLayerAssets(layers: Layer[]): Promise { + const prepared: PreparedLayerAsset[] = []; + for (let index = 0; index < layers.length; index += 1) { + const layer = layers[index]; + if (!layer || !isBinaryLayer(layer) || !layer.sourceUri?.startsWith("data:image/")) continue; + const blob = await dataUrlToBlob(layer.sourceUri); + const hash = await hashBlob(blob); + prepared.push({ + layerIndex: index, + blob, + hash, + mimeType: blob.type || inferMimeType(layer), + }); + } + return prepared; +} + +async function syncLinksForProject(db: IDBDatabase, project: Project): Promise> { + const tx = db.transaction(ASSET_LINKS_STORE, "readwrite"); + const store = tx.objectStore(ASSET_LINKS_STORE); + const byProject = store.index(LINKS_BY_PROJECT_INDEX); + const existing = (await requestToPromise(byProject.getAll(project.id))) as AssetLinkRecord[]; + const existingMap = new Map(existing.map((link) => [link.id, link])); + const now = new Date().toISOString(); + + const referenced = new Set(); + for (const layer of project.layers) { + if (!layer.assetId || !isBinaryLayer(layer)) continue; + const id = makeLinkId(project.id, layer.id); + referenced.add(layer.assetId); + await requestToPromise( + store.put({ + id, + projectId: project.id, + layerId: layer.id, + assetId: layer.assetId, + updatedAt: now, + } satisfies AssetLinkRecord), + ); + existingMap.delete(id); + } + + const removedAssetIds = new Set(); + for (const stale of existingMap.values()) { + removedAssetIds.add(stale.assetId); + await requestToPromise(store.delete(stale.id)); + } + + await new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + + for (const id of removedAssetIds) referenced.add(id); + return referenced; +} + +async function cleanupOrphansInternal(db: IDBDatabase, candidateAssetIds?: Iterable): Promise { + const assetsTx = db.transaction([ASSETS_STORE, ASSET_LINKS_STORE], "readwrite"); + const assetsStore = assetsTx.objectStore(ASSETS_STORE); + const linksStore = assetsTx.objectStore(ASSET_LINKS_STORE); + const byAsset = linksStore.index(LINKS_BY_ASSET_INDEX); + + const candidates = candidateAssetIds + ? Array.from(new Set(candidateAssetIds)).filter(Boolean) + : ((await requestToPromise(assetsStore.getAllKeys())) as string[]); + + let removed = 0; + for (const assetId of candidates) { + const links = (await requestToPromise(byAsset.getAll(assetId))) as AssetLinkRecord[]; + if (links.length > 0) continue; + await requestToPromise(assetsStore.delete(assetId)); + const url = objectUrlByAssetId.get(assetId); + if (url?.startsWith("blob:") && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { + URL.revokeObjectURL(url); + } + objectUrlByAssetId.delete(assetId); + removed += 1; + } + + await new Promise((resolve, reject) => { + assetsTx.oncomplete = () => resolve(); + assetsTx.onerror = () => reject(assetsTx.error); + assetsTx.onabort = () => reject(assetsTx.error); + }); + + return removed; +} + +async function persistProject(db: IDBDatabase, project: Project): Promise { + const normalized = normalizeProject(project); + const preparedAssets = await prepareLayerAssets(normalized.layers); + const assetTx = db.transaction(ASSETS_STORE, "readwrite"); + const assetStore = assetTx.objectStore(ASSETS_STORE); + const layers = [...normalized.layers]; + const hashIndex = assetStore.index(ASSETS_BY_HASH_INDEX); + + for (const prepared of preparedAssets) { + const layer = layers[prepared.layerIndex]; + if (!layer) continue; + const matching = (await requestToPromise(hashIndex.getAll(prepared.hash))) as AssetRecord[]; + const reusable = matching.find((asset) => asset.blob.size === prepared.blob.size); + const now = new Date().toISOString(); + const id = layer.assetId ?? reusable?.id ?? crypto.randomUUID(); + + await requestToPromise( + assetStore.put({ + id, + mimeType: prepared.mimeType, + blob: prepared.blob, + hash: prepared.hash, + createdAt: reusable?.createdAt ?? now, + updatedAt: now, + } satisfies AssetRecord), + ); + + layers[prepared.layerIndex] = { + ...layer, + assetId: id, + sourceUri: undefined, + }; + } + + for (let index = 0; index < layers.length; index += 1) { + const layer = layers[index]; + if (!layer || !isBinaryLayer(layer)) continue; + const assetId = await putAssetFromLayer(assetStore, layer); + if (!assetId) continue; + layers[index] = { + ...layer, + assetId, + sourceUri: layer.sourceUri?.startsWith("data:image/") ? undefined : layer.sourceUri, + }; + } + + await new Promise((resolve, reject) => { + assetTx.oncomplete = () => resolve(); + assetTx.onerror = () => reject(assetTx.error); + assetTx.onabort = () => reject(assetTx.error); + }); + + const persisted = { ...normalized, layers }; + const candidateAssets = await syncLinksForProject(db, persisted); + + const projectTx = db.transaction(PROJECTS_STORE, "readwrite"); + await requestToPromise(projectTx.objectStore(PROJECTS_STORE).put(persisted)); + await new Promise((resolve, reject) => { + projectTx.oncomplete = () => resolve(); + projectTx.onerror = () => reject(projectTx.error); + projectTx.onabort = () => reject(projectTx.error); + }); + + await cleanupOrphansInternal(db, candidateAssets); + return persisted; +} + +async function hydrateProject(db: IDBDatabase, project: Project): Promise { + const tx = db.transaction(ASSETS_STORE, "readonly"); + const store = tx.objectStore(ASSETS_STORE); + + const layers = await Promise.all( + project.layers.map(async (layer) => { + if (!layer.assetId) return layer; + const record = (await requestToPromise(store.get(layer.assetId))) as AssetRecord | undefined; + if (!record?.blob) return layer; + const existing = objectUrlByAssetId.get(layer.assetId); + if (existing) return { ...layer, sourceUri: existing }; + if (typeof URL !== "undefined" && typeof URL.createObjectURL === "function") { + const objectUrl = URL.createObjectURL(record.blob); + objectUrlByAssetId.set(layer.assetId, objectUrl); + return { ...layer, sourceUri: objectUrl }; + } + return layer; + }), + ); + + return { ...project, layers }; +} + +export function releaseProjectObjectUrls(project: Project, keepAssetIds?: Iterable): void { + const keep = keepAssetIds ? new Set(Array.from(keepAssetIds).filter(Boolean)) : null; + for (const layer of project.layers) { + if (!layer.assetId) continue; + if (keep?.has(layer.assetId)) continue; + const url = objectUrlByAssetId.get(layer.assetId); + if (!url) continue; + if (url.startsWith("blob:") && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") { + URL.revokeObjectURL(url); + } + objectUrlByAssetId.delete(layer.assetId); + } +} + +export async function cleanupOrphanAssets(): Promise { + const db = await openDatabase(); + if (!db) return 0; + return cleanupOrphansInternal(db); +} + +export function startAssetCleanupJob(intervalMs = 45_000): () => void { + if (typeof window === "undefined") return () => undefined; + const id = window.setInterval(() => { + void cleanupOrphanAssets(); + }, intervalMs); + return () => window.clearInterval(id); +} + +export async function saveProjects(projects: Project[]): Promise { + const db = await openDatabase(); + if (!db) return; + + const existing = await loadProjects(); + const keep = new Set(projects.map((project) => project.id)); + for (const project of existing) { + if (!keep.has(project.id)) { + await deleteProject(project.id); + } + } + + for (const project of projects) { + await persistProject(db, project); + } +} + +export async function loadProjects(): Promise { + const db = await openDatabase(); + if (!db) return []; + + const tx = db.transaction(PROJECTS_STORE, "readonly"); + const records = await requestToPromise(tx.objectStore(PROJECTS_STORE).getAll()); + const parsed = (records as unknown[]) + .map((record) => ProjectSchema.safeParse(record)) + .filter((result) => result.success) + .map((result) => normalizeProject(result.data)); + + const hydrated = await Promise.all(parsed.map((project) => hydrateProject(db, project))); + void cleanupOrphansInternal(db); + return hydrated.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); +} + +export async function getProjectById(projectId: string): Promise { + const db = await openDatabase(); + if (!db) return null; + + const tx = db.transaction(PROJECTS_STORE, "readonly"); + const record = await requestToPromise(tx.objectStore(PROJECTS_STORE).get(projectId)); + const parsed = ProjectSchema.safeParse(record); + if (!parsed.success) return null; + return hydrateProject(db, normalizeProject(parsed.data)); +} + +export async function upsertProject(project: Project): Promise { + const db = await openDatabase(); + if (!db) return; + await persistProject(db, { ...project, updatedAt: new Date().toISOString() }); +} + +export async function deleteProject(projectId: string): Promise { + const db = await openDatabase(); + if (!db) return; + + const linksTx = db.transaction(ASSET_LINKS_STORE, "readwrite"); + const linksStore = linksTx.objectStore(ASSET_LINKS_STORE); + const byProject = linksStore.index(LINKS_BY_PROJECT_INDEX); + const links = (await requestToPromise(byProject.getAll(projectId))) as AssetLinkRecord[]; + for (const link of links) { + await requestToPromise(linksStore.delete(link.id)); + } + await new Promise((resolve, reject) => { + linksTx.oncomplete = () => resolve(); + linksTx.onerror = () => reject(linksTx.error); + linksTx.onabort = () => reject(linksTx.error); + }); + + const projectTx = db.transaction(PROJECTS_STORE, "readwrite"); + await requestToPromise(projectTx.objectStore(PROJECTS_STORE).delete(projectId)); + await cleanupOrphansInternal(db, links.map((link) => link.assetId)); +} + +export async function duplicateProject(projectId: string): Promise { + const source = await getProjectById(projectId); + if (!source) return null; + const now = new Date().toISOString(); + const copy: Project = { + ...source, + id: crypto.randomUUID(), + title: `${source.title} Copy`, + createdAt: now, + updatedAt: now, + layers: source.layers.map((layer) => ({ ...layer, id: crypto.randomUUID() })), + }; + await upsertProject(copy); + return getProjectById(copy.id); +} diff --git a/packages/storage/tsconfig.json b/packages/storage/tsconfig.json new file mode 100644 index 0000000..9e25e6e --- /dev/null +++ b/packages/storage/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"] +} diff --git a/packages/storage/vitest.config.ts b/packages/storage/vitest.config.ts new file mode 100644 index 0000000..c13f7bd --- /dev/null +++ b/packages/storage/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000..196a17a --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,15 @@ +{ + "name": "@pien-studio/types", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "zod": "^4.4.3" + } +} diff --git a/packages/types/src/index.test.ts b/packages/types/src/index.test.ts new file mode 100644 index 0000000..e70c5e7 --- /dev/null +++ b/packages/types/src/index.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { DeviceSessionSchema, ProjectFileSchema, ProjectSchema } from "./index"; + +describe("types schemas", () => { + it("accepts valid device session payload", () => { + const result = DeviceSessionSchema.safeParse({ + deviceId: "abcd1234", + locale: "ja", + }); + expect(result.success).toBe(true); + }); + + it("rejects unknown locale", () => { + const result = DeviceSessionSchema.safeParse({ + deviceId: "abcd1234", + locale: "fr", + }); + expect(result.success).toBe(false); + }); + + it("validates project shape", () => { + const result = ProjectSchema.safeParse({ + id: "p1", + title: "test", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + aspectRatio: "4:5", + canvas: { width: 1080, height: 1350, unit: "px" }, + layers: [], + }); + expect(result.success).toBe(true); + }); + + it("validates versioned project file envelope", () => { + const now = new Date().toISOString(); + const result = ProjectFileSchema.safeParse({ + format: "pien.project", + version: 1, + exportedAt: now, + app: { name: "pien.studio", platform: "web" }, + project: { + id: "p1", + title: "test", + createdAt: now, + updatedAt: now, + aspectRatio: "4:5", + canvas: { width: 1080, height: 1350, unit: "px" }, + layers: [], + }, + assets: [], + history: { checkpointCount: 0 }, + }); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 0000000..51878f2 --- /dev/null +++ b/packages/types/src/index.ts @@ -0,0 +1,122 @@ +import { z } from "zod"; + +export const LayerTypeSchema = z.enum(["image", "text", "sticker"]); + +export const FaceBlurMethodSchema = z.enum(["gaussian", "pixelate", "censor"]); + +export const FaceBlurRegionSchema = z.object({ + x: z.number(), + y: z.number(), + width: z.number(), + height: z.number(), + sourceWidth: z.number().optional(), + sourceHeight: z.number().optional(), + censorColor: z.string().optional(), +}); + +export const FaceBlurSettingsSchema = z.object({ + method: FaceBlurMethodSchema, + amount: z.number().int().min(4).max(40), + regions: z.array(FaceBlurRegionSchema), + censorColor: z.string().optional(), +}); + +export const LayerSchema = z.object({ + id: z.string(), + type: LayerTypeSchema, + name: z.string().optional(), + assetId: z.string().optional(), + sourceUri: z.string().optional(), + faceBlur: FaceBlurSettingsSchema.optional(), + x: z.number(), + y: z.number(), + width: z.number().optional(), + height: z.number().optional(), + scale: z.number().default(1), + rotation: z.number().default(0), + opacity: z.number().min(0).max(1).default(1), +}); + +export const AspectRatioSchema = z.enum([ + "1:1", // square feed + "4:5", // portrait 4:5 + "9:16", // story / vertical + "16:9", // widescreen + "4:3", // classic photo + "3:2", // landscape photo + "free", // custom +]); + +export type AspectRatio = z.infer; + +export const CanvasSizeSchema = z.object({ + width: z.number().int().positive(), + height: z.number().int().positive(), + unit: z.enum(["px", "in", "cm"]).default("px"), +}); + +export const ProjectSchema = z.object({ + id: z.string(), + title: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + canvas: CanvasSizeSchema, + aspectRatio: AspectRatioSchema, + layers: z.array(LayerSchema), +}); + +export const PresetAspectRatioSchema = z.object({ + label: z.string(), + value: AspectRatioSchema, + width: z.number(), + height: z.number(), +}); + +export type PresetAspectRatio = z.infer; + +export const PRESET_CANVAS_SIZES: PresetAspectRatio[] = [ + { label: "Square (1:1)", value: "1:1", width: 1080, height: 1080 }, + { label: "Portrait (4:5)", value: "4:5", width: 1080, height: 1350 }, + { label: "Story (9:16)", value: "9:16", width: 1080, height: 1920 }, + { label: "Widescreen (16:9)", value: "16:9", width: 1920, height: 1080 }, + { label: "Photo (4:3)", value: "4:3", width: 1440, height: 1080 }, + { label: "Classic (3:2)", value: "3:2", width: 1620, height: 1080 }, +]; + +export const DeviceSessionSchema = z.object({ + deviceId: z.string().min(4), + locale: z.enum(["en", "th", "ja"]), +}); + +export const ProjectFileV1Schema = z.object({ + format: z.literal("pien.project"), + version: z.literal(1), + exportedAt: z.string(), + app: z.object({ + name: z.literal("pien.studio"), + platform: z.enum(["web", "mobile", "desktop"]).default("web"), + }), + project: ProjectSchema, + assets: z.array( + z.object({ + id: z.string(), + kind: z.enum(["image", "sticker", "font", "frame"]), + name: z.string(), + uri: z.string(), + checksum: z.string().optional(), + }), + ), + history: z.object({ + checkpointCount: z.number().int().nonnegative().default(0), + }), +}); + +export const ProjectFileSchema = ProjectFileV1Schema; + +export type Project = z.infer; +export type Layer = z.infer; +export type LayerType = z.infer; +export type FaceBlurMethod = z.infer; +export type FaceBlurRegion = z.infer; +export type FaceBlurSettings = z.infer; +export type ProjectFile = z.infer; diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 0000000..9e25e6e --- /dev/null +++ b/packages/types/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts"] +} diff --git a/packages/types/vitest.config.ts b/packages/types/vitest.config.ts new file mode 100644 index 0000000..26892d6 --- /dev/null +++ b/packages/types/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["**/*.test.ts"], + }, +}); diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..91c6c6a --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1,14 @@ +{ + "name": "@pien-studio/ui", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "class-variance-authority": "^0.7.1" + } +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts new file mode 100644 index 0000000..d1ea1d7 --- /dev/null +++ b/packages/ui/src/index.ts @@ -0,0 +1 @@ +export * from "./tokens"; diff --git a/packages/ui/src/tokens.ts b/packages/ui/src/tokens.ts new file mode 100644 index 0000000..42c482d --- /dev/null +++ b/packages/ui/src/tokens.ts @@ -0,0 +1,13 @@ +export const themeTokens = { + radius: { + sm: "10px", + md: "16px", + lg: "24px", + }, + spacing: { + xs: "4px", + sm: "8px", + md: "12px", + lg: "20px", + }, +}; diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json new file mode 100644 index 0000000..7af87be --- /dev/null +++ b/packages/ui/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5d09cb1 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "tests/e2e", + timeout: 30_000, + use: { + baseURL: "http://127.0.0.1:3000", + trace: "on-first-retry", + }, + webServer: { + command: "bun run dev", + cwd: "apps/web", + url: "http://127.0.0.1:3000", + reuseExistingServer: true, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/tests/e2e/editor.spec.ts b/tests/e2e/editor.spec.ts new file mode 100644 index 0000000..0ac8aa3 --- /dev/null +++ b/tests/e2e/editor.spec.ts @@ -0,0 +1,103 @@ +import { expect, test } from "@playwright/test"; +import { writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + localStorage.clear(); + if (typeof indexedDB !== "undefined") { + indexedDB.deleteDatabase("pien.db"); + } + }); +}); + +test("tools switch modes and action tools add layers", async ({ page }) => { + await page.goto("/editor/new"); + + await page.locator('button[title="Text"]').click(); + await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(0); + + await page.locator('button[title="Face"]').click(); + await expect(page.getByText("Face Tool")).toBeVisible(); + + await page.locator('button[title="Pointer"]').click(); + await expect(page.getByText("Face Tool")).toHaveCount(0); +}); + +test("undo and redo restore layer changes", async ({ page }) => { + await page.goto("/editor/new"); + + await page.locator('button[title="Text"]').click(); + await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(0); + + await page.locator('header button[title="Undo"]').click(); + await expect(page.getByText("No layers yet. Add text or image.")).toBeVisible(); + + await page.locator('header button[title="Redo"]').click(); + await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(0); +}); + +test("exports and imports project file", async ({ page }) => { + await page.goto("/editor/new"); + await page.locator('button[title="Text"]').click(); + + await page.getByRole("button", { name: "File", exact: true }).hover(); + const downloadPromise = page.waitForEvent("download"); + await page.getByRole("button", { name: "Export Project File" }).click(); + const download = await downloadPromise; + const suggested = download.suggestedFilename(); + expect(suggested.endsWith(".pien.json")).toBe(true); + + const filePath = test.info().outputPath("import-project.pien.json"); + await writeFile( + filePath, + JSON.stringify( + { + format: "pien.project", + version: 1, + exportedAt: new Date().toISOString(), + app: { name: "pien.studio", platform: "web" }, + project: { + id: randomUUID(), + title: "Imported Project", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + canvas: { width: 1080, height: 1350, unit: "px" }, + aspectRatio: "4:5", + layers: [ + { + id: randomUUID(), + type: "text", + x: 110, + y: 90, + scale: 1, + rotation: 0, + opacity: 1, + }, + ], + }, + assets: [], + history: { checkpointCount: 0 }, + }, + null, + 2, + ), + "utf8", + ); + + await page.goto("/"); + await page.locator('input[type="file"][accept*=".json"]').setInputFiles(filePath); + + await expect(page).toHaveURL(/\/editor\//); + await expect(page.getByText("No layers yet. Add text or image.")).toHaveCount(0); +}); + +test("persists project list to indexeddb across reload", async ({ page }) => { + await page.goto("/"); + await page.getByRole("button", { name: "New Project" }).click(); + + await page.goto("/"); + await page.reload(); + + await expect(page.getByRole("button", { name: "Open" }).first()).toBeVisible(); +}); diff --git a/tests/e2e/home.spec.ts b/tests/e2e/home.spec.ts new file mode 100644 index 0000000..f49473f --- /dev/null +++ b/tests/e2e/home.spec.ts @@ -0,0 +1,7 @@ +import { expect, test } from "@playwright/test"; + +test("home page loads and shows mood presets", async ({ page }) => { + await page.goto("/"); + await expect(page.getByText("Project Hub")).toBeVisible(); + await expect(page.getByRole("button", { name: "New Project" })).toBeVisible(); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..ec53a8f --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "jsx": "preserve", + "baseUrl": ".", + "paths": { + "@pien-studio/types": ["packages/types/src/index.ts"], + "@pien-studio/editor-core": ["packages/editor-core/src/index.ts"], + "@pien-studio/storage": ["packages/storage/src/index.ts"], + "@pien-studio/ui/*": ["packages/ui/src/*"] + } + } +} diff --git a/turbo.json b/turbo.json new file mode 100644 index 0000000..46f9392 --- /dev/null +++ b/turbo.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://turborepo.com/schema.json", + "tasks": { + "dev": { + "cache": false, + "persistent": true + }, + "build": { + "dependsOn": ["^build"], + "outputs": [".next/**", "dist/**"] + }, + "lint": { + "dependsOn": ["^lint"] + }, + "typecheck": { + "dependsOn": ["^typecheck"] + }, + "test": { + "dependsOn": ["^test"] + } + } +}