- 메인 페이지에서 배경, 말, 주사위면을 커스텀하게 지정하도록 변경.

- 모든 데이터를 로컬에 저장하고, 가져오기 및 내보내기를 할 수 있도록 수정.
This commit is contained in:
kimyu
2026-09-08 01:03:21 +09:00
parent fc6eb5fce1
commit 1b99e3961d
9 changed files with 830 additions and 82 deletions

View File

@@ -1,69 +1,280 @@
import Image from "next/image";
'use client';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
import {
ASSET_ASPECT,
DEFAULT_ASSETS,
type DiceFace,
type DrinkmarbleAssets,
clearAllAssets,
clearBackgroundAsset,
clearChessmanAsset,
clearDiceFaceAsset,
fileToAssetDataUrl,
getAssets,
setBackgroundAsset,
setChessmanAsset,
setDiceFaceAsset,
} from './game/asset-storage';
import { exportAssetPack, importAssetPack } from './game/asset-pack';
const DICE_FACES: DiceFace[] = [1, 2, 3, 4, 5, 6];
function AssetThumb({
src,
label,
ratioLabel,
previewClassName,
onPick,
onReset,
}: {
src: string;
label: string;
ratioLabel: string;
previewClassName: string;
onPick: (file: File) => void;
onReset: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
<div className="flex flex-col gap-2 rounded-xl border border-white/10 bg-white/5 p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col">
<span className="text-sm font-medium text-white">{label}</span>
<span className="text-[11px] text-white/50">비율 {ratioLabel} · 중앙 크롭</span>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<div className="flex gap-1">
<button
type="button"
className="rounded bg-white/15 px-2 py-1 text-xs text-white hover:bg-white/25"
onClick={() => inputRef.current?.click()}
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
변경
</button>
<button
type="button"
className="rounded bg-white/10 px-2 py-1 text-xs text-white/80 hover:bg-white/20"
onClick={onReset}
>
Documentation
</a>
기본
</button>
</div>
</main>
</div>
<div className={`overflow-hidden rounded-lg bg-black/30 ${previewClassName}`}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={src} alt={label} className="h-full w-full object-cover" />
</div>
<input
ref={inputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onPick(file);
e.target.value = '';
}}
/>
</div>
);
}
export default function Home() {
const [assets, setAssets] = useState<DrinkmarbleAssets>(DEFAULT_ASSETS);
const [ready, setReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const packInputRef = useRef<HTMLInputElement>(null);
const reload = () => {
setAssets(getAssets());
};
useEffect(() => {
reload();
setReady(true);
}, []);
const save = async (kind: 'background' | 'chessman' | DiceFace, file: File) => {
try {
const aspectKind = kind === 'background' || kind === 'chessman' ? kind : 'dice';
const aspect = ASSET_ASPECT[aspectKind];
const isBg = kind === 'background';
const dataUrl = await fileToAssetDataUrl(file, {
aspectW: aspect.w,
aspectH: aspect.h,
maxW: isBg ? 1920 : 512,
maxH: isBg ? 1080 : 512,
quality: isBg ? 0.85 : 0.9,
});
if (kind === 'background') setBackgroundAsset(dataUrl);
else if (kind === 'chessman') setChessmanAsset(dataUrl);
else setDiceFaceAsset(kind, dataUrl);
reload();
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : '저장에 실패했습니다.');
}
};
const onExport = async () => {
try {
setBusy(true);
setError(null);
await exportAssetPack(assets);
} catch (e) {
setError(e instanceof Error ? e.message : '내보내기에 실패했습니다.');
} finally {
setBusy(false);
}
};
const onImport = async (file: File | null) => {
if (!file) return;
try {
setBusy(true);
setError(null);
await importAssetPack(file);
reload();
} catch (e) {
setError(e instanceof Error ? e.message : '가져오기에 실패했습니다.');
} finally {
setBusy(false);
}
};
return (
<div className="min-h-screen bg-[#1c1224] text-white">
<div className="mx-auto flex min-h-screen max-w-3xl flex-col gap-8 px-6 py-10">
<header className="flex flex-col items-start gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<p className="text-sm text-white/60">온라인 주루마블</p>
<h1 className="mt-1 text-4xl font-bold tracking-tight">Drinkmarble</h1>
<p className="mt-2 max-w-md text-sm text-white/65">
이미지는 서버에 올리지 않고, 이 브라우저 localStorage에만 저장됩니다.
값이 없으면 public 기본 이미지를 사용합니다. 업로드 시 정해진 비율로 중앙
크롭됩니다.
</p>
</div>
<Link
href="/game"
className="inline-flex items-center justify-center rounded-full bg-amber-500 px-6 py-3 text-base font-semibold text-stone-950 transition hover:bg-amber-400"
>
게임 시작
</Link>
</header>
{!ready ? (
<p className="text-white/50">불러오는 중…</p>
) : (
<section className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-lg font-semibold">커스텀 이미지</h2>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={busy}
className="rounded bg-sky-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-sky-500 disabled:opacity-50"
onClick={() => void onExport()}
>
ZIP 내보내기
</button>
<button
type="button"
disabled={busy}
className="rounded bg-emerald-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-600 disabled:opacity-50"
onClick={() => packInputRef.current?.click()}
>
ZIP 가져오기
</button>
<button
type="button"
disabled={busy}
className="rounded bg-white/10 px-3 py-1.5 text-xs text-white/80 hover:bg-white/20 disabled:opacity-50"
onClick={() => {
clearAllAssets();
reload();
setError(null);
}}
>
전부 기본값으로
</button>
<input
ref={packInputRef}
type="file"
accept=".zip,application/zip"
className="hidden"
onChange={(e) => {
void onImport(e.target.files?.[0] ?? null);
e.target.value = '';
}}
/>
</div>
</div>
<p className="text-xs text-white/45">
내보내기 ZIP 구조: manifest.json, background.*, chessman.*, dice/1.* … 6.*
</p>
{error && (
<p className="rounded bg-red-900/70 px-3 py-2 text-sm text-white">{error}</p>
)}
<div className="grid gap-4 sm:grid-cols-2">
<AssetThumb
label="배경 (background)"
ratioLabel={ASSET_ASPECT.background.label}
previewClassName="aspect-video w-full"
src={assets.background}
onPick={(file) => void save('background', file)}
onReset={() => {
clearBackgroundAsset();
reload();
}}
/>
<AssetThumb
label="말 (chessman)"
ratioLabel={ASSET_ASPECT.chessman.label}
previewClassName="aspect-square w-40 mx-auto"
src={assets.chessman}
onPick={(file) => void save('chessman', file)}
onReset={() => {
clearChessmanAsset();
reload();
}}
/>
</div>
<div>
<h3 className="mb-3 text-sm font-medium text-white/80">
주사위 면 (dice 1~6 · {ASSET_ASPECT.dice.label})
</h3>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{DICE_FACES.map((face) => (
<AssetThumb
key={face}
label={`${face}면`}
ratioLabel={ASSET_ASPECT.dice.label}
previewClassName="aspect-square w-full"
src={assets.dice[face]}
onPick={(file) => void save(face, file)}
onReset={() => {
clearDiceFaceAsset(face);
reload();
}}
/>
))}
</div>
</div>
<p className="text-xs text-white/45">
이후 각 칸(한잔, 무인도 등) 이미지도 같은 방식으로 추가할 예정입니다.
</p>
</section>
)}
</div>
</div>
);
}