♻️ refactor!: massive refactor

This commit is contained in:
2026-05-31 03:04:30 +07:00
parent c1f12c7201
commit df3c944547
86 changed files with 3691 additions and 1129 deletions
@@ -15,22 +15,44 @@ type CanvasContextMenuProps = {
onPaste: () => void;
};
export function CanvasContextMenu({ isDark, x, y, labels, onCopy, onCut, onPaste }: CanvasContextMenuProps) {
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]"
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)}`}>
<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)}`}>
<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)}`}>
<button
type="button"
onClick={onPaste}
className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}
>
{labels.paste}
</button>
</div>
@@ -4,13 +4,20 @@ 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 }) => (
CanvasRenderer: ({
onContextMenu,
}: {
onContextMenu?: (x: number, y: number) => void;
}) =>
React.createElement(
"button",
{ type: "button", "data-testid": "canvas-renderer", onClick: () => onContextMenu?.(10, 12) },
{
type: "button",
"data-testid": "canvas-renderer",
onClick: () => onContextMenu?.(10, 12),
},
"canvas",
)
),
),
}));
describe("EditorCanvasStage", () => {
@@ -69,7 +69,9 @@ export function EditorCanvasStage(props: EditorCanvasStageProps) {
} = props;
return (
<div className={`h-full overflow-hidden p-4 ${isDark ? "bg-[#1e1f23]" : "bg-[#f2f4f8]"}`}>
<div
className={`h-full overflow-hidden p-4 ${isDark ? "bg-[#1e1f23]" : "bg-[#f2f4f8]"}`}
>
<div
className="flex h-full items-center justify-center"
style={{
+169 -27
View File
@@ -70,17 +70,27 @@ export function EditorHeader({
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]"
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"}`}>
<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]"}`}>
<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>
<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">
@@ -98,12 +108,31 @@ export function EditorHeader({
/>
</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} />
<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} />
<ViewMenu
isDark={isDark}
labels={labels}
onSetHandTool={onSetHandTool}
onSetPointerTool={onSetPointerTool}
/>
</MenuShell>
<MenuShell isDark={isDark} label={labels.settings} menuClass={menuClass}>
<MenuShell
isDark={isDark}
label={labels.settings}
menuClass={menuClass}
>
<SettingsMenu isDark={isDark} preferences={labels.preferences} />
</MenuShell>
</nav>
@@ -116,7 +145,9 @@ export function EditorHeader({
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]"
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" />
@@ -127,12 +158,16 @@ export function EditorHeader({
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]"
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]"}`}>
<span
className={`text-[10px] font-semibold uppercase tracking-[0.2em] ${isDark ? "text-[#a8abb2]" : "text-[#6c7382]"}`}
>
{isDirty ? labels.unsavedChanges : labels.saved}
</span>
</div>
@@ -140,10 +175,23 @@ export function EditorHeader({
);
}
function MenuShell({ isDark, label, menuClass, children }: { isDark: boolean; label: string; menuClass: string; children: React.ReactNode }) {
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"}`}>
<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()}>
@@ -176,13 +224,43 @@ function FileMenu({
}) {
return (
<div className="space-y-1">
<button type="button" onClick={onImportImage} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.importImage}</button>
<button type="button" onClick={onSave} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.save}</button>
<button
type="button"
onClick={onImportImage}
className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}
>
{labels.importImage}
</button>
<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>
<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={onOpenCanvasSize} className={`w-full rounded px-2 py-1 text-left ${hoverSubtleClass(isDark)}`}>{labels.canvasSize} ({canvasWidth} x {canvasHeight})</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>
);
}
@@ -210,29 +288,93 @@ function EditMenu({
}) {
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>
<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>
<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 }) {
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>
<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 }) {
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>
<p
className={`px-2 text-[10px] font-semibold uppercase tracking-wide ${isDark ? "text-[#9aa1ad]" : "text-[#6c7382]"}`}
>
{preferences}
</p>
<UiPreferences />
</div>
);
@@ -4,7 +4,8 @@ import { describe, expect, it, vi } from "vitest";
import { EditorMobileSection } from "./editor-mobile-section";
vi.mock("../canvas-renderer", () => ({
CanvasRenderer: () => React.createElement("div", { "data-testid": "canvas-renderer" }),
CanvasRenderer: () =>
React.createElement("div", { "data-testid": "canvas-renderer" }),
}));
describe("EditorMobileSection", () => {
@@ -17,7 +18,17 @@ describe("EditorMobileSection", () => {
layers: [],
selectedLayerId: null,
tool: "face",
faceDetections: [{ x: 1, y: 1, width: 10, height: 10, label: "f", sourceWidth: 100, sourceHeight: 100 }],
faceDetections: [
{
x: 1,
y: 1,
width: 10,
height: 10,
label: "f",
sourceWidth: 100,
sourceHeight: 100,
},
],
faceOverlayLayerId: null,
faceStatus: "idle",
faceBlurPreview: null,
@@ -66,15 +66,21 @@ export function EditorMobileSection(props: EditorMobileSectionProps) {
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={`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]"}`}>
<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]"
isDark
? "border-white/20 text-[#d7dae0]"
: "border-black/20 text-[#1f2430]"
}`}
>
{labels.resize}
@@ -106,7 +112,9 @@ export function EditorMobileSection(props: EditorMobileSectionProps) {
/>
</div>
{tool === "face" && (
<p className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
<p
className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}
>
{faceStatus === "unsupported"
? labels.faceMlFailedShort
: faceStatus === "detecting"
@@ -116,27 +124,35 @@ export function EditorMobileSection(props: EditorMobileSectionProps) {
)}
</div>
<div className={`mt-3 rounded-2xl border p-3 ${isDark ? "border-white/10 bg-[#2a2c31]" : "border-black/10 bg-white"}`}>
<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]"
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>
))}
{[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>
+127 -25
View File
@@ -1,9 +1,17 @@
import React from "react";
import type { FaceBlurMethod, Layer, Project } from "@pien-studio/types";
import type {
FaceBlurMethod,
Layer,
LayerEffect,
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";
import type {
FaceDetectionOverlay,
FacePreview,
} from "../../hooks/use-face-detection";
type EditorSidebarProps = {
isDark: boolean;
@@ -37,7 +45,11 @@ type EditorSidebarProps = {
selectedFaceIndices: number[];
onSelectLayer: (layerId: string | null) => void;
onSetLayerVisible: (layerId: string, visible: boolean) => void;
onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void; // string intentional: UI doesn't need the narrowed union
onSetEffectEnabled: (
layerId: string,
kind: LayerEffect["kind"],
enabled: boolean,
) => void;
onMoveLayerOrder: (direction: "up" | "down") => void;
onRemoveSelectedLayer: () => void;
onUndo: () => void;
@@ -99,7 +111,9 @@ export function EditorSidebar({
onClearBlur,
}: EditorSidebarProps) {
return (
<aside className={`border-l p-3 ${isDark ? "border-white/10 bg-[#24262a]" : "border-black/10 bg-[#eceff3]"}`}>
<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}
@@ -114,24 +128,44 @@ export function EditorSidebar({
/>
{tool === "fill" && onSetFillColor && onSetFillTolerance ? (
<div className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}>
<p className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>
<div
className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}
>
<p
className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
>
Fill
</p>
<div className="flex items-center gap-2 mb-3">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Color</label>
<label
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
>
Color
</label>
<input
type="color"
value={fillColor ?? "#ff0000"}
onChange={(e) => onSetFillColor(e.target.value)}
className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5"
/>
<span className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>{fillColor ?? "#ff0000"}</span>
<span
className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
>
{fillColor ?? "#ff0000"}
</span>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Tolerance</label>
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{fillTolerance ?? 32}</span>
<label
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
>
Tolerance
</label>
<span
className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
>
{fillTolerance ?? 32}
</span>
</div>
<input
type="range"
@@ -154,42 +188,107 @@ export function EditorSidebar({
</div>
) : null}
{tool === "brush" && onSetBrushColor && onSetBrushSize && onSetBrushOpacity && onSetBrushHardness ? (
<div className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}>
<p className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>
{tool === "brush" &&
onSetBrushColor &&
onSetBrushSize &&
onSetBrushOpacity &&
onSetBrushHardness ? (
<div
className={`rounded-lg border p-3 ${isDark ? "border-white/10 bg-[#2d3036]" : "border-black/10 bg-white"}`}
>
<p
className={`mb-2 text-xs font-semibold uppercase tracking-wider ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
>
Brush
</p>
<div className="flex items-center gap-2 mb-3">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Color</label>
<label
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
>
Color
</label>
<input
type="color"
value={brushColor ?? "#000000"}
onChange={(e) => onSetBrushColor(e.target.value)}
className="h-7 w-10 cursor-pointer rounded border border-black/10 p-0.5"
/>
<span className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>{brushColor ?? "#000000"}</span>
<span
className={`text-xs font-mono ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
>
{brushColor ?? "#000000"}
</span>
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Size</label>
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{brushSize ?? 20}px</span>
<label
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
>
Size
</label>
<span
className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
>
{brushSize ?? 20}px
</span>
</div>
<input type="range" min={1} max={200} value={brushSize ?? 20} onChange={(e) => onSetBrushSize(Number(e.target.value))} className="w-full accent-[var(--color-accent-strong)]" />
<input
type="range"
min={1}
max={200}
value={brushSize ?? 20}
onChange={(e) => onSetBrushSize(Number(e.target.value))}
className="w-full accent-[var(--color-accent-strong)]"
/>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Opacity</label>
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{Math.round((brushOpacity ?? 1) * 100)}%</span>
<label
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
>
Opacity
</label>
<span
className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
>
{Math.round((brushOpacity ?? 1) * 100)}%
</span>
</div>
<input type="range" min={0} max={100} value={Math.round((brushOpacity ?? 1) * 100)} onChange={(e) => onSetBrushOpacity(Number(e.target.value) / 100)} className="w-full accent-[var(--color-accent-strong)]" />
<input
type="range"
min={0}
max={100}
value={Math.round((brushOpacity ?? 1) * 100)}
onChange={(e) =>
onSetBrushOpacity(Number(e.target.value) / 100)
}
className="w-full accent-[var(--color-accent-strong)]"
/>
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<label className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}>Hardness</label>
<span className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}>{Math.round((brushHardness ?? 0.8) * 100)}%</span>
<label
className={`text-xs ${isDark ? "text-[#d7dae0]" : "text-[#1f2430]"}`}
>
Hardness
</label>
<span
className={`text-xs font-mono ${isDark ? "text-[#8b9ab1]" : "text-[#6b7280]"}`}
>
{Math.round((brushHardness ?? 0.8) * 100)}%
</span>
</div>
<input type="range" min={0} max={100} value={Math.round((brushHardness ?? 0.8) * 100)} onChange={(e) => onSetBrushHardness(Number(e.target.value) / 100)} className="w-full accent-[var(--color-accent-strong)]" />
<input
type="range"
min={0}
max={100}
value={Math.round((brushHardness ?? 0.8) * 100)}
onChange={(e) =>
onSetBrushHardness(Number(e.target.value) / 100)
}
className="w-full accent-[var(--color-accent-strong)]"
/>
</div>
</div>
</div>
@@ -206,7 +305,10 @@ export function EditorSidebar({
blurAmount={blurAmount}
censorColor={censorColor}
selectedFaceIndices={selectedFaceIndices}
hasActiveBlur={Boolean(selectedLayer && selectedLayer.effects.some((e) => e.kind === "face-blur"))}
hasActiveBlur={Boolean(
selectedLayer &&
selectedLayer.effects.some((e) => e.kind === "face-blur"),
)}
onSetBlurMethod={onSetBlurMethod}
onSetBlurAmount={onSetBlurAmount}
onSetCensorColor={onSetCensorColor}
+50 -12
View File
@@ -2,9 +2,17 @@
import type { Layer } from "@pien-studio/types";
import Image from "next/image";
import type { FaceDetectionOverlay, FacePreview } from "../../hooks/use-face-detection";
import type {
FaceDetectionOverlay,
FacePreview,
} from "../../hooks/use-face-detection";
import { useTranslations } from "../../hooks/use-translations";
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
import {
panelClass,
panelCounterClass,
panelInsetClass,
panelTitleClass,
} from "../../lib/theme";
type Props = {
isDark: boolean;
@@ -49,10 +57,20 @@ export function FacePanel({
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>
<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]"}`}>
<p
className={`mt-2 text-[11px] ${isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}
>
{faceStatus === "unsupported"
? t("editor.faceMlFailed")
: faceStatus === "detecting"
@@ -64,7 +82,9 @@ export function FacePanel({
: t("editor.facesDetected")}
</p>
{hasFaces ? (
<div className={`mt-3 max-h-[180px] space-y-1 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
<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}`}
@@ -79,12 +99,24 @@ export function FacePanel({
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} />
<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
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 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>
@@ -92,8 +124,12 @@ export function FacePanel({
</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={`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") },
@@ -110,7 +146,9 @@ export function FacePanel({
? "bg-white/10 text-[#d7dae0]"
: "bg-white text-[#1f2430]"
}`}
onClick={() => onSetBlurMethod(option.id as "gaussian" | "pixelate" | "censor")}
onClick={() =>
onSetBlurMethod(option.id as "gaussian" | "pixelate" | "censor")
}
>
{option.label}
</button>
+47 -8
View File
@@ -16,23 +16,50 @@ type Props = {
onJumpToFuture: (idx: number) => void;
};
export function HistoryPanel({ history, isDark, canUndo, canRedo, onUndo, onRedo, onJumpToPast, onJumpToFuture }: Props) {
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>
<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"}`}>
<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"}`}>
<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"}`}>
<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 (
@@ -42,11 +69,17 @@ export function HistoryPanel({ history, isDark, canUndo, canRedo, onUndo, onRedo
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}`}
{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>
<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) => (
@@ -59,7 +92,13 @@ export function HistoryPanel({ history, isDark, canUndo, canRedo, onUndo, onRedo
{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}
{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>
);
+191 -95
View File
@@ -1,10 +1,19 @@
"use client";
import type { Layer } from "@pien-studio/types";
import {
getLayerRuntimeSource,
type Layer,
type LayerEffect,
} from "@pien-studio/types";
import Image from "next/image";
import { Eye, EyeOff } from "lucide-react";
import { useTranslations } from "../../hooks/use-translations";
import { panelClass, panelCounterClass, panelInsetClass, panelTitleClass } from "../../lib/theme";
import {
panelClass,
panelCounterClass,
panelInsetClass,
panelTitleClass,
} from "../../lib/theme";
const EFFECT_LABELS: Record<string, string> = {
"face-blur": "Face Blur",
@@ -16,19 +25,37 @@ type Props = {
isDark: boolean;
onSelectLayer: (layerId: string) => void;
onSetLayerVisible: (layerId: string, visible: boolean) => void;
onSetEffectEnabled: (layerId: string, kind: string, enabled: boolean) => void;
onSetEffectEnabled: (
layerId: string,
kind: LayerEffect["kind"],
enabled: boolean,
) => void;
onMoveLayerOrder: (direction: "up" | "down") => void;
onRemoveSelectedLayer: () => void;
onAddLayer?: () => void;
};
export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, onSetLayerVisible, onSetEffectEnabled, onMoveLayerOrder, onRemoveSelectedLayer, onAddLayer }: Props) {
export function LayersPanel({
layers,
selectedLayerId,
isDark,
onSelectLayer,
onSetLayerVisible,
onSetEffectEnabled,
onMoveLayerOrder,
onRemoveSelectedLayer,
onAddLayer,
}: 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>
<h2
className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}
>
{t("editor.layers")}
</h2>
<div className="flex items-center gap-1.5">
{onAddLayer ? (
<button
@@ -40,111 +67,180 @@ export function LayersPanel({ layers, selectedLayerId, isDark, onSelectLayer, on
+ New
</button>
) : null}
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}
>
{layers.length}
</span>
</div>
</div>
<div className={`mt-3 max-h-[420px] space-y-0.5 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}>
{layers.slice().reverse().map((layer, idx) => {
const isSelected = layer.id === selectedLayerId;
const isRaster = layer.type === "raster" || layer.type === "sticker";
const hasEffects = layer.effects.length > 0;
const isHidden = layer.visible === false;
return (
<div key={layer.id}>
<div className={`group flex w-full items-center gap-2 rounded px-2 py-2 text-xs transition ${
isSelected
? "bg-[var(--color-accent-strong)] text-white"
: isDark
? "text-[#d7dae0] hover:bg-white/10"
: "text-[#1f2430] hover:bg-black/5"
} ${isHidden ? "opacity-40" : ""}`}>
{/* Thumbnail */}
<button
type="button"
onClick={() => onSelectLayer(layer.id)}
className="shrink-0"
<div
className={`mt-3 max-h-[420px] space-y-0.5 overflow-auto rounded-md border p-1 ${panelInsetClass(isDark)}`}
>
{layers
.slice()
.reverse()
.map((layer, idx) => {
const isSelected = layer.id === selectedLayerId;
const isRaster =
layer.type === "raster" || layer.type === "sticker";
const sourceUri = getLayerRuntimeSource(layer);
const hasEffects = layer.effects.length > 0;
const isHidden = layer.visible === false;
return (
<div key={layer.id}>
<div
className={`group flex w-full items-center gap-2 rounded px-2 py-2 text-xs transition ${
isSelected
? "bg-[var(--color-accent-strong)] text-white"
: isDark
? "text-[#d7dae0] hover:bg-white/10"
: "text-[#1f2430] hover:bg-black/5"
} ${isHidden ? "opacity-40" : ""}`}
>
<div className={`h-10 w-10 overflow-hidden rounded border ${isSelected ? "border-white/40 bg-white/10" : isDark ? "border-white/15 bg-[#1a1c20]" : "border-black/10 bg-[#eef1f6]"}`}>
{isRaster && layer.sourceUri ? (
<Image src={layer.sourceUri} alt={layer.name ?? layer.type} width={40} height={40} unoptimized className="h-full w-full object-cover" draggable={false} />
<button
type="button"
onClick={() => onSelectLayer(layer.id)}
className="shrink-0"
>
<div
className={`h-10 w-10 overflow-hidden rounded border ${isSelected ? "border-white/40 bg-white/10" : isDark ? "border-white/15 bg-[#1a1c20]" : "border-black/10 bg-[#eef1f6]"}`}
>
{isRaster && sourceUri ? (
<Image
src={sourceUri}
alt={layer.name ?? layer.type}
width={40}
height={40}
unoptimized
className="h-full w-full object-cover"
draggable={false}
/>
) : (
<div
className={`flex h-full w-full items-center justify-center text-[8px] font-bold uppercase tracking-wide ${isSelected ? "text-white/80" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}
>
{layer.type}
</div>
)}
</div>
</button>
<button
type="button"
onClick={() => onSelectLayer(layer.id)}
className="min-w-0 flex-1 text-left"
>
<p className="truncate font-semibold leading-tight">
{layer.name ?? layer.type}
</p>
<p
className={`text-[10px] leading-tight mt-0.5 ${isSelected ? "text-white/70" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}
>
{layer.type}
{layer.opacity < 1
? ` · ${Math.round(layer.opacity * 100)}%`
: ""}
</p>
</button>
<button
type="button"
title={isHidden ? "Show layer" : "Hide layer"}
onClick={(e) => {
e.stopPropagation();
onSetLayerVisible(layer.id, !isHidden);
}}
className={`shrink-0 rounded p-0.5 opacity-0 group-hover:opacity-100 transition-opacity ${isHidden ? "!opacity-100" : ""} ${isSelected ? "hover:bg-white/20" : isDark ? "hover:bg-white/10" : "hover:bg-black/10"}`}
>
{isHidden ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<div className={`flex h-full w-full items-center justify-center text-[8px] font-bold uppercase tracking-wide ${isSelected ? "text-white/80" : isDark ? "text-[#b7bdc8]" : "text-[#596274]"}`}>
{layer.type}
</div>
<Eye className="h-3.5 w-3.5" />
)}
</div>
</button>
</button>
{/* Info */}
<button type="button" onClick={() => onSelectLayer(layer.id)} className="min-w-0 flex-1 text-left">
<p className="truncate font-semibold leading-tight">{layer.name ?? layer.type}</p>
<p className={`text-[10px] leading-tight mt-0.5 ${isSelected ? "text-white/70" : isDark ? "text-[#9aa1ad]" : "text-[#7b8392]"}`}>
{layer.type}
{layer.opacity < 1 ? ` · ${Math.round(layer.opacity * 100)}%` : ""}
</p>
</button>
{/* Visibility toggle */}
<button
type="button"
title={isHidden ? "Show layer" : "Hide layer"}
onClick={(e) => { e.stopPropagation(); onSetLayerVisible(layer.id, !isHidden); }}
className={`shrink-0 rounded p-0.5 opacity-0 group-hover:opacity-100 transition-opacity ${isHidden ? "!opacity-100" : ""} ${isSelected ? "hover:bg-white/20" : isDark ? "hover:bg-white/10" : "hover:bg-black/10"}`}
>
{isHidden
? <EyeOff className="h-3.5 w-3.5" />
: <Eye className="h-3.5 w-3.5" />}
</button>
{/* Layer index */}
<span className={`shrink-0 text-[10px] font-semibold ${isSelected ? "text-white/60" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}>
{layers.length - idx}
</span>
</div>
{/* Effects chips */}
{hasEffects ? (
<div className="ml-12 mb-0.5 flex flex-wrap gap-1 px-1">
{layer.effects.map((effect) => {
const isDisabled = effect.enabled === false;
return (
<button
key={effect.kind}
type="button"
title={isDisabled ? "Enable effect" : "Disable effect"}
onClick={() => onSetEffectEnabled(layer.id, effect.kind, isDisabled)}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold transition ${
isDisabled
? isDark ? "bg-white/10 text-[#6b7280] line-through" : "bg-black/5 text-[#9ca3af] line-through"
: isSelected
? "bg-white/20 text-white"
: isDark
? "bg-[var(--color-accent-strong)]/20 text-[var(--color-accent-strong)]"
: "bg-[var(--color-accent-strong)]/15 text-[var(--color-accent-strong)]"
}`}
>
{isDisabled ? <EyeOff className="h-2.5 w-2.5" /> : <Eye className="h-2.5 w-2.5" />}
{EFFECT_LABELS[effect.kind] ?? effect.kind}
</button>
);
})}
<span
className={`shrink-0 text-[10px] font-semibold ${isSelected ? "text-white/60" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}
>
{layers.length - idx}
</span>
</div>
) : null}
</div>
);
})}
{hasEffects ? (
<div className="ml-12 mb-0.5 flex flex-wrap gap-1 px-1">
{layer.effects.map((effect) => {
const isDisabled = effect.enabled === false;
return (
<button
key={effect.kind}
type="button"
title={
isDisabled ? "Enable effect" : "Disable effect"
}
onClick={() =>
onSetEffectEnabled(
layer.id,
effect.kind,
isDisabled,
)
}
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-[9px] font-semibold transition ${
isDisabled
? isDark
? "bg-white/10 text-[#6b7280] line-through"
: "bg-black/5 text-[#9ca3af] line-through"
: isSelected
? "bg-white/20 text-white"
: isDark
? "bg-[var(--color-accent-strong)]/20 text-[var(--color-accent-strong)]"
: "bg-[var(--color-accent-strong)]/15 text-[var(--color-accent-strong)]"
}`}
>
{isDisabled ? (
<EyeOff className="h-2.5 w-2.5" />
) : (
<Eye className="h-2.5 w-2.5" />
)}
{EFFECT_LABELS[effect.kind] ?? effect.kind}
</button>
);
})}
</div>
) : null}
</div>
);
})}
{layers.length === 0 ? (
<div className={`px-2 py-8 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}>
<div
className={`px-2 py-8 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}
>
{t("editor.noLayersYet")}
</div>
) : null}
</div>
<div className="mt-2 flex gap-1.5">
<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>
<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>
);
+17 -4
View File
@@ -11,19 +11,32 @@ type Props = {
onSetTool: (tool: EditorToolId) => void;
};
export function ToolRail({ controllers, selectedTool, isDark, icons, onSetTool }: Props) {
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]"}`}>
<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;
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())}
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"