2026-05-10 03:06:54 +07:00
|
|
|
"use client";
|
|
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
import {
|
|
|
|
|
getLayerRuntimeSource,
|
|
|
|
|
type Layer,
|
|
|
|
|
type LayerEffect,
|
|
|
|
|
} from "@pien-studio/types";
|
2026-05-10 03:06:54 +07:00
|
|
|
import Image from "next/image";
|
2026-05-21 00:02:12 +07:00
|
|
|
import { Eye, EyeOff } from "lucide-react";
|
2026-05-10 03:06:54 +07:00
|
|
|
import { useTranslations } from "../../hooks/use-translations";
|
2026-05-31 03:03:49 +07:00
|
|
|
import {
|
|
|
|
|
panelClass,
|
|
|
|
|
panelCounterClass,
|
|
|
|
|
panelInsetClass,
|
|
|
|
|
panelTitleClass,
|
|
|
|
|
} from "../../lib/theme";
|
2026-05-10 03:06:54 +07:00
|
|
|
|
2026-05-21 00:02:12 +07:00
|
|
|
const EFFECT_LABELS: Record<string, string> = {
|
|
|
|
|
"face-blur": "Face Blur",
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-10 03:06:54 +07:00
|
|
|
type Props = {
|
|
|
|
|
layers: Layer[];
|
|
|
|
|
selectedLayerId: string | null;
|
|
|
|
|
isDark: boolean;
|
|
|
|
|
onSelectLayer: (layerId: string) => void;
|
2026-05-21 00:02:12 +07:00
|
|
|
onSetLayerVisible: (layerId: string, visible: boolean) => void;
|
2026-05-31 03:03:49 +07:00
|
|
|
onSetEffectEnabled: (
|
|
|
|
|
layerId: string,
|
|
|
|
|
kind: LayerEffect["kind"],
|
|
|
|
|
enabled: boolean,
|
|
|
|
|
) => void;
|
2026-05-10 03:06:54 +07:00
|
|
|
onMoveLayerOrder: (direction: "up" | "down") => void;
|
|
|
|
|
onRemoveSelectedLayer: () => void;
|
2026-05-21 00:02:12 +07:00
|
|
|
onAddLayer?: () => void;
|
2026-05-10 03:06:54 +07:00
|
|
|
};
|
|
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
export function LayersPanel({
|
|
|
|
|
layers,
|
|
|
|
|
selectedLayerId,
|
|
|
|
|
isDark,
|
|
|
|
|
onSelectLayer,
|
|
|
|
|
onSetLayerVisible,
|
|
|
|
|
onSetEffectEnabled,
|
|
|
|
|
onMoveLayerOrder,
|
|
|
|
|
onRemoveSelectedLayer,
|
|
|
|
|
onAddLayer,
|
|
|
|
|
}: Props) {
|
2026-05-10 03:06:54 +07:00
|
|
|
const { t } = useTranslations();
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className={panelClass(isDark)}>
|
|
|
|
|
<div className="flex items-center justify-between">
|
2026-05-31 03:03:49 +07:00
|
|
|
<h2
|
|
|
|
|
className={`text-xs font-semibold uppercase tracking-wide ${panelTitleClass(isDark)}`}
|
|
|
|
|
>
|
|
|
|
|
{t("editor.layers")}
|
|
|
|
|
</h2>
|
2026-05-21 00:02:12 +07:00
|
|
|
<div className="flex items-center gap-1.5">
|
|
|
|
|
{onAddLayer ? (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={onAddLayer}
|
|
|
|
|
title={t("editor.newLayer")}
|
|
|
|
|
className={`rounded border px-1.5 py-0.5 text-[10px] font-semibold ${isDark ? "border-white/20 text-[#d7dae0] hover:bg-white/10" : "border-black/20 text-[#1f2430] hover:bg-black/5"}`}
|
|
|
|
|
>
|
|
|
|
|
+ New
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
2026-05-31 03:03:49 +07:00
|
|
|
<span
|
|
|
|
|
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${panelCounterClass(isDark)}`}
|
|
|
|
|
>
|
2026-05-21 00:02:12 +07:00
|
|
|
{layers.length}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
2026-05-10 03:06:54 +07:00
|
|
|
</div>
|
2026-05-31 03:03:49 +07:00
|
|
|
<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" : ""}`}
|
2026-05-21 00:02:12 +07:00
|
|
|
>
|
2026-05-31 03:03:49 +07:00
|
|
|
<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" />
|
2026-05-21 00:02:12 +07:00
|
|
|
) : (
|
2026-05-31 03:03:49 +07:00
|
|
|
<Eye className="h-3.5 w-3.5" />
|
2026-05-21 00:02:12 +07:00
|
|
|
)}
|
2026-05-31 03:03:49 +07:00
|
|
|
</button>
|
2026-05-21 00:02:12 +07:00
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
<span
|
|
|
|
|
className={`shrink-0 text-[10px] font-semibold ${isSelected ? "text-white/60" : isDark ? "text-[#9aa1ad]" : "text-[#6b7280]"}`}
|
|
|
|
|
>
|
|
|
|
|
{layers.length - idx}
|
|
|
|
|
</span>
|
2026-05-21 00:02:12 +07:00
|
|
|
</div>
|
2026-05-31 03:03:49 +07:00
|
|
|
|
|
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-05-21 00:02:12 +07:00
|
|
|
{layers.length === 0 ? (
|
2026-05-31 03:03:49 +07:00
|
|
|
<div
|
|
|
|
|
className={`px-2 py-8 text-center text-xs ${isDark ? "text-[#aeb3bc]" : "text-[#5f6672]"}`}
|
|
|
|
|
>
|
2026-05-21 00:02:12 +07:00
|
|
|
{t("editor.noLayersYet")}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
2026-05-10 03:06:54 +07:00
|
|
|
</div>
|
2026-05-21 00:02:12 +07:00
|
|
|
<div className="mt-2 flex gap-1.5">
|
2026-05-31 03:03:49 +07:00
|
|
|
<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>
|
2026-05-10 03:06:54 +07:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|