feat: initial app

This commit is contained in:
2026-05-10 03:06:54 +07:00
parent 23be44dbe0
commit d5b92cc9a4
95 changed files with 8104 additions and 0 deletions
+335
View File
@@ -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<HTMLCanvasElement | null>(null);
const imageRef = React.useRef<HTMLImageElement | null>(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 <canvas ref={canvasRef} width={Math.max(1, Math.round(width))} height={Math.max(1, Math.round(height))} className="pointer-events-none h-full w-full rounded object-cover" />;
}
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 (
<div
ref={containerRef}
className="relative overflow-hidden"
style={{ width: "100%", height: "100%", touchAction: "none", userSelect: "none", cursor: tool === "hand" || isSpacePan ? "grab" : "default" }}
onPointerDown={(e) => {
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}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: canvasWidth,
height: canvasHeight,
willChange: "transform",
transform: `translate(${viewport.x}px, ${viewport.y}px) scale(${viewport.scale})`,
transformOrigin: "0 0",
boxShadow: "0 0 0 1px var(--color-accent-strong)",
background: isDark ? "#17181b" : "#ffffff",
}}
>
{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 (
<div
key={layer.id}
className="absolute"
style={{
left: layer.x,
top: layer.y,
width: layerWidth,
height: layerHeight,
transform: `rotate(${layer.rotation}deg)`,
opacity: layer.opacity,
cursor: "move",
border: isSelected ? "2px solid var(--color-accent-strong)" : "1px dashed transparent",
outline: isSelected ? "2px solid var(--color-accent-strong)" : "none",
outlineOffset: "2px",
zIndex: idx,
}}
onPointerDown={(e) => 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) ? (
<BlurredImageLayer
layer={layer}
width={layerWidth ?? 1}
height={layerHeight ?? 1}
faceBlurOverride={faceBlurPreview && faceBlurPreview.layerId === layer.id ? faceBlurPreview : null}
/>
) : (
<NextImage
src={layer.sourceUri}
alt={layer.name ?? t("editor.layer")}
width={layerWidth ?? 1}
height={layerHeight ?? 1}
unoptimized
className="pointer-events-none h-full w-full rounded object-cover"
draggable={false}
/>
)
) : (
<div
className={`flex items-center justify-center rounded border px-2 py-1 text-xs font-semibold ${
isSelected
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
: isDark
? "border-white/20 bg-[#2d3036] text-[#d7dae0]"
: "border-black/15 bg-white text-[#1f2430]"
}`}
>
{layer.type}
</div>
)}
{tool === "face" && faceOverlayLayerId === layer.id
? faceDetections.map((face, index) => (
<div
key={`${layer.id}-face-${index}`}
className="absolute pointer-events-none border-2 border-[#00d2ff]"
style={{
left: face.x,
top: face.y,
width: face.width,
height: face.height,
zIndex: layers.length + 20 + index,
boxShadow: "0 0 0 1px rgba(0,0,0,0.35)",
}}
/>
))
: null}
{isSelected && tool === "pointer" && isImage && onResizeLayer ? (
<>
<button
type="button"
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
style={{ width: handleSizePx, height: handleSizePx, top: -handleSize / 2, left: -handleSize / 2 }}
onPointerDown={(e) => onResizeHandleDown(e, layer, "tl")}
title={t("editor.resize")}
/>
<button
type="button"
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
style={{ width: handleSizePx, height: handleSizePx, top: -handleSize / 2, right: -handleSize / 2 }}
onPointerDown={(e) => onResizeHandleDown(e, layer, "tr")}
title={t("editor.resize")}
/>
<button
type="button"
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
style={{ width: handleSizePx, height: handleSizePx, bottom: -handleSize / 2, left: -handleSize / 2 }}
onPointerDown={(e) => onResizeHandleDown(e, layer, "bl")}
title={t("editor.resize")}
/>
<button
type="button"
className="absolute rounded-full border-2 border-white/80 bg-[var(--color-accent-strong)] shadow"
style={{ width: handleSizePx, height: handleSizePx, bottom: -handleSize / 2, right: -handleSize / 2 }}
onPointerDown={(e) => onResizeHandleDown(e, layer, "br")}
title={t("editor.resize")}
/>
<div className="absolute pointer-events-none" style={{ top: -largeHandleSize, left: "50%", transform: "translateX(-50%)", height: largeHandleSize }}>
<div className="w-px bg-[var(--color-accent-strong)]" style={{ width: "1px", height: "100%", marginLeft: "0px" }} />
<button
type="button"
className="absolute top-0 left-1/2 -translate-x-1/2 rounded-full border-2 border-white/90 bg-[var(--color-accent-strong)] shadow flex items-center justify-center"
style={{ width: largeHandleSizePx, height: largeHandleSizePx }}
onPointerDown={(e) => onRotateHandleDown(e, layer)}
title={t("editor.rotate")}
>
<RotateCw className="text-white" style={{ width: handleSize * 0.6, height: handleSize * 0.6 }} />
</button>
</div>
</>
) : null}
</div>
);
})}
</div>
{faceLabelOverlays.map((label) => (
<span
key={label.id}
className="pointer-events-none absolute rounded px-1.5 py-0.5 text-[10px] font-semibold whitespace-nowrap text-white"
style={{
left: label.left,
top: label.top,
background: "rgba(0, 210, 255, 0.9)",
boxShadow: "0 2px 6px rgba(0,0,0,0.35), 0 0 0 1px rgba(0,0,0,0.22)",
textShadow: "0 1px 1px rgba(0,0,0,0.35)",
zIndex: layers.length + 100,
}}
>
{label.text}
</span>
))}
</div>
);
}
+173
View File
@@ -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<AspectRatio>(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 (
<div className={overlay} onClick={onClose}>
<div className={panel} onClick={(e) => e.stopPropagation()}>
<div className="mb-4 flex items-center justify-between">
<h2 className={`text-base font-semibold ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}>
{t("editor.canvasSizeTitle")}
</h2>
<button
onClick={onClose}
className={`rounded border px-2 py-0.5 text-xs ${isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"}`}
>
</button>
</div>
<div className="mb-4 flex gap-2">
{(["preset", "custom"] as const).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className={`flex-1 rounded border py-1.5 text-xs font-semibold capitalize ${
mode === m
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
: isDark
? "border-white/20 text-[#d7dae0]"
: "border-black/20 text-[#1f2430]"
}`}
>
{m === "preset" ? t("editor.modePreset") : t("editor.modeCustom")}
</button>
))}
</div>
{mode === "preset" ? (
<div className="grid grid-cols-2 gap-2">
{PRESETS.map((p) => (
<button
key={p.aspect}
onClick={() => setSelectedPreset(p.aspect)}
className={`rounded border p-3 text-left ${
selectedPreset === p.aspect
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
: isDark
? "border-white/10 bg-[#343841] text-[#d7dae0]"
: "border-black/10 bg-[#f6f8fb] text-[#1f2430]"
}`}
>
<div className="text-xs font-semibold">{t(p.labelKey)}</div>
<div className={`text-[10px] ${selectedPreset === p.aspect ? "text-white/70" : isDark ? "text-[#aeb3bc]" : "text-[#6c7382]"}`}>
{p.sublabel}
</div>
</button>
))}
</div>
) : (
<div className="space-y-3">
<div className="flex items-center gap-2">
<label className={`w-16 text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.widthShort")}</label>
<input
type="number"
value={customWidth}
onChange={(e) => 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]"
}`}
/>
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>px</span>
</div>
<div className="flex items-center gap-2">
<label className={`w-16 text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.heightShort")}</label>
<input
type="number"
value={customHeight}
onChange={(e) => 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]"
}`}
/>
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>px</span>
</div>
<div className="rounded border border-dashed border-black/20 p-2 text-center">
<span className={`text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>
{parseInt(customWidth) || 0} × {parseInt(customHeight) || 0} px
</span>
</div>
</div>
)}
<div className="mt-5 flex justify-end gap-2">
<button
onClick={onClose}
className={`rounded border px-3 py-1.5 text-xs font-medium ${
isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"
}`}
>
{t("editor.cancel")}
</button>
<button
onClick={handleApply}
className="rounded border border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] px-3 py-1.5 text-xs font-semibold text-white"
>
{t("editor.apply")}
</button>
</div>
</div>
</div>
);
}
@@ -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 (
<div
className={`absolute z-40 min-w-[160px] rounded border p-1 text-[12px] shadow-2xl ${
isDark ? "border-white/10 bg-[#2a2c31] text-[#e2e5ea]" : "border-black/10 bg-white text-[#1f2430]"
}`}
style={{ left: x, top: y }}
onClick={(event) => event.stopPropagation()}
>
<button type="button" onClick={onCopy} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
{labels.copy}
</button>
<button type="button" onClick={onCut} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
{labels.cut}
</button>
<button type="button" onClick={onPaste} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>
{labels.paste}
</button>
</div>
);
}
@@ -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 }) => (
<button type="button" data-testid="canvas-renderer" onClick={() => onContextMenu?.(10, 12)}>
canvas
</button>
),
}));
describe("EditorCanvasStage", () => {
it("wires context menu actions", () => {
const onCopy = vi.fn();
const onCloseContextMenu = vi.fn();
render(
<EditorCanvasStage
isDark={false}
layers={[]}
canvasWidth={100}
canvasHeight={100}
selectedLayerId={null}
tool="pointer"
faceDetections={[]}
faceOverlayLayerId={null}
faceBlurPreview={null}
contextMenu={{ x: 10, y: 20 }}
labels={{ copy: "Copy", cut: "Cut", paste: "Paste" }}
onSelectLayer={() => 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);
});
});
@@ -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 (
<div className={`h-full overflow-hidden p-4 ${isDark ? "bg-[#1e1f23]" : "bg-[#f2f4f8]"}`}>
<div
className="flex h-full items-center justify-center"
style={{
backgroundImage: isDark
? "linear-gradient(#2b2d31 1px, transparent 1px), linear-gradient(90deg, #2b2d31 1px, transparent 1px)"
: "linear-gradient(#e8eaed 1px, transparent 1px), linear-gradient(90deg, #e8eaed 1px, transparent 1px)",
backgroundSize: "20px 20px",
}}
>
<div className="relative flex h-full w-full items-center justify-center overflow-hidden shadow-2xl ring-1 ring-black/10">
<CanvasRenderer
layers={layers}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
selectedLayerId={selectedLayerId}
onSelectLayer={onSelectLayer}
onMoveLayer={onMoveLayer}
onMoveLayerEnd={onMoveLayerEnd}
onResizeLayer={onResizeLayer}
onResizeLayerEnd={onResizeLayerEnd}
onRotateLayer={onRotateLayer}
onRotateLayerEnd={onRotateLayerEnd}
onInteractionStart={onInteractionStart}
onInteractionEnd={onInteractionEnd}
onContextMenu={onContextMenu}
isDark={isDark}
tool={tool}
faceDetections={faceDetections}
faceOverlayLayerId={faceOverlayLayerId}
faceBlurPreview={faceBlurPreview}
/>
{contextMenu ? (
<CanvasContextMenu
isDark={isDark}
x={contextMenu.x}
y={contextMenu.y}
labels={labels}
onCopy={() => {
onCopy();
onCloseContextMenu();
}}
onCut={() => {
onCut();
onCloseContextMenu();
}}
onPaste={() => {
onPaste();
onCloseContextMenu();
}}
/>
) : null}
</div>
</div>
</div>
);
}
@@ -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 (
<header className={`flex h-14 items-center justify-between border-b px-4 ${isDark ? "border-white/10 bg-[#2b2d31]" : "border-black/10 bg-white"}`}>
<div className="flex items-center gap-4">
<Link href="/" className="group">
<p className={`text-[10px] uppercase tracking-[0.2em] group-hover:opacity-80 ${isDark ? "text-[#a8abb2]" : "text-[#6c7382]"}`}>
pien.studio
</p>
<h1 className={`text-sm font-semibold group-hover:opacity-80 ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}>{projectTitle}</h1>
</Link>
<nav className="flex items-center gap-2 text-xs font-medium">
<MenuShell isDark={isDark} label={labels.file} menuClass={menuClass}>
<FileMenu
isDark={isDark}
labels={labels}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
onSave={onSave}
onExportPng={onExportPng}
onExportProjectFile={onExportProjectFile}
onImportImage={onImportImage}
onOpenCanvasSize={onOpenCanvasSize}
/>
</MenuShell>
<MenuShell isDark={isDark} label={labels.edit} menuClass={menuClass}>
<EditMenu isDark={isDark} labels={labels} canUndo={canUndo} canRedo={canRedo} onUndo={onUndo} onRedo={onRedo} onCopy={onCopy} onCut={onCut} onPaste={onPaste} />
</MenuShell>
<MenuShell isDark={isDark} label={labels.view} menuClass={menuClass}>
<ViewMenu isDark={isDark} labels={labels} onSetHandTool={onSetHandTool} onSetPointerTool={onSetPointerTool} />
</MenuShell>
<MenuShell isDark={isDark} label={labels.settings} menuClass={menuClass}>
<SettingsMenu isDark={isDark} preferences={labels.preferences} />
</MenuShell>
</nav>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onUndo}
disabled={!canUndo}
title={labels.undo}
className={`rounded border px-2.5 py-1.5 text-xs font-medium ${
isDark ? "border-white/15 bg-[#25272b] text-[#d7dae0]" : "border-black/15 bg-[#f2f4f8] text-[#1f2430]"
} ${!canUndo ? "opacity-50" : "hover:opacity-90"}`}
>
<Undo2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onRedo}
disabled={!canRedo}
title={labels.redo}
className={`rounded border px-2.5 py-1.5 text-xs font-medium ${
isDark ? "border-white/15 bg-[#25272b] text-[#d7dae0]" : "border-black/15 bg-[#f2f4f8] text-[#1f2430]"
} ${!canRedo ? "opacity-50" : "hover:opacity-90"}`}
>
<Redo2 className="h-3.5 w-3.5" />
</button>
<span className={`text-[10px] font-semibold uppercase tracking-[0.2em] ${isDark ? "text-[#a8abb2]" : "text-[#6c7382]"}`}>
{isDirty ? labels.unsavedChanges : labels.saved}
</span>
</div>
</header>
);
}
function MenuShell({ isDark, label, menuClass, children }: { isDark: boolean; label: string; menuClass: string; children: React.ReactNode }) {
return (
<div className="relative group">
<button type="button" className={`rounded px-2 py-1 ${isDark ? "text-[#d7dae0] hover:bg-white/10" : "text-[#1f2430] hover:bg-black/5"}`}>
{label}
</button>
<div className={menuClass} onClick={(event) => event.stopPropagation()}>
{children}
</div>
</div>
);
}
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 (
<div className="space-y-1">
<button type="button" onClick={onSave} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.save}</button>
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
<button type="button" onClick={onExportPng} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportPng}</button>
<button type="button" onClick={onExportProjectFile} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.exportProjectFile}</button>
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
<button type="button" onClick={onImportImage} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.importImage}</button>
<button type="button" onClick={onOpenCanvasSize} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.canvasSize} ({canvasWidth} x {canvasHeight})</button>
</div>
);
}
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 (
<div className="space-y-1">
<button type="button" onClick={onUndo} disabled={!canUndo} className={`w-full rounded px-2 py-1 text-left ${!canUndo ? "opacity-50" : hoverSubtleClass(isDark)}`}>{labels.undo}</button>
<button type="button" onClick={onRedo} disabled={!canRedo} className={`w-full rounded px-2 py-1 text-left ${!canRedo ? "opacity-50" : hoverSubtleClass(isDark)}`}>{labels.redo}</button>
<div className={`my-1 h-px ${dividerClass(isDark)}`} />
<button type="button" onClick={onCopy} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.copy}</button>
<button type="button" onClick={onCut} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.cut}</button>
<button type="button" onClick={onPaste} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.paste}</button>
</div>
);
}
function ViewMenu({ isDark, labels, onSetHandTool, onSetPointerTool }: { isDark: boolean; labels: EditorHeaderProps["labels"]; onSetHandTool: () => void; onSetPointerTool: () => void }) {
return (
<div className="space-y-1">
<button type="button" onClick={onSetHandTool} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.panTool}</button>
<button type="button" onClick={onSetPointerTool} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.pointerTool}</button>
</div>
);
}
function SettingsMenu({ isDark, preferences }: { isDark: boolean; preferences: string }) {
return (
<div className="space-y-2 p-1">
<p className={`px-2 text-[10px] font-semibold uppercase tracking-wide ${isDark ? "text-[#9aa1ad]" : "text-[#6c7382]"}`}>{preferences}</p>
<UiPreferences />
</div>
);
}
@@ -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: () => <div data-testid="canvas-renderer" />,
}));
describe("EditorMobileSection", () => {
it("shows face detection helper message in face mode", () => {
render(
<EditorMobileSection
isDark={false}
canvasWidth={100}
canvasHeight={120}
layers={[]}
selectedLayerId={null}
tool="face"
faceDetections={[{ x: 1, y: 1, width: 10, height: 10, label: "f" }]}
faceOverlayLayerId={null}
faceStatus="idle"
faceBlurPreview={null}
labels={{
resize: "Resize",
faceMlFailedShort: "Face failed",
detectingFacesShort: "Detecting",
faceDetectionTip: (count) => `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();
});
});
@@ -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 (
<section className="lg:hidden">
<div className={`rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}>
<div className="mb-2 flex items-center justify-between">
<p className={`text-sm font-semibold ${isDark ? "text-[#dfe3ea]" : "text-[#1f2430]"}`}>
{canvasWidth} x {canvasHeight}px
</p>
<button
onClick={onOpenCanvasSize}
className={`rounded-md border px-2 py-1 text-xs font-semibold ${
isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"
}`}
>
{labels.resize}
</button>
</div>
<div
className={`relative overflow-hidden rounded-2xl border ${isDark ? "border-white/10 bg-[#17181b]" : "border-black/10 bg-[#f7f8fa]"}`}
style={{ height: "55vw", maxHeight: "70vh" }}
>
<CanvasRenderer
layers={layers}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
selectedLayerId={selectedLayerId}
onSelectLayer={onSelectLayer}
onMoveLayer={onMoveLayer}
onMoveLayerEnd={onMoveLayerEnd}
onResizeLayer={onResizeLayer}
onResizeLayerEnd={onResizeLayerEnd}
onRotateLayer={onRotateLayer}
onRotateLayerEnd={onRotateLayerEnd}
onInteractionStart={onInteractionStart}
onInteractionEnd={onInteractionEnd}
isDark={isDark}
tool={tool}
faceDetections={faceDetections}
faceOverlayLayerId={faceOverlayLayerId}
faceBlurPreview={faceBlurPreview}
/>
</div>
{tool === "face" ? (
<p className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
{faceStatus === "unsupported"
? labels.faceMlFailedShort
: faceStatus === "detecting"
? labels.detectingFacesShort
: labels.faceDetectionTip(faceDetections.length)}
</p>
) : null}
</div>
<div className={`mt-3 rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}>
<div className="grid grid-cols-4 gap-2">
<button
type="button"
onClick={onImportImage}
className={`rounded-xl border px-2 py-3 text-[11px] font-semibold ${
isDark ? "border-white/20 bg-[#25272b] text-[#dfe3ea]" : "border-black/20 bg-[#f5f6f8] text-[#1f2430]"
}`}
>
{labels.import}
</button>
{[labels.mood, labels.quick, labels.face, labels.decor].map((toolLabel) => (
<button
key={toolLabel}
className={`rounded-xl border px-2 py-3 text-[11px] font-semibold ${
isDark ? "border-white/20 bg-[#25272b] text-[#dfe3ea]" : "border-black/20 bg-[#f5f6f8] text-[#1f2430]"
}`}
>
{toolLabel}
</button>
))}
</div>
</div>
</section>
);
}
@@ -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 (
<aside className={`border-l p-3 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}>
<div className="space-y-3">
<LayersPanel
layers={layers}
selectedLayerId={selectedLayerId}
isDark={isDark}
onSelectLayer={(layerId) => onSelectLayer(layerId)}
onMoveLayerOrder={onMoveLayerOrder}
onRemoveSelectedLayer={onRemoveSelectedLayer}
/>
{tool === "face" ? (
<FacePanel
isDark={isDark}
selectedLayer={selectedLayer}
faceDetections={faceDetections}
facePreviews={facePreviews}
faceStatus={faceStatus}
blurMethod={blurMethod}
blurAmount={blurAmount}
censorColor={censorColor}
selectedFaceIndices={selectedFaceIndices}
hasActiveBlur={Boolean(selectedLayer && selectedLayer.type === "image" && selectedLayer.faceBlur)}
onSetBlurMethod={onSetBlurMethod}
onSetBlurAmount={onSetBlurAmount}
onSetCensorColor={onSetCensorColor}
onToggleFaceIndex={onToggleFaceIndex}
onBlur={onBlur}
onClearBlur={onClearBlur}
/>
) : null}
<HistoryPanel
history={history}
isDark={isDark}
canUndo={canUndo}
canRedo={canRedo}
onUndo={onUndo}
onRedo={onRedo}
onJumpToPast={onJumpToPast}
onJumpToFuture={onJumpToFuture}
/>
</div>
</aside>
);
}
+184
View File
@@ -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 (
<div className={panelClass(isDark)}>
<div className="flex items-center justify-between">
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.faceTool")}</h2>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>{faceDetections.length}</span>
</div>
<p className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
{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")}
</p>
{hasFaces ? (
<div className={`mt-3 max-h-[180px] space-y-1 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
{faceDetections.map((face, index) => (
<button
key={`face-result-${index}`}
type="button"
className={`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-[11px] ${isDark ? "bg-white/5 text-[#d7dae0]" : "bg-white text-[#1f2430]"} ${
selectedFaceIndices.includes(index)
? isDark
? "ring-1 ring-[#7cdcff]"
: "ring-1 ring-[#00b7f0]"
: ""
}`}
onClick={() => onToggleFaceIndex(index)}
>
{facePreviews[index]?.src ? (
<Image src={facePreviews[index].src} alt={`Face preview ${index + 1}`} width={40} height={40} unoptimized className="h-10 w-10 rounded object-cover" draggable={false} />
) : (
<div className={`h-10 w-10 rounded ${isDark ? "bg-white/10" : "bg-black/10"}`} />
)}
<div>
<p className="font-semibold">{t("editor.person")} {index + 1}</p>
<p>{`${face.gender ?? t("editor.unknown")}${face.genderScore != null ? ` ${Math.round(face.genderScore * 100)}%` : ""}`}</p>
</div>
</button>
))}
</div>
) : null}
<div className={`mt-3 space-y-2 rounded-md border p-2 ${panelInsetClass(isDark)}`}>
<label className="block text-[11px] font-semibold">{t("editor.blurMethod")}</label>
<div className="grid grid-cols-3 gap-1">
{[
{ id: "gaussian", label: t("editor.soft") },
{ id: "pixelate", label: t("editor.pixelate") },
{ id: "censor", label: t("editor.censor") },
].map((option) => (
<button
key={option.id}
type="button"
className={`rounded px-2 py-1 text-[11px] font-semibold ${
blurMethod === option.id
? "bg-[var(--color-accent-strong)] text-white"
: isDark
? "bg-white/10 text-[#d7dae0]"
: "bg-white text-[#1f2430]"
}`}
onClick={() => onSetBlurMethod(option.id as "gaussian" | "pixelate" | "censor")}
>
{option.label}
</button>
))}
</div>
{blurMethod !== "censor" ? (
<div className="space-y-1">
<div className="flex items-center justify-between text-[11px]">
<span>{t("editor.strength")}</span>
<span>{blurAmount}</span>
</div>
<input
type="range"
min={4}
max={40}
value={blurAmount}
onChange={(event) => onSetBlurAmount(Number(event.target.value))}
className="w-full"
/>
</div>
) : null}
{blurMethod === "censor" ? (
<div className="space-y-1">
<div className="flex items-center justify-between text-[11px]">
<span>{t("editor.color")}</span>
</div>
<div className="flex items-center gap-2">
<input
type="color"
value={censorColor}
onChange={(event) => onSetCensorColor(event.target.value)}
className="h-6 w-6 cursor-pointer rounded border-none"
/>
<input
type="text"
value={censorColor}
onChange={(event) => 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"}`}
/>
</div>
</div>
) : null}
<button
type="button"
onClick={() => onBlur(selectedFaceIndices)}
disabled={!hasFaces || selectedFaceIndices.length === 0}
className={`w-full rounded px-2 py-1 text-[11px] font-semibold ${
!hasFaces || selectedFaceIndices.length === 0
? "opacity-50"
: "bg-[var(--color-accent-strong)] text-white"
}`}
>
{hasFaces && selectedFaceIndices.length === 0
? t("editor.selectFacesToBlur")
: t("editor.blurFaces", { count: selectedFaceIndices.length })}
</button>
{hasActiveBlur ? (
<button
type="button"
onClick={onClearBlur}
className={`w-full rounded px-2 py-1 text-[11px] font-semibold ${
isDark ? "bg-white/10 text-[#d7dae0]" : "bg-white text-[#1f2430]"
}`}
>
{t("editor.clearBlur")}
</button>
) : null}
</div>
</div>
);
}
@@ -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 (
<div className={panelClass(isDark)}>
<div className="flex items-center justify-between">
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.history")}</h2>
<div className="flex gap-1">
<button type="button" onClick={onUndo} disabled={!canUndo} title={t("editor.undo")} className={`rounded p-1 ${!canUndo ? "opacity-30" : isDark ? "hover:bg-white/10" : "hover:bg-black/5"}`}>
<Undo2 className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={onRedo} disabled={!canRedo} title={t("editor.redo")} className={`rounded p-1 ${!canRedo ? "opacity-30" : isDark ? "hover:bg-white/10" : "hover:bg-black/5"}`}>
<Redo2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<div className={`mt-2 flex max-h-[240px] flex-col gap-1 overflow-y-auto ${isDark ? "[&::-webkit-scrollbar-thumb]:bg-white/20" : "[&::-webkit-scrollbar-thumb]:bg-black/20"}`}>
{[...history.past].reverse().map((_, i) => {
const idx = history.past.length - i;
return (
<button
key={`past-${idx}`}
type="button"
onClick={() => onJumpToPast(idx)}
className={`w-full rounded px-2 py-1 text-left text-[10px] ${isDark ? "text-[#9aa1ad] hover:bg-white/10" : "text-[#6b7280] hover:bg-black/5"}`}
>
{idx > history.past.length - 2 ? t("editor.beforeLastAction") : `${t("editor.step")} ${idx}`}
</button>
);
})}
<button type="button" className={`w-full rounded px-2 py-1 text-left text-[10px] font-semibold ${isDark ? "bg-white/10 text-[#e2e5ea]" : "bg-black/5 text-[#1f2430]"}`} disabled>
{t("editor.now")}
</button>
{[...history.future].map((_, i) => (
<button
key={`future-${i}`}
type="button"
onClick={() => onJumpToFuture(i)}
className={`w-full rounded px-2 py-1 text-left text-[10px] ${isDark ? "text-[#9aa1ad] hover:bg-white/10" : "text-[#6b7280] hover:bg-black/5"}`}
>
{t("editor.undoneStep")} {i + 1}
</button>
))}
{history.past.length === 0 && history.future.length === 0 ? <p className={`py-2 text-center text-[10px] ${isDark ? "text-[#6b7280]" : "text-[#9ca3af]"}`}>{t("editor.noHistoryYet")}</p> : null}
</div>
</div>
);
}
@@ -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 (
<div className={panelClass(isDark)}>
<div className="flex items-center justify-between">
<h2 className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}>{t("editor.layers")}</h2>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>
{layers.length}
</span>
</div>
<div className={`mt-3 max-h-[320px] space-y-1 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
{layers.slice().reverse().map((layer, idx) => {
const isSelected = layer.id === selectedLayerId;
const isImageLayer = layer.type === "image" || layer.type === "sticker";
return (
<button
key={layer.id}
type="button"
onClick={() => onSelectLayer(layer.id)}
className={`group flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-xs transition ${
isSelected
? "bg-[var(--color-accent-strong)] text-white"
: isDark
? "text-[#d7dae0] hover:bg-white/10"
: "text-[#1f2430] hover:bg-black/5"
}`}
>
<div className="flex items-center gap-2">
<span className={`w-6 text-[10px] font-semibold ${isSelected ? "text-white/90" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>{idx + 1}</span>
<div className={`h-9 w-9 shrink-0 overflow-hidden rounded border ${isSelected ? "border-white/60 bg-white/10" : isDark ? "border-white/15 bg-[#1f2126]" : "border-black/10 bg-[#eef1f6]"}`}>
{isImageLayer && layer.sourceUri ? (
<Image src={layer.sourceUri} alt={layer.name ?? layer.type} width={36} height={36} unoptimized className="h-full w-full object-cover" draggable={false} />
) : (
<div className={`flex h-full w-full items-center justify-center text-[9px] font-semibold uppercase tracking-wide ${isSelected ? "text-white/90" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}>
{layer.type}
</div>
)}
</div>
<div>
<p className="font-semibold">{layer.name ?? layer.type}</p>
<p className={`${isSelected ? "text-white/80" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}>{layer.type}</p>
</div>
</div>
<span className={`h-1.5 w-1.5 rounded-full ${isSelected ? "bg-white" : isDark ? "bg-[#3d424c]" : "bg-[#d4d8e0]"}`} />
</button>
);
})}
{layers.length === 0 ? <div className={`px-2 py-6 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>{t("editor.noLayersYet")}</div> : null}
</div>
<div className="mt-3 flex gap-2">
<button type="button" onClick={() => onMoveLayerOrder("up")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.up")}</button>
<button type="button" onClick={() => onMoveLayerOrder("down")} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}>{t("editor.down")}</button>
<button type="button" onClick={onRemoveSelectedLayer} className={`flex-1 rounded border px-2 py-1 text-xs ${isDark ? "border-red-400/30 bg-red-400/10 text-red-200 hover:bg-red-400/20" : "border-red-500/30 bg-red-500/10 text-red-600 hover:bg-red-500/15"}`}>{t("editor.delete")}</button>
</div>
</div>
);
}
+42
View File
@@ -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<string, React.ComponentType<{ className?: string }>>;
onSetTool: (tool: EditorToolId) => void;
};
export function ToolRail({ controllers, selectedTool, isDark, icons, onSetTool }: Props) {
return (
<aside className={`border-r p-2 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}>
<div className="flex flex-col gap-2">
{controllers.map((controller) => {
const Icon = icons[controller.id] ?? MousePointer2;
const isSelected = controller.kind === "mode" && controller.id === selectedTool;
return (
<button
key={controller.id}
type="button"
title={controller.label}
onClick={() => (controller.kind === "mode" ? onSetTool(controller.id) : controller.run())}
className={`rounded border p-2 text-[11px] font-medium ${
isSelected
? "border-[var(--color-accent-strong)] bg-[var(--color-accent-strong)] text-white"
: isDark
? "border-white/10 bg-[#2d3036] text-[#d7dae0] hover:bg-[#353942]"
: "border-black/10 bg-white text-[#1f2430] hover:bg-[#f4f6f9]"
}`}
>
<Icon className="mx-auto h-4 w-4" />
</button>
);
})}
</div>
</aside>
);
}
+43
View File
@@ -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 (
<div className="flex flex-wrap items-center gap-2">
<label className={cx("inline-flex items-center rounded border px-2", subtleButtonClass(isDark), wrapperHeight)}>
<span className={cx("mr-2 font-semibold opacity-70", textSize)}>{t("ui.locale")}</span>
<select
aria-label={t("ui.locale")}
value={locale}
onChange={(event) => setLocale(event.target.value as "en" | "th" | "ja")}
className={cx(
"bg-transparent font-semibold outline-none",
textSize,
isDark ? "text-[#f5f7fa]" : "text-[#1f2430]",
)}
>
<option value="en">English</option>
<option value="th">Thai</option>
<option value="ja">Japanese</option>
</select>
</label>
<button
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className={cx("rounded border px-3 font-semibold", subtleButtonClass(isDark), wrapperHeight, textSize)}
>
{theme === "dark" ? t("ui.darkMode") : t("ui.lightMode")}
</button>
</div>
);
}