2026-05-10 03:06:54 +07:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import React from "react";
|
|
|
|
|
import { useRouter } from "next/navigation";
|
2026-05-31 03:03:49 +07:00
|
|
|
import {
|
|
|
|
|
addLayer,
|
|
|
|
|
createLayer,
|
|
|
|
|
createProject,
|
|
|
|
|
parseProjectFile,
|
|
|
|
|
setCanvasSize,
|
|
|
|
|
} from "@pien-studio/editor-core";
|
2026-05-10 03:06:54 +07:00
|
|
|
import type { Project } from "@pien-studio/types";
|
2026-05-31 03:03:49 +07:00
|
|
|
import { localProjectRepository } from "../lib/project-repository";
|
2026-05-10 03:06:54 +07:00
|
|
|
import { useEditorStore } from "../store/editor-store";
|
2026-05-16 11:50:53 +07:00
|
|
|
import { useUiStore, isDarkTheme } from "../store/ui-store";
|
2026-05-31 03:03:49 +07:00
|
|
|
import {
|
|
|
|
|
accentButtonClass,
|
|
|
|
|
cx,
|
|
|
|
|
mutedSurfaceClass,
|
|
|
|
|
subtleButtonClass,
|
|
|
|
|
surfaceClass,
|
|
|
|
|
} from "../lib/theme";
|
2026-05-10 03:06:54 +07:00
|
|
|
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);
|
2026-05-31 03:03:49 +07:00
|
|
|
const [projectPendingDelete, setProjectPendingDelete] =
|
|
|
|
|
React.useState<Project | null>(null);
|
2026-05-10 03:06:54 +07:00
|
|
|
const projectInputRef = React.useRef<HTMLInputElement | null>(null);
|
|
|
|
|
const imageInputRef = React.useRef<HTMLInputElement | null>(null);
|
2026-05-16 11:50:53 +07:00
|
|
|
const isDark = isDarkTheme(theme);
|
2026-05-10 03:06:54 +07:00
|
|
|
|
|
|
|
|
const refreshProjects = React.useCallback(async () => {
|
2026-05-31 03:03:49 +07:00
|
|
|
setProjects(await localProjectRepository.listProjects());
|
2026-05-10 03:06:54 +07:00
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
React.useEffect(() => {
|
2026-05-24 21:05:21 +07:00
|
|
|
let canceled = false;
|
2026-05-10 03:06:54 +07:00
|
|
|
hydrate();
|
2026-05-31 03:03:49 +07:00
|
|
|
localProjectRepository.listProjects().then((loadedProjects) => {
|
2026-05-24 21:05:21 +07:00
|
|
|
if (!canceled) setProjects(loadedProjects);
|
|
|
|
|
});
|
|
|
|
|
return () => {
|
|
|
|
|
canceled = true;
|
|
|
|
|
};
|
|
|
|
|
}, [hydrate]);
|
2026-05-10 03:06:54 +07:00
|
|
|
|
|
|
|
|
function openProject(project: Project) {
|
|
|
|
|
setProject(project);
|
|
|
|
|
router.push(`/editor/${project.id}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function confirmDeleteProject() {
|
|
|
|
|
if (!projectPendingDelete) return;
|
2026-05-31 03:03:49 +07:00
|
|
|
await localProjectRepository.deleteProject(projectPendingDelete.id);
|
2026-05-10 03:06:54 +07:00
|
|
|
await refreshProjects();
|
|
|
|
|
setProjectPendingDelete(null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleNewProject() {
|
|
|
|
|
const project = createProject(t("home.untitledProject"));
|
2026-05-31 03:03:49 +07:00
|
|
|
await localProjectRepository.upsertProject(project);
|
2026-05-10 03:06:54 +07:00
|
|
|
openProject(project);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
async function handleImportProjectFile(
|
|
|
|
|
event: React.ChangeEvent<HTMLInputElement>,
|
|
|
|
|
) {
|
2026-05-10 03:06:54 +07:00
|
|
|
const file = event.target.files?.[0];
|
|
|
|
|
if (!file) return;
|
|
|
|
|
const raw = await file.text();
|
|
|
|
|
try {
|
|
|
|
|
const parsed = parseProjectFile(raw);
|
|
|
|
|
if (!parsed.ok) return;
|
2026-05-31 03:03:49 +07:00
|
|
|
await localProjectRepository.upsertProject(parsed.project);
|
2026-05-10 03:06:54 +07:00
|
|
|
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 {
|
2026-05-31 03:03:49 +07:00
|
|
|
const sourceUri =
|
|
|
|
|
typeof reader.result === "string" ? reader.result : undefined;
|
2026-05-10 03:06:54 +07:00
|
|
|
if (!sourceUri) return;
|
|
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
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;
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
|
|
|
|
|
const base = createProject(title, "free");
|
2026-05-31 03:03:49 +07:00
|
|
|
const projectWithImageCanvas = setCanvasSize(
|
|
|
|
|
base,
|
|
|
|
|
imageSize.width,
|
|
|
|
|
imageSize.height,
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
const project = addLayer(
|
|
|
|
|
projectWithImageCanvas,
|
|
|
|
|
createLayer("raster", {
|
|
|
|
|
name: file.name,
|
|
|
|
|
asset: { kind: "inline", uri: sourceUri },
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
width: imageSize.width,
|
|
|
|
|
height: imageSize.height,
|
|
|
|
|
}),
|
|
|
|
|
);
|
2026-05-10 03:06:54 +07:00
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
await localProjectRepository.upsertProject(project);
|
2026-05-10 03:06:54 +07:00
|
|
|
await refreshProjects();
|
|
|
|
|
openProject(project);
|
|
|
|
|
} finally {
|
|
|
|
|
event.target.value = "";
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
reader.readAsDataURL(file);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{showWipModal ? (
|
2026-05-31 03:03:49 +07:00
|
|
|
<div
|
|
|
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4"
|
|
|
|
|
onClick={() => setShowWipModal(false)}
|
|
|
|
|
>
|
2026-05-10 03:06:54 +07:00
|
|
|
<div
|
2026-05-31 03:03:49 +07:00
|
|
|
className={cx(
|
|
|
|
|
"w-full max-w-lg rounded-2xl border p-5 shadow-2xl",
|
|
|
|
|
surfaceClass(isDark),
|
|
|
|
|
)}
|
2026-05-10 03:06:54 +07:00
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
<h2 className="text-lg font-semibold">{t("home.wipTitle")}</h2>
|
2026-05-31 03:03:49 +07:00
|
|
|
<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")}{" "}
|
2026-05-10 03:06:54 +07:00
|
|
|
<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)}
|
2026-05-31 03:03:49 +07:00
|
|
|
className={cx(
|
|
|
|
|
"rounded border px-3 py-1.5 text-sm font-semibold",
|
|
|
|
|
accentButtonClass(),
|
|
|
|
|
)}
|
2026-05-10 03:06:54 +07:00
|
|
|
>
|
|
|
|
|
{t("home.wipAcknowledge")}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{projectPendingDelete ? (
|
2026-05-31 03:03:49 +07:00
|
|
|
<div
|
|
|
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/45 p-4"
|
|
|
|
|
onClick={() => setProjectPendingDelete(null)}
|
|
|
|
|
>
|
2026-05-10 03:06:54 +07:00
|
|
|
<div
|
2026-05-31 03:03:49 +07:00
|
|
|
className={cx(
|
|
|
|
|
"w-full max-w-md rounded-2xl border p-5 shadow-2xl",
|
|
|
|
|
surfaceClass(isDark),
|
|
|
|
|
)}
|
2026-05-10 03:06:54 +07:00
|
|
|
onClick={(event) => event.stopPropagation()}
|
|
|
|
|
>
|
|
|
|
|
<h2 className="text-lg font-semibold">Delete project?</h2>
|
2026-05-31 03:03:49 +07:00
|
|
|
<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.
|
2026-05-10 03:06:54 +07:00
|
|
|
</p>
|
|
|
|
|
<div className="mt-5 flex justify-end gap-2">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setProjectPendingDelete(null)}
|
2026-05-31 03:03:49 +07:00
|
|
|
className={cx(
|
|
|
|
|
"rounded border px-3 py-1.5 text-sm font-semibold",
|
|
|
|
|
subtleButtonClass(isDark),
|
|
|
|
|
)}
|
2026-05-10 03:06:54 +07:00
|
|
|
>
|
|
|
|
|
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]"
|
|
|
|
|
}`}
|
|
|
|
|
>
|
2026-05-31 03:03:49 +07:00
|
|
|
<section
|
|
|
|
|
className={cx("rounded-xl border p-4 sm:p-5", surfaceClass(isDark))}
|
2026-05-10 03:06:54 +07:00
|
|
|
>
|
2026-05-31 03:03:49 +07:00
|
|
|
<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>
|
2026-05-10 03:06:54 +07:00
|
|
|
|
2026-05-31 03:03:49 +07:00
|
|
|
<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]",
|
|
|
|
|
)}
|
2026-05-10 03:06:54 +07:00
|
|
|
>
|
2026-05-31 03:03:49 +07:00
|
|
|
{project.title}
|
|
|
|
|
</p>
|
|
|
|
|
<p
|
|
|
|
|
className={cx(
|
|
|
|
|
"mt-1 text-xs",
|
|
|
|
|
isDark ? "text-[#aeb3bc]" : "text-[#5f6672]",
|
|
|
|
|
)}
|
2026-05-10 03:06:54 +07:00
|
|
|
>
|
2026-05-31 03:03:49 +07:00
|
|
|
{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>
|
2026-05-10 03:06:54 +07:00
|
|
|
</main>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|