mirror of
https://github.com/YuzuZensai/Pien-Studio.git
synced 2026-09-02 14:18:35 +00:00
✨ feat: initial app
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { MousePointer2, Hand, ScanFace, Type, ImagePlus } from "lucide-react";
|
||||
import { useEditorStore } from "../../../store/editor-store";
|
||||
import { useUiStore } from "../../../store/ui-store";
|
||||
import { CanvasSizeModal } from "../../../components/canvas-size-modal";
|
||||
import { EditorCanvasStage } from "../../../components/editor/editor-canvas-stage";
|
||||
import { EditorHeader } from "../../../components/editor/editor-header";
|
||||
import { EditorMobileSection } from "../../../components/editor/editor-mobile-section";
|
||||
import { EditorSidebar } from "../../../components/editor/editor-sidebar";
|
||||
import { ToolRail } from "../../../components/editor/tool-rail";
|
||||
import { exportProjectAsPng } from "../../../lib/export-png";
|
||||
import { createEditorToolControllers } from "../../../lib/editor-tool-controller";
|
||||
import { useFaceDetection } from "../../../hooks/use-face-detection";
|
||||
import { useFaceBlurWorkflow } from "../../../hooks/use-face-blur-workflow";
|
||||
import { useEditorAutosave } from "../../../hooks/use-editor-autosave";
|
||||
import { useEditorBindings } from "../../../hooks/use-editor-bindings";
|
||||
import { useEditorContextMenu } from "../../../hooks/use-editor-context-menu";
|
||||
import { useEditorLabels } from "../../../hooks/use-editor-labels";
|
||||
import { useEditorProjectLifecycle } from "../../../hooks/use-editor-project-lifecycle";
|
||||
import { useEditorShortcuts } from "../../../hooks/use-editor-shortcuts";
|
||||
import { useAssetCleanupJob } from "../../../hooks/use-asset-cleanup-job";
|
||||
import { useTranslations } from "../../../hooks/use-translations";
|
||||
import type { AspectRatio } from "@pien-studio/types";
|
||||
|
||||
const TOOL_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
pointer: MousePointer2,
|
||||
hand: Hand,
|
||||
face: ScanFace,
|
||||
"add-text": Type,
|
||||
"import-image": ImagePlus,
|
||||
};
|
||||
|
||||
export default function EditorPage() {
|
||||
useAssetCleanupJob();
|
||||
const params = useParams<{ projectId: string }>();
|
||||
const projectId = params.projectId;
|
||||
const { state, actions } = useEditorBindings();
|
||||
const {
|
||||
project,
|
||||
selectedLayerId,
|
||||
selectedLayer,
|
||||
canUndo,
|
||||
canRedo,
|
||||
history,
|
||||
isDirty,
|
||||
tool,
|
||||
} = state;
|
||||
const {
|
||||
loadProjectById,
|
||||
setProject,
|
||||
saveCurrentProject,
|
||||
selectLayer,
|
||||
setSelectedLayerPosition,
|
||||
setSelectedLayerPositionDraft,
|
||||
setSelectedLayerSize,
|
||||
setSelectedLayerSizeDraft,
|
||||
setSelectedLayerRotation,
|
||||
setSelectedLayerRotationDraft,
|
||||
startTransaction,
|
||||
commitTransaction,
|
||||
removeSelectedLayer,
|
||||
addLayerByType,
|
||||
moveSelectedLayerOrder,
|
||||
importImageFromFile,
|
||||
setImageLayerFaceBlur,
|
||||
setCanvasSize,
|
||||
setTool,
|
||||
undo,
|
||||
redo,
|
||||
exportProjectToJson,
|
||||
jumpToPast,
|
||||
jumpToFuture,
|
||||
copySelectedLayer,
|
||||
cutSelectedLayer,
|
||||
pasteLayer,
|
||||
} = actions;
|
||||
const { theme, hydrate } = useUiStore((s) => s);
|
||||
const { t } = useTranslations();
|
||||
const { headerLabels, contextMenuLabels, mobileLabels } = useEditorLabels(t);
|
||||
const { contextMenu, openContextMenu, closeContextMenu } = useEditorContextMenu();
|
||||
const [canvasModalOpen, setCanvasModalOpen] = React.useState(false);
|
||||
const [faceMlErrorModalOpen, setFaceMlErrorModalOpen] = React.useState(false);
|
||||
const previousFaceStatusRef = React.useRef<"idle" | "detecting" | "unsupported">("idle");
|
||||
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const selectedImageLayer = React.useMemo(() => {
|
||||
if (!selectedLayer || selectedLayer.type !== "image" || !selectedLayer.sourceUri) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
id: selectedLayer.id,
|
||||
sourceUri: selectedLayer.sourceUri,
|
||||
width: selectedLayer.width,
|
||||
height: selectedLayer.height,
|
||||
};
|
||||
}, [selectedLayer?.id, selectedLayer?.type, selectedLayer?.sourceUri, selectedLayer?.width, selectedLayer?.height]);
|
||||
|
||||
const isLayerStillSelected = React.useCallback((layerId: string) => {
|
||||
const state = useEditorStore.getState();
|
||||
return state.selectedLayerId === layerId;
|
||||
}, []);
|
||||
|
||||
const { faceDetections, faceDetectionsLayerId, facePreviews, faceStatus } = useFaceDetection({
|
||||
tool,
|
||||
selectedLayerId,
|
||||
selectedImageLayer,
|
||||
activeLayerStillSelected: isLayerStillSelected,
|
||||
});
|
||||
|
||||
const {
|
||||
blurMethod,
|
||||
setBlurMethod,
|
||||
blurAmount,
|
||||
setBlurAmount,
|
||||
censorColor,
|
||||
setCensorColor,
|
||||
selectedFaceIndices,
|
||||
faceBlurPreview,
|
||||
toggleFaceIndex,
|
||||
clearBlur,
|
||||
blurFaces,
|
||||
} = useFaceBlurWorkflow({
|
||||
selectedLayer,
|
||||
faceDetectionsLayerId,
|
||||
faceDetections,
|
||||
setImageLayerFaceBlur,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (isDirty) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
}
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, [isDirty]);
|
||||
|
||||
useEditorProjectLifecycle({
|
||||
projectId,
|
||||
hydrate,
|
||||
loadProjectById,
|
||||
setProject,
|
||||
t,
|
||||
});
|
||||
|
||||
const isDark = theme === "dark";
|
||||
const { width: cw, height: ch } = project.canvas;
|
||||
|
||||
const handleSave = React.useCallback(async () => {
|
||||
await saveCurrentProject();
|
||||
}, [saveCurrentProject]);
|
||||
|
||||
const handleExportPng = React.useCallback(async () => {
|
||||
await exportProjectAsPng(project, { isDark });
|
||||
}, [isDark, project, exportProjectAsPng]);
|
||||
|
||||
const handleExportProjectFile = React.useCallback(() => {
|
||||
const json = exportProjectToJson();
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const href = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.download = `${project.title || "project"}.pien.json`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(href);
|
||||
}, [exportProjectToJson, project.title]);
|
||||
|
||||
useEditorShortcuts({
|
||||
onSave: handleSave,
|
||||
onCopy: copySelectedLayer,
|
||||
onCut: cutSelectedLayer,
|
||||
onPaste: pasteLayer,
|
||||
onDelete: removeSelectedLayer,
|
||||
});
|
||||
|
||||
useEditorAutosave({
|
||||
isDirty,
|
||||
projectUpdatedAt: project.updatedAt,
|
||||
saveCurrentProject,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (faceStatus === "unsupported" && previousFaceStatusRef.current !== "unsupported") {
|
||||
setFaceMlErrorModalOpen(true);
|
||||
}
|
||||
previousFaceStatusRef.current = faceStatus;
|
||||
}, [faceStatus]);
|
||||
|
||||
async function handleImageImport(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
await importImageFromFile(file);
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
function handleCanvasApply(width: number, height: number, aspect: AspectRatio) {
|
||||
setCanvasSize(width, height);
|
||||
}
|
||||
|
||||
const toolControllers = React.useMemo(
|
||||
() =>
|
||||
createEditorToolControllers({
|
||||
onAddTextLayer: () => addLayerByType("text"),
|
||||
onImportImage: () => imageInputRef.current?.click(),
|
||||
labels: {
|
||||
pointer: t("editor.toolPointer"),
|
||||
pan: t("editor.toolPan"),
|
||||
face: t("editor.toolFace"),
|
||||
text: t("editor.toolText"),
|
||||
image: t("editor.toolImage"),
|
||||
},
|
||||
}),
|
||||
[addLayerByType, t],
|
||||
);
|
||||
|
||||
const canvasBindings = {
|
||||
onMoveLayer: (_id: string, x: number, y: number) => setSelectedLayerPositionDraft(x, y),
|
||||
onMoveLayerEnd: (_id: string, x: number, y: number) => setSelectedLayerPosition(x, y),
|
||||
onResizeLayer: (_id: string, width: number, height: number) => setSelectedLayerSizeDraft(width, height),
|
||||
onResizeLayerEnd: (_id: string, width: number, height: number) => setSelectedLayerSize(width, height),
|
||||
onRotateLayer: (_id: string, rotation: number) => setSelectedLayerRotationDraft(rotation),
|
||||
onRotateLayerEnd: (_id: string, rotation: number) => setSelectedLayerRotation(rotation),
|
||||
onInteractionStart: startTransaction,
|
||||
onInteractionEnd: commitTransaction,
|
||||
};
|
||||
|
||||
return (
|
||||
<main
|
||||
className={`h-screen p-0 overflow-hidden ${isDark ? "bg-[#202124] text-[#e8eaed]" : "bg-[#f2f4f8] text-[#1f2430]"}`}
|
||||
onClick={closeContextMenu}
|
||||
>
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<EditorHeader
|
||||
isDark={isDark}
|
||||
projectTitle={project.title}
|
||||
canvasWidth={cw}
|
||||
canvasHeight={ch}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
isDirty={isDirty}
|
||||
labels={headerLabels}
|
||||
onSave={handleSave}
|
||||
onExportPng={handleExportPng}
|
||||
onExportProjectFile={handleExportProjectFile}
|
||||
onImportImage={() => imageInputRef.current?.click()}
|
||||
onOpenCanvasSize={() => setCanvasModalOpen(true)}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onCopy={copySelectedLayer}
|
||||
onCut={cutSelectedLayer}
|
||||
onPaste={pasteLayer}
|
||||
onSetHandTool={() => setTool("hand")}
|
||||
onSetPointerTool={() => setTool("pointer")}
|
||||
/>
|
||||
|
||||
<section className="hidden h-[calc(100vh-56px)] grid-cols-[68px_1fr_300px] lg:grid">
|
||||
<ToolRail
|
||||
controllers={toolControllers}
|
||||
selectedTool={tool}
|
||||
isDark={isDark}
|
||||
icons={TOOL_ICONS}
|
||||
onSetTool={setTool}
|
||||
/>
|
||||
|
||||
<EditorCanvasStage
|
||||
isDark={isDark}
|
||||
layers={project.layers}
|
||||
canvasWidth={cw}
|
||||
canvasHeight={ch}
|
||||
selectedLayerId={selectedLayerId}
|
||||
tool={tool}
|
||||
faceDetections={faceDetections}
|
||||
faceOverlayLayerId={faceDetectionsLayerId}
|
||||
faceBlurPreview={faceBlurPreview}
|
||||
contextMenu={contextMenu}
|
||||
labels={contextMenuLabels}
|
||||
onSelectLayer={selectLayer}
|
||||
onMoveLayer={canvasBindings.onMoveLayer}
|
||||
onMoveLayerEnd={canvasBindings.onMoveLayerEnd}
|
||||
onResizeLayer={canvasBindings.onResizeLayer}
|
||||
onResizeLayerEnd={canvasBindings.onResizeLayerEnd}
|
||||
onRotateLayer={canvasBindings.onRotateLayer}
|
||||
onRotateLayerEnd={canvasBindings.onRotateLayerEnd}
|
||||
onInteractionStart={canvasBindings.onInteractionStart}
|
||||
onInteractionEnd={canvasBindings.onInteractionEnd}
|
||||
onContextMenu={openContextMenu}
|
||||
onCloseContextMenu={closeContextMenu}
|
||||
onCopy={copySelectedLayer}
|
||||
onCut={cutSelectedLayer}
|
||||
onPaste={pasteLayer}
|
||||
/>
|
||||
|
||||
<EditorSidebar
|
||||
isDark={isDark}
|
||||
tool={tool}
|
||||
layers={project.layers}
|
||||
selectedLayerId={selectedLayerId}
|
||||
selectedLayer={selectedLayer}
|
||||
history={history}
|
||||
canUndo={canUndo}
|
||||
canRedo={canRedo}
|
||||
faceDetections={faceDetections}
|
||||
facePreviews={facePreviews}
|
||||
faceStatus={faceStatus}
|
||||
blurMethod={blurMethod}
|
||||
blurAmount={blurAmount}
|
||||
censorColor={censorColor}
|
||||
selectedFaceIndices={selectedFaceIndices}
|
||||
onSelectLayer={selectLayer}
|
||||
onMoveLayerOrder={moveSelectedLayerOrder}
|
||||
onRemoveSelectedLayer={removeSelectedLayer}
|
||||
onUndo={undo}
|
||||
onRedo={redo}
|
||||
onJumpToPast={jumpToPast}
|
||||
onJumpToFuture={jumpToFuture}
|
||||
onSetBlurMethod={setBlurMethod}
|
||||
onSetBlurAmount={setBlurAmount}
|
||||
onSetCensorColor={setCensorColor}
|
||||
onToggleFaceIndex={toggleFaceIndex}
|
||||
onBlur={(indices) => void blurFaces(indices)}
|
||||
onClearBlur={clearBlur}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<EditorMobileSection
|
||||
isDark={isDark}
|
||||
canvasWidth={cw}
|
||||
canvasHeight={ch}
|
||||
layers={project.layers}
|
||||
selectedLayerId={selectedLayerId}
|
||||
tool={tool}
|
||||
faceDetections={faceDetections}
|
||||
faceOverlayLayerId={faceDetectionsLayerId}
|
||||
faceStatus={faceStatus}
|
||||
faceBlurPreview={faceBlurPreview}
|
||||
labels={mobileLabels}
|
||||
onOpenCanvasSize={() => setCanvasModalOpen(true)}
|
||||
onImportImage={() => imageInputRef.current?.click()}
|
||||
onSelectLayer={selectLayer}
|
||||
onMoveLayer={canvasBindings.onMoveLayer}
|
||||
onMoveLayerEnd={canvasBindings.onMoveLayerEnd}
|
||||
onResizeLayer={canvasBindings.onResizeLayer}
|
||||
onResizeLayerEnd={canvasBindings.onResizeLayerEnd}
|
||||
onRotateLayer={canvasBindings.onRotateLayer}
|
||||
onRotateLayerEnd={canvasBindings.onRotateLayerEnd}
|
||||
onInteractionStart={canvasBindings.onInteractionStart}
|
||||
onInteractionEnd={canvasBindings.onInteractionEnd}
|
||||
/>
|
||||
<input ref={imageInputRef} type="file" accept="image/*" className="hidden" onChange={handleImageImport} />
|
||||
|
||||
<CanvasSizeModal
|
||||
isOpen={canvasModalOpen}
|
||||
onClose={() => setCanvasModalOpen(false)}
|
||||
currentWidth={cw}
|
||||
currentHeight={ch}
|
||||
currentAspect={project.aspectRatio}
|
||||
onApply={handleCanvasApply}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{faceMlErrorModalOpen ? (
|
||||
<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"
|
||||
}`}
|
||||
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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFaceMlErrorModalOpen(false)}
|
||||
className={`rounded border px-2 py-0.5 text-xs ${isDark ? "border-white/20 text-[#d7dae0]" : "border-black/20 text-[#1f2430]"}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? "text-[#d7dae0]" : "text-[#374151]"}`}>{t("editor.faceMlFailed")}</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFaceMlErrorModalOpen(false)}
|
||||
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.dismiss")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.ts";
|
||||
|
||||
:root {
|
||||
--color-cream: #fff7ef;
|
||||
--color-berry: #cc5d8f;
|
||||
--color-wine: #5a2339;
|
||||
--color-mint: #c9f0df;
|
||||
--color-ink: #1f1b24;
|
||||
--color-accent: #f5a3c7;
|
||||
--color-accent-strong: #e782b1;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-2: #f5f6f8;
|
||||
--bg-a: #f5f6f8;
|
||||
--bg-b: #f5f6f8;
|
||||
--bg-c: #f5f6f8;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--color-cream: #1f2126;
|
||||
--color-berry: #d979a9;
|
||||
--color-wine: #f1d9e7;
|
||||
--color-mint: #2d3a35;
|
||||
--color-ink: #eef1f5;
|
||||
--color-accent: #f3a9cc;
|
||||
--color-accent-strong: #e88bb8;
|
||||
--color-surface: #2a2c31;
|
||||
--color-surface-2: #23252a;
|
||||
--bg-a: #1b1d21;
|
||||
--bg-b: #1b1d21;
|
||||
--bg-c: #1b1d21;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
color: var(--color-ink);
|
||||
background: var(--bg-a), var(--bg-b), var(--bg-c);
|
||||
min-height: 100vh;
|
||||
font-family: "Zen Kaku Gothic New", "Noto Sans Thai", sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "pien.studio",
|
||||
description: "Local-first expressive image editor",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { addLayer, createLayer, createProject, parseProjectFile, setCanvasSize } from "@pien-studio/editor-core";
|
||||
import type { Project } from "@pien-studio/types";
|
||||
import { deleteProject, duplicateProject, loadProjects, upsertProject } from "@pien-studio/storage";
|
||||
import { useEditorStore } from "../store/editor-store";
|
||||
import { useUiStore } from "../store/ui-store";
|
||||
import { accentButtonClass, cx, mutedSurfaceClass, subtleButtonClass, surfaceClass } from "../lib/theme";
|
||||
import { UiPreferences } from "../components/ui-preferences";
|
||||
import { useAssetCleanupJob } from "../hooks/use-asset-cleanup-job";
|
||||
import { useTranslations } from "../hooks/use-translations";
|
||||
|
||||
export default function HomePage() {
|
||||
useAssetCleanupJob();
|
||||
const router = useRouter();
|
||||
const setProject = useEditorStore((s) => s.setProject);
|
||||
const { theme, hydrate } = useUiStore((s) => s);
|
||||
const { t } = useTranslations();
|
||||
const [projects, setProjects] = React.useState<Project[]>([]);
|
||||
const [showWipModal, setShowWipModal] = React.useState(true);
|
||||
const [projectPendingDelete, setProjectPendingDelete] = React.useState<Project | null>(null);
|
||||
const projectInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const isDark = theme === "dark";
|
||||
|
||||
const refreshProjects = React.useCallback(async () => {
|
||||
setProjects(await loadProjects());
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
hydrate();
|
||||
void refreshProjects();
|
||||
}, [hydrate, refreshProjects]);
|
||||
|
||||
function openProject(project: Project) {
|
||||
setProject(project);
|
||||
router.push(`/editor/${project.id}`);
|
||||
}
|
||||
|
||||
async function confirmDeleteProject() {
|
||||
if (!projectPendingDelete) return;
|
||||
await deleteProject(projectPendingDelete.id);
|
||||
await refreshProjects();
|
||||
setProjectPendingDelete(null);
|
||||
}
|
||||
|
||||
async function handleNewProject() {
|
||||
const project = createProject(t("home.untitledProject"));
|
||||
await upsertProject(project);
|
||||
openProject(project);
|
||||
}
|
||||
|
||||
async function handleImportProjectFile(event: React.ChangeEvent<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 refreshProjects();
|
||||
openProject(parsed.project);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenImage(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
const title = file.name.replace(/\.[^/.]+$/, "") || t("home.imageProject");
|
||||
const reader = new FileReader();
|
||||
reader.onload = async () => {
|
||||
try {
|
||||
const sourceUri = typeof reader.result === "string" ? reader.result : undefined;
|
||||
if (!sourceUri) return;
|
||||
|
||||
const imageSize = await new Promise<{ width: number; height: number }>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
resolve({
|
||||
width: Math.max(1, Math.round(image.naturalWidth)),
|
||||
height: Math.max(1, Math.round(image.naturalHeight)),
|
||||
});
|
||||
};
|
||||
image.onerror = () => resolve({ width: 1, height: 1 });
|
||||
image.src = sourceUri;
|
||||
});
|
||||
|
||||
const base = createProject(title, "free");
|
||||
const projectWithImageCanvas = setCanvasSize(base, imageSize.width, imageSize.height);
|
||||
|
||||
const project = addLayer(projectWithImageCanvas, createLayer("image", {
|
||||
name: file.name,
|
||||
sourceUri,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: imageSize.width,
|
||||
height: imageSize.height,
|
||||
}));
|
||||
|
||||
await upsertProject(project);
|
||||
await refreshProjects();
|
||||
openProject(project);
|
||||
} finally {
|
||||
event.target.value = "";
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showWipModal ? (
|
||||
<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))}
|
||||
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")} {" "}
|
||||
<a
|
||||
href="https://github.com/sponsors/YuzuZensai"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="font-semibold text-[var(--color-accent-strong)] underline underline-offset-2"
|
||||
>
|
||||
github.com/sponsors/YuzuZensai
|
||||
</a>
|
||||
</p>
|
||||
<div className="mt-5 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowWipModal(false)}
|
||||
className={cx("rounded border px-3 py-1.5 text-sm font-semibold", accentButtonClass())}
|
||||
>
|
||||
{t("home.wipAcknowledge")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{projectPendingDelete ? (
|
||||
<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))}
|
||||
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>
|
||||
<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))}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void confirmDeleteProject();
|
||||
}}
|
||||
className="rounded border border-red-500/40 bg-red-500/15 px-3 py-1.5 text-sm font-semibold text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<main
|
||||
className={`min-h-screen w-full px-4 py-5 sm:px-6 ${
|
||||
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())}
|
||||
>
|
||||
{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]")}>{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())}
|
||||
>
|
||||
{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))}
|
||||
>
|
||||
{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