mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
♻️ refactor!: massive refactor
This commit is contained in:
@@ -5,11 +5,15 @@ export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET() {
|
||||
if (process.env.NODE_ENV !== "development") {
|
||||
return NextResponse.json({ hash: process.env.NEXT_PUBLIC_GIT_HASH || "unknown" });
|
||||
return NextResponse.json({
|
||||
hash: process.env.NEXT_PUBLIC_GIT_HASH || "unknown",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const hash = execSync("git rev-parse --short HEAD", { cwd: process.cwd() }).toString().trim();
|
||||
const hash = execSync("git rev-parse --short HEAD", { cwd: process.cwd() })
|
||||
.toString()
|
||||
.trim();
|
||||
return NextResponse.json({ hash });
|
||||
} catch {
|
||||
return NextResponse.json({ hash: "unknown" });
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
|
||||
import React from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { MousePointer2, Hand, ScanFace, PaintBucket, Brush, Type, ImagePlus } from "lucide-react";
|
||||
import { getLayerRuntimeSource } from "@pien-studio/types";
|
||||
import {
|
||||
MousePointer2,
|
||||
Hand,
|
||||
ScanFace,
|
||||
PaintBucket,
|
||||
Brush,
|
||||
Type,
|
||||
ImagePlus,
|
||||
} from "lucide-react";
|
||||
import { useEditorStore } from "../../../store/editor-store";
|
||||
import { useUiStore, isDarkTheme } from "../../../store/ui-store";
|
||||
import { CanvasSizeModal } from "../../../components/canvas-size-modal";
|
||||
@@ -27,7 +36,10 @@ import { useAssetCleanupJob } from "../../../hooks/use-asset-cleanup-job";
|
||||
import { useTranslations } from "../../../hooks/use-translations";
|
||||
import type { AspectRatio } from "@pien-studio/types";
|
||||
|
||||
const TOOL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
const TOOL_ICONS: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
pointer: MousePointer2,
|
||||
hand: Hand,
|
||||
face: ScanFace,
|
||||
@@ -89,7 +101,8 @@ export default function EditorPage() {
|
||||
const { theme, hydrate } = useUiStore((s) => s);
|
||||
const { t } = useTranslations();
|
||||
const { headerLabels, contextMenuLabels, mobileLabels } = useEditorLabels(t);
|
||||
const { contextMenu, openContextMenu, closeContextMenu } = useEditorContextMenu();
|
||||
const { contextMenu, openContextMenu, closeContextMenu } =
|
||||
useEditorContextMenu();
|
||||
const [canvasModalOpen, setCanvasModalOpen] = React.useState(false);
|
||||
const [faceMlErrorModalOpen, setFaceMlErrorModalOpen] = React.useState(false);
|
||||
const [fillColor, setFillColor] = React.useState("#ff0000");
|
||||
@@ -98,16 +111,21 @@ export default function EditorPage() {
|
||||
const [brushSize, setBrushSize] = React.useState(20);
|
||||
const [brushOpacity, setBrushOpacity] = React.useState(1);
|
||||
const [brushHardness, setBrushHardness] = React.useState(0.8);
|
||||
const previousFaceStatusRef = React.useRef<"idle" | "detecting" | "unsupported">("idle");
|
||||
const previousFaceStatusRef = React.useRef<
|
||||
"idle" | "detecting" | "unsupported"
|
||||
>("idle");
|
||||
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const selectedImageLayer = React.useMemo(() => {
|
||||
if (!selectedLayer || selectedLayer.type !== "raster" || !selectedLayer.sourceUri) {
|
||||
const sourceUri = selectedLayer
|
||||
? getLayerRuntimeSource(selectedLayer)
|
||||
: undefined;
|
||||
if (!selectedLayer || selectedLayer.type !== "raster" || !sourceUri) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: selectedLayer.id,
|
||||
sourceUri: selectedLayer.sourceUri,
|
||||
sourceUri,
|
||||
width: selectedLayer.width,
|
||||
height: selectedLayer.height,
|
||||
};
|
||||
@@ -118,11 +136,12 @@ export default function EditorPage() {
|
||||
return state.selectedLayerId === layerId;
|
||||
}, []);
|
||||
|
||||
const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } = useFaceDetection({
|
||||
tool,
|
||||
selectedImageLayer,
|
||||
activeLayerStillSelected: isLayerStillSelected,
|
||||
});
|
||||
const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } =
|
||||
useFaceDetection({
|
||||
tool,
|
||||
selectedImageLayer,
|
||||
activeLayerStillSelected: isLayerStillSelected,
|
||||
});
|
||||
|
||||
const {
|
||||
blurMethod,
|
||||
@@ -204,7 +223,10 @@ export default function EditorPage() {
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (faceStatus === "unsupported" && previousFaceStatusRef.current !== "unsupported") {
|
||||
if (
|
||||
faceStatus === "unsupported" &&
|
||||
previousFaceStatusRef.current !== "unsupported"
|
||||
) {
|
||||
setFaceMlErrorModalOpen(true);
|
||||
}
|
||||
previousFaceStatusRef.current = faceStatus;
|
||||
@@ -217,41 +239,53 @@ export default function EditorPage() {
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
function handleCanvasApply(width: number, height: number, aspect: AspectRatio) {
|
||||
function handleCanvasApply(
|
||||
width: number,
|
||||
height: number,
|
||||
aspect: AspectRatio,
|
||||
) {
|
||||
void aspect;
|
||||
setCanvasSize(width, height);
|
||||
}
|
||||
|
||||
const handleFillLayer = React.useCallback(async (layerId: string, x: number, y: number) => {
|
||||
const layer = project.layers.find((l) => l.id === layerId);
|
||||
if (!layer || layer.type !== "raster" || !layer.sourceUri) return;
|
||||
const layerWidth = layer.width ?? Math.round(200 * layer.scale);
|
||||
const layerHeight = layer.height ?? Math.round(150 * layer.scale);
|
||||
// x/y are in layer CSS-pixel space; scale to image pixel space
|
||||
const img = new Image();
|
||||
const uri = layer.sourceUri;
|
||||
const color = fillColor;
|
||||
const tolerance = fillTolerance;
|
||||
img.onload = () => {
|
||||
const scaleX = img.naturalWidth / layerWidth;
|
||||
const scaleY = img.naturalHeight / layerHeight;
|
||||
const pixelX = x * scaleX;
|
||||
const pixelY = y * scaleY;
|
||||
floodFillDataUrl(uri, pixelX, pixelY, color, tolerance).then((nextUri) => {
|
||||
if (nextUri !== uri) updateImageLayerSource(layerId, nextUri);
|
||||
}).catch(() => {});
|
||||
};
|
||||
img.src = uri;
|
||||
}, [project.layers, fillColor, fillTolerance, updateImageLayerSource]);
|
||||
const handleFillLayer = React.useCallback(
|
||||
async (layerId: string, x: number, y: number) => {
|
||||
const layer = project.layers.find((l) => l.id === layerId);
|
||||
const uri = layer ? getLayerRuntimeSource(layer) : undefined;
|
||||
if (!layer || layer.type !== "raster" || !uri) return;
|
||||
const layerWidth = layer.width;
|
||||
const layerHeight = layer.height;
|
||||
const img = new Image();
|
||||
const color = fillColor;
|
||||
const tolerance = fillTolerance;
|
||||
img.onload = () => {
|
||||
const scaleX = img.naturalWidth / layerWidth;
|
||||
const scaleY = img.naturalHeight / layerHeight;
|
||||
const pixelX = x * scaleX;
|
||||
const pixelY = y * scaleY;
|
||||
floodFillDataUrl(uri, pixelX, pixelY, color, tolerance)
|
||||
.then((nextUri) => {
|
||||
if (nextUri !== uri) updateImageLayerSource(layerId, nextUri);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
img.src = uri;
|
||||
},
|
||||
[project.layers, fillColor, fillTolerance, updateImageLayerSource],
|
||||
);
|
||||
|
||||
const handleBrushCommit = React.useCallback(async (layerId: string, stroke: BrushStroke) => {
|
||||
const layer = project.layers.find((l) => l.id === layerId);
|
||||
if (!layer || layer.type !== "raster" || !layer.sourceUri) return;
|
||||
try {
|
||||
const nextUri = await commitStroke(layer.sourceUri, stroke);
|
||||
updateImageLayerSource(layerId, nextUri);
|
||||
} catch {}
|
||||
}, [project.layers, updateImageLayerSource]);
|
||||
const handleBrushCommit = React.useCallback(
|
||||
async (layerId: string, stroke: BrushStroke) => {
|
||||
const layer = project.layers.find((l) => l.id === layerId);
|
||||
const sourceUri = layer ? getLayerRuntimeSource(layer) : undefined;
|
||||
if (!layer || layer.type !== "raster" || !sourceUri) return;
|
||||
try {
|
||||
const nextUri = await commitStroke(sourceUri, stroke);
|
||||
updateImageLayerSource(layerId, nextUri);
|
||||
} catch {}
|
||||
},
|
||||
[project.layers, updateImageLayerSource],
|
||||
);
|
||||
|
||||
const handleCreateFillLayer = React.useCallback(() => {
|
||||
const canvas = document.createElement("canvas");
|
||||
@@ -275,23 +309,42 @@ export default function EditorPage() {
|
||||
imageInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const toolControllers = React.useMemo<EditorToolController[]>(() => [
|
||||
{ kind: "mode", id: "pointer", label: t("editor.toolPointer") },
|
||||
{ kind: "mode", id: "hand", label: t("editor.toolPan") },
|
||||
{ kind: "mode", id: "face", label: t("editor.toolFace") },
|
||||
{ kind: "mode", id: "fill", label: t("editor.toolFill") },
|
||||
{ kind: "mode", id: "brush", label: t("editor.toolBrush") },
|
||||
{ kind: "action", id: "add-text", label: t("editor.toolText"), run: () => addLayerByType("text") },
|
||||
{ kind: "action", id: "import-image", label: t("editor.toolImage"), run: handleImportImageClick },
|
||||
], [addLayerByType, handleImportImageClick, t]);
|
||||
const toolControllers = React.useMemo<EditorToolController[]>(
|
||||
() => [
|
||||
{ kind: "mode", id: "pointer", label: t("editor.toolPointer") },
|
||||
{ kind: "mode", id: "hand", label: t("editor.toolPan") },
|
||||
{ kind: "mode", id: "face", label: t("editor.toolFace") },
|
||||
{ kind: "mode", id: "fill", label: t("editor.toolFill") },
|
||||
{ kind: "mode", id: "brush", label: t("editor.toolBrush") },
|
||||
{
|
||||
kind: "action",
|
||||
id: "add-text",
|
||||
label: t("editor.toolText"),
|
||||
run: () => addLayerByType("text"),
|
||||
},
|
||||
{
|
||||
kind: "action",
|
||||
id: "import-image",
|
||||
label: t("editor.toolImage"),
|
||||
run: handleImportImageClick,
|
||||
},
|
||||
],
|
||||
[addLayerByType, handleImportImageClick, t],
|
||||
);
|
||||
|
||||
const canvasBindings = {
|
||||
onMoveLayer: (_id: string, x: number, y: number) => setSelectedLayerPositionDraft(x, y),
|
||||
onMoveLayerEnd: (_id: string, x: number, y: number) => setSelectedLayerPosition(x, y),
|
||||
onResizeLayer: (_id: string, width: number, height: number) => setSelectedLayerSizeDraft(width, height),
|
||||
onResizeLayerEnd: (_id: string, width: number, height: number) => setSelectedLayerSize(width, height),
|
||||
onRotateLayer: (_id: string, rotation: number) => setSelectedLayerRotationDraft(rotation),
|
||||
onRotateLayerEnd: (_id: string, rotation: number) => setSelectedLayerRotation(rotation),
|
||||
onMoveLayer: (_id: string, x: number, y: number) =>
|
||||
setSelectedLayerPositionDraft(x, y),
|
||||
onMoveLayerEnd: (_id: string, x: number, y: number) =>
|
||||
setSelectedLayerPosition(x, y),
|
||||
onResizeLayer: (_id: string, width: number, height: number) =>
|
||||
setSelectedLayerSizeDraft(width, height),
|
||||
onResizeLayerEnd: (_id: string, width: number, height: number) =>
|
||||
setSelectedLayerSize(width, height),
|
||||
onRotateLayer: (_id: string, rotation: number) =>
|
||||
setSelectedLayerRotationDraft(rotation),
|
||||
onRotateLayerEnd: (_id: string, rotation: number) =>
|
||||
setSelectedLayerRotation(rotation),
|
||||
onInteractionStart: startTransaction,
|
||||
onInteractionEnd: commitTransaction,
|
||||
};
|
||||
@@ -361,7 +414,12 @@ export default function EditorPage() {
|
||||
onCut={cutSelectedLayer}
|
||||
onPaste={pasteLayer}
|
||||
onFillLayer={handleFillLayer}
|
||||
brushOptions={{ color: brushColor, size: brushSize, opacity: brushOpacity, hardness: brushHardness }}
|
||||
brushOptions={{
|
||||
color: brushColor,
|
||||
size: brushSize,
|
||||
opacity: brushOpacity,
|
||||
hardness: brushHardness,
|
||||
}}
|
||||
onBrushCommit={handleBrushCommit}
|
||||
/>
|
||||
|
||||
@@ -397,7 +455,7 @@ export default function EditorPage() {
|
||||
selectedFaceIndices={selectedFaceIndices}
|
||||
onSelectLayer={selectLayer}
|
||||
onSetLayerVisible={setLayerVisible}
|
||||
onSetEffectEnabled={(layerId, kind, enabled) => setEffectEnabled(layerId, kind as import("@pien-studio/types").LayerEffect["kind"], enabled)}
|
||||
onSetEffectEnabled={setEffectEnabled}
|
||||
onMoveLayerOrder={moveSelectedLayerOrder}
|
||||
onRemoveSelectedLayer={removeSelectedLayer}
|
||||
onUndo={undo}
|
||||
@@ -437,7 +495,13 @@ export default function EditorPage() {
|
||||
onInteractionStart={canvasBindings.onInteractionStart}
|
||||
onInteractionEnd={canvasBindings.onInteractionEnd}
|
||||
/>
|
||||
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={handleImageImport} />
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageImport}
|
||||
/>
|
||||
|
||||
<CanvasSizeModal
|
||||
isOpen={canvasModalOpen}
|
||||
@@ -450,15 +514,24 @@ export default function EditorPage() {
|
||||
/>
|
||||
|
||||
{faceMlErrorModalOpen ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={() => setFaceMlErrorModalOpen(false)}>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={() => setFaceMlErrorModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
className={`w-full max-w-sm rounded-2xl border p-5 shadow-2xl ${
|
||||
isDark ? "border-white/15 bg-[#2b2d31]" : "border-black/15 bg-white"
|
||||
isDark
|
||||
? "border-white/15 bg-[#2b2d31]"
|
||||
: "border-black/15 bg-white"
|
||||
}`}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className={`text-base font-semibold ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}>{t("editor.faceTool")}</h2>
|
||||
<h2
|
||||
className={`text-base font-semibold ${isDark ? "text-[#f5f7fa]" : "text-[#1f2430]"}`}
|
||||
>
|
||||
{t("editor.faceTool")}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFaceMlErrorModalOpen(false)}
|
||||
@@ -467,7 +540,11 @@ export default function EditorPage() {
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? "text-[#d7dae0]" : "text-[#374151]"}`}>{t("editor.faceMlFailed")}</p>
|
||||
<p
|
||||
className={`text-sm ${isDark ? "text-[#d7dae0]" : "text-[#374151]"}`}
|
||||
>
|
||||
{t("editor.faceMlFailed")}
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+259
-107
@@ -2,12 +2,24 @@
|
||||
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { addLayer, createLayer, createProject, parseProjectFile, setCanvasSize } from "@pien-studio/editor-core";
|
||||
import {
|
||||
addLayer,
|
||||
createLayer,
|
||||
createProject,
|
||||
parseProjectFile,
|
||||
setCanvasSize,
|
||||
} from "@pien-studio/editor-core";
|
||||
import type { Project } from "@pien-studio/types";
|
||||
import { deleteProject, duplicateProject, loadProjects, upsertProject } from "@pien-studio/storage";
|
||||
import { localProjectRepository } from "../lib/project-repository";
|
||||
import { useEditorStore } from "../store/editor-store";
|
||||
import { useUiStore, isDarkTheme } from "../store/ui-store";
|
||||
import { accentButtonClass, cx, mutedSurfaceClass, subtleButtonClass, surfaceClass } from "../lib/theme";
|
||||
import {
|
||||
accentButtonClass,
|
||||
cx,
|
||||
mutedSurfaceClass,
|
||||
subtleButtonClass,
|
||||
surfaceClass,
|
||||
} from "../lib/theme";
|
||||
import { UiPreferences } from "../components/ui-preferences";
|
||||
import { useAssetCleanupJob } from "../hooks/use-asset-cleanup-job";
|
||||
import { useTranslations } from "../hooks/use-translations";
|
||||
@@ -20,19 +32,20 @@ export default function HomePage() {
|
||||
const { t } = useTranslations();
|
||||
const [projects, setProjects] = React.useState<Project[]>([]);
|
||||
const [showWipModal, setShowWipModal] = React.useState(true);
|
||||
const [projectPendingDelete, setProjectPendingDelete] = React.useState<Project | null>(null);
|
||||
const [projectPendingDelete, setProjectPendingDelete] =
|
||||
React.useState<Project | null>(null);
|
||||
const projectInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const isDark = isDarkTheme(theme);
|
||||
|
||||
const refreshProjects = React.useCallback(async () => {
|
||||
setProjects(await loadProjects());
|
||||
setProjects(await localProjectRepository.listProjects());
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let canceled = false;
|
||||
hydrate();
|
||||
loadProjects().then((loadedProjects) => {
|
||||
localProjectRepository.listProjects().then((loadedProjects) => {
|
||||
if (!canceled) setProjects(loadedProjects);
|
||||
});
|
||||
return () => {
|
||||
@@ -47,25 +60,27 @@ export default function HomePage() {
|
||||
|
||||
async function confirmDeleteProject() {
|
||||
if (!projectPendingDelete) return;
|
||||
await deleteProject(projectPendingDelete.id);
|
||||
await localProjectRepository.deleteProject(projectPendingDelete.id);
|
||||
await refreshProjects();
|
||||
setProjectPendingDelete(null);
|
||||
}
|
||||
|
||||
async function handleNewProject() {
|
||||
const project = createProject(t("home.untitledProject"));
|
||||
await upsertProject(project);
|
||||
await localProjectRepository.upsertProject(project);
|
||||
openProject(project);
|
||||
}
|
||||
|
||||
async function handleImportProjectFile(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
async function handleImportProjectFile(
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const raw = await file.text();
|
||||
try {
|
||||
const parsed = parseProjectFile(raw);
|
||||
if (!parsed.ok) return;
|
||||
await upsertProject(parsed.project);
|
||||
await localProjectRepository.upsertProject(parsed.project);
|
||||
await refreshProjects();
|
||||
openProject(parsed.project);
|
||||
} finally {
|
||||
@@ -80,34 +95,44 @@ export default function HomePage() {
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const sourceUri = typeof reader.result === "string" ? reader.result : undefined;
|
||||
const sourceUri =
|
||||
typeof reader.result === "string" ? reader.result : undefined;
|
||||
if (!sourceUri) return;
|
||||
|
||||
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
resolve({
|
||||
width: Math.max(1, Math.round(image.naturalWidth)),
|
||||
height: Math.max(1, Math.round(image.naturalHeight)),
|
||||
});
|
||||
};
|
||||
image.onerror = () => resolve({ width: 1, height: 1 });
|
||||
image.src = sourceUri;
|
||||
});
|
||||
const imageSize = await new Promise<{ width: number; height: number }>(
|
||||
(resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
resolve({
|
||||
width: Math.max(1, Math.round(image.naturalWidth)),
|
||||
height: Math.max(1, Math.round(image.naturalHeight)),
|
||||
});
|
||||
};
|
||||
image.onerror = () => resolve({ width: 1, height: 1 });
|
||||
image.src = sourceUri;
|
||||
},
|
||||
);
|
||||
|
||||
const base = createProject(title, "free");
|
||||
const projectWithImageCanvas = setCanvasSize(base, imageSize.width, imageSize.height);
|
||||
const projectWithImageCanvas = setCanvasSize(
|
||||
base,
|
||||
imageSize.width,
|
||||
imageSize.height,
|
||||
);
|
||||
|
||||
const project = addLayer(projectWithImageCanvas, createLayer("raster", {
|
||||
name: file.name,
|
||||
sourceUri,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
}));
|
||||
const project = addLayer(
|
||||
projectWithImageCanvas,
|
||||
createLayer("raster", {
|
||||
name: file.name,
|
||||
asset: { kind: "inline", uri: sourceUri },
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
}),
|
||||
);
|
||||
|
||||
await upsertProject(project);
|
||||
await localProjectRepository.upsertProject(project);
|
||||
await refreshProjects();
|
||||
openProject(project);
|
||||
} finally {
|
||||
@@ -120,15 +145,33 @@ export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
{showWipModal ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4" onClick={() => setShowWipModal(false)}>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4"
|
||||
onClick={() => setShowWipModal(false)}
|
||||
>
|
||||
<div
|
||||
className={cx("w-full max-w-lg rounded-2xl border p-5 shadow-2xl", surfaceClass(isDark))}
|
||||
className={cx(
|
||||
"w-full max-w-lg rounded-2xl border p-5 shadow-2xl",
|
||||
surfaceClass(isDark),
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold">{t("home.wipTitle")}</h2>
|
||||
<p className={cx("mt-2 text-sm leading-relaxed", isDark ? "text-[#c9ced8]" : "text-[#545d6d]")}>{t("home.wipBody")}</p>
|
||||
<p className={cx("mt-3 text-sm leading-relaxed", isDark ? "text-[#c9ced8]" : "text-[#545d6d]")}>
|
||||
{t("home.wipSupportPrefix")} {" "}
|
||||
<p
|
||||
className={cx(
|
||||
"mt-2 text-sm leading-relaxed",
|
||||
isDark ? "text-[#c9ced8]" : "text-[#545d6d]",
|
||||
)}
|
||||
>
|
||||
{t("home.wipBody")}
|
||||
</p>
|
||||
<p
|
||||
className={cx(
|
||||
"mt-3 text-sm leading-relaxed",
|
||||
isDark ? "text-[#c9ced8]" : "text-[#545d6d]",
|
||||
)}
|
||||
>
|
||||
{t("home.wipSupportPrefix")}{" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/YuzuZensai"
|
||||
target="_blank"
|
||||
@@ -142,7 +185,10 @@ export default function HomePage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWipModal(false)}
|
||||
className={cx("rounded border px-3 py-1.5 text-sm font-semibold", accentButtonClass())}
|
||||
className={cx(
|
||||
"rounded border px-3 py-1.5 text-sm font-semibold",
|
||||
accentButtonClass(),
|
||||
)}
|
||||
>
|
||||
{t("home.wipAcknowledge")}
|
||||
</button>
|
||||
@@ -152,20 +198,38 @@ export default function HomePage() {
|
||||
) : null}
|
||||
|
||||
{projectPendingDelete ? (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4" onClick={() => setProjectPendingDelete(null)}>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4"
|
||||
onClick={() => setProjectPendingDelete(null)}
|
||||
>
|
||||
<div
|
||||
className={cx("w-full max-w-md rounded-2xl border p-5 shadow-2xl", surfaceClass(isDark))}
|
||||
className={cx(
|
||||
"w-full max-w-md rounded-2xl border p-5 shadow-2xl",
|
||||
surfaceClass(isDark),
|
||||
)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold">Delete project?</h2>
|
||||
<p className={cx("mt-2 text-sm leading-relaxed", isDark ? "text-[#c9ced8]" : "text-[#545d6d]")}>
|
||||
This will permanently delete <span className="font-semibold">{projectPendingDelete.title}</span> from local storage.
|
||||
<p
|
||||
className={cx(
|
||||
"mt-2 text-sm leading-relaxed",
|
||||
isDark ? "text-[#c9ced8]" : "text-[#545d6d]",
|
||||
)}
|
||||
>
|
||||
This will permanently delete{" "}
|
||||
<span className="font-semibold">
|
||||
{projectPendingDelete.title}
|
||||
</span>{" "}
|
||||
from local storage.
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProjectPendingDelete(null)}
|
||||
className={cx("rounded border px-3 py-1.5 text-sm font-semibold", subtleButtonClass(isDark))}
|
||||
className={cx(
|
||||
"rounded border px-3 py-1.5 text-sm font-semibold",
|
||||
subtleButtonClass(isDark),
|
||||
)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -188,74 +252,162 @@ export default function HomePage() {
|
||||
isDark ? "bg-[#1b1d21] text-[#e8eaed]" : "bg-[#f5f6f8] text-[#1f2430]"
|
||||
}`}
|
||||
>
|
||||
<section className={cx("rounded-xl border p-4 sm:p-5", surfaceClass(isDark))}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className={cx("text-[10px] uppercase tracking-[0.2em]", isDark ? "text-[#a8abb2]" : "text-[#6c7382]")}>pien.studio</p>
|
||||
<h1 className="text-2xl font-semibold">{t("home.projectHub")}</h1>
|
||||
<p className={cx("text-sm", isDark ? "text-[#b9bec8]" : "text-[#5f6672]")}>{t("home.createOpenManage")}</p>
|
||||
</div>
|
||||
<UiPreferences />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewProject}
|
||||
className={cx("rounded border px-3 py-2 text-sm font-semibold", accentButtonClass())}
|
||||
<section
|
||||
className={cx("rounded-xl border p-4 sm:p-5", surfaceClass(isDark))}
|
||||
>
|
||||
{t("home.newProject")}
|
||||
</button>
|
||||
<button type="button" onClick={() => projectInputRef.current?.click()} className={cx("rounded border px-3 py-2 text-sm font-semibold", subtleButtonClass(isDark))}>
|
||||
{t("home.openProjectFile")}
|
||||
</button>
|
||||
<button type="button" onClick={() => imageInputRef.current?.click()} className={cx("rounded border px-3 py-2 text-sm font-semibold", subtleButtonClass(isDark))}>
|
||||
{t("home.openImage")}
|
||||
</button>
|
||||
<input ref={projectInputRef} type="file" accept=".json,.pien.json,application/json" className="hidden" onChange={handleImportProjectFile} />
|
||||
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={handleOpenImage} />
|
||||
</section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p
|
||||
className={cx(
|
||||
"text-[10px] uppercase tracking-[0.2em]",
|
||||
isDark ? "text-[#a8abb2]" : "text-[#6c7382]",
|
||||
)}
|
||||
>
|
||||
pien.studio
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold">{t("home.projectHub")}</h1>
|
||||
<p
|
||||
className={cx(
|
||||
"text-sm",
|
||||
isDark ? "text-[#b9bec8]" : "text-[#5f6672]",
|
||||
)}
|
||||
>
|
||||
{t("home.createOpenManage")}
|
||||
</p>
|
||||
</div>
|
||||
<UiPreferences />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={cx("mt-4 rounded-xl border p-4", surfaceClass(isDark))}>
|
||||
<h2 className={cx("mb-3 text-sm font-semibold uppercase tracking-wide", isDark ? "text-[#c5cad3]" : "text-[#6c7382]")}>{t("home.myProjects")}</h2>
|
||||
{projects.length === 0 ? <p className={cx("text-sm", isDark ? "text-[#aeb3bc]" : "text-[#5f6672]")}>{t("home.noProjectsYet")}</p> : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<article key={project.id} className={cx("rounded border p-3", mutedSurfaceClass(isDark))}>
|
||||
<p className={cx("truncate text-sm font-semibold", isDark ? "text-[#f3f5f8]" : "text-[#1f2430]")}>{project.title}</p>
|
||||
<p className={cx("mt-1 text-xs", isDark ? "text-[#aeb3bc]" : "text-[#5f6672]")}>{new Date(project.updatedAt).toLocaleString()}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openProject(project)}
|
||||
className={cx("rounded border px-2 py-1 text-xs font-semibold", accentButtonClass())}
|
||||
<section className="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewProject}
|
||||
className={cx(
|
||||
"rounded border px-3 py-2 text-sm font-semibold",
|
||||
accentButtonClass(),
|
||||
)}
|
||||
>
|
||||
{t("home.newProject")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => projectInputRef.current?.click()}
|
||||
className={cx(
|
||||
"rounded border px-3 py-2 text-sm font-semibold",
|
||||
subtleButtonClass(isDark),
|
||||
)}
|
||||
>
|
||||
{t("home.openProjectFile")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
className={cx(
|
||||
"rounded border px-3 py-2 text-sm font-semibold",
|
||||
subtleButtonClass(isDark),
|
||||
)}
|
||||
>
|
||||
{t("home.openImage")}
|
||||
</button>
|
||||
<input
|
||||
ref={projectInputRef}
|
||||
type="file"
|
||||
accept=".json,.pien.json,application/json"
|
||||
className="hidden"
|
||||
onChange={handleImportProjectFile}
|
||||
/>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleOpenImage}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className={cx("mt-4 rounded-xl border p-4", surfaceClass(isDark))}
|
||||
>
|
||||
<h2
|
||||
className={cx(
|
||||
"mb-3 text-sm font-semibold uppercase tracking-wide",
|
||||
isDark ? "text-[#c5cad3]" : "text-[#6c7382]",
|
||||
)}
|
||||
>
|
||||
{t("home.myProjects")}
|
||||
</h2>
|
||||
{projects.length === 0 ? (
|
||||
<p
|
||||
className={cx(
|
||||
"text-sm",
|
||||
isDark ? "text-[#aeb3bc]" : "text-[#5f6672]",
|
||||
)}
|
||||
>
|
||||
{t("home.noProjectsYet")}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<article
|
||||
key={project.id}
|
||||
className={cx("rounded border p-3", mutedSurfaceClass(isDark))}
|
||||
>
|
||||
<p
|
||||
className={cx(
|
||||
"truncate text-sm font-semibold",
|
||||
isDark ? "text-[#f3f5f8]" : "text-[#1f2430]",
|
||||
)}
|
||||
>
|
||||
{t("home.open")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void duplicateProject(project.id).then(refreshProjects);
|
||||
}}
|
||||
className={cx("rounded border px-2 py-1 text-xs font-semibold", subtleButtonClass(isDark))}
|
||||
{project.title}
|
||||
</p>
|
||||
<p
|
||||
className={cx(
|
||||
"mt-1 text-xs",
|
||||
isDark ? "text-[#aeb3bc]" : "text-[#5f6672]",
|
||||
)}
|
||||
>
|
||||
{t("home.duplicate")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProjectPendingDelete(project);
|
||||
}}
|
||||
className="rounded border border-red-400/30 bg-red-400/10 px-2 py-1 text-xs font-semibold text-red-200"
|
||||
>
|
||||
{t("home.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
{new Date(project.updatedAt).toLocaleString()}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openProject(project)}
|
||||
className={cx(
|
||||
"rounded border px-2 py-1 text-xs font-semibold",
|
||||
accentButtonClass(),
|
||||
)}
|
||||
>
|
||||
{t("home.open")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void localProjectRepository
|
||||
.duplicateProject(project.id)
|
||||
.then(refreshProjects);
|
||||
}}
|
||||
className={cx(
|
||||
"rounded border px-2 py-1 text-xs font-semibold",
|
||||
subtleButtonClass(isDark),
|
||||
)}
|
||||
>
|
||||
{t("home.duplicate")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProjectPendingDelete(project);
|
||||
}}
|
||||
className="rounded border border-red-400/30 bg-red-400/10 px-2 py-1 text-xs font-semibold text-red-200"
|
||||
>
|
||||
{t("home.delete")}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user