- 메인 페이지에서 배경, 말, 주사위면을 커스텀하게 지정하도록 변경.
- 모든 데이터를 로컬에 저장하고, 가져오기 및 내보내기를 할 수 있도록 수정.
This commit is contained in:
331
app/page.tsx
331
app/page.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user