Files
drinkmarble/app/page.tsx
2026-09-21 13:03:01 +09:00

980 lines
36 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import {
ASSET_ASPECT,
DEFAULT_ASSETS,
DEFAULT_DICE_FLOOR_COLOR,
DICE_FACES,
type DiceCount,
type DiceFace,
type DiceSlot,
type DrinkmarbleAssets,
clearAllAssets,
clearBackgroundAsset,
clearChessmanAsset,
clearDiceFaceAsset,
clearDiceFloorColor,
clearDiceFloorImage,
fileToAssetDataUrl,
getAssets,
setBackgroundAsset,
setChessmanAsset,
setDiceCount,
setDiceFaceAsset,
setDiceFloorColor,
setDiceFloorImage,
} from './game/asset-storage';
import { exportAssetPack, importAssetPack } from './game/asset-pack';
import {
type BoardConfig,
CELL_EVENT_OPTIONS,
CELL_TYPE_PRESETS,
MAX_PLANE_SIZE,
MIN_PLANE_SIZE,
buildPerimeterGeometry,
cellColorFor,
clearBoardConfig,
getOrCreateBoardConfig,
perimeterLength,
resetToDefaultBoard,
resizeBoardConfig,
setAllCellsContentVisible,
setBoardConfig,
updateBoardCell,
} from './game/board-config';
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);
return (
<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 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()}
>
변경
</button>
<button
type="button"
className="rounded bg-white/10 px-2 py-1 text-xs text-white/80 hover:bg-white/20"
onClick={onReset}
>
기본
</button>
</div>
</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>
);
}
function BoardEditor({
board,
onChange,
}: {
board: BoardConfig;
onChange: (next: BoardConfig) => void;
}) {
const geo = buildPerimeterGeometry(board.width, board.height);
const [selected, setSelected] = useState(1);
const cellImageInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (selected >= board.cells.length) {
setSelected(Math.min(1, board.cells.length - 1));
}
}, [board.cells.length, selected]);
const selectedCell = board.cells[selected];
const cellCount = perimeterLength(board.width, board.height);
const applySize = (width: number, height: number) => {
onChange(resizeBoardConfig(width, height, board));
};
const onCellImagePick = async (file: File) => {
const aspect = ASSET_ASPECT.cell;
const dataUrl = await fileToAssetDataUrl(file, {
aspectW: aspect.w,
aspectH: aspect.h,
maxW: 512,
maxH: 512,
quality: 0.88,
});
onChange(updateBoardCell(selected, { imageSrc: dataUrl }, board));
};
return (
<div className="flex flex-col gap-4 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">주사위판 칸 (board)</h3>
<p className="text-[11px] text-white/50">
가로·세로로 판 크기를 바꾸고, 칸마다 이름·효과·이미지·텍스트 표시를 설정합니다. 0번(시작) 이름은
고정입니다.
</p>
</div>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
className="rounded bg-white/15 px-3 py-1.5 text-xs font-medium text-white hover:bg-white/25"
onClick={() => onChange(setAllCellsContentVisible(true, board))}
>
전체 텍스트 표시
</button>
<button
type="button"
className="rounded bg-white/15 px-3 py-1.5 text-xs font-medium text-white hover:bg-white/25"
onClick={() => onChange(setAllCellsContentVisible(false, board))}
>
전체 텍스트 숨김
</button>
<button
type="button"
className="rounded bg-violet-700 px-3 py-1.5 text-xs font-medium text-white hover:bg-violet-600"
onClick={() => onChange(resetToDefaultBoard())}
>
추천 배치로 되돌리기
</button>
</div>
</div>
<div className="flex flex-wrap items-end gap-3 rounded-lg bg-black/25 p-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-white/60">가로 (칸)</label>
<input
type="number"
min={MIN_PLANE_SIZE}
max={MAX_PLANE_SIZE}
value={board.width}
onChange={(e) => applySize(Number(e.target.value), board.height)}
className="w-20 rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-white/60">세로 (칸)</label>
<input
type="number"
min={MIN_PLANE_SIZE}
max={MAX_PLANE_SIZE}
value={board.height}
onChange={(e) => applySize(board.width, Number(e.target.value))}
className="w-20 rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white"
/>
</div>
<p className="pb-1.5 text-xs text-white/50">
{MIN_PLANE_SIZE}~{MAX_PLANE_SIZE} · 둘레 {cellCount}칸
</p>
</div>
<div
className="grid gap-1"
style={{
gridTemplateColumns: `repeat(${board.width}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${board.height}, minmax(0, 1fr))`,
}}
>
{geo.map((g, index) => {
const cell = board.cells[index];
const active = selected === index;
const showText = cell?.contentVisible !== false;
return (
<button
key={g.id}
type="button"
title={`#${index} ${cell?.type ?? ''}${showText ? '' : ' (텍스트 숨김)'}`}
onClick={() => setSelected(index)}
className="relative min-h-10 rounded px-0.5 text-[10px] font-semibold text-white sm:text-[11px]"
style={{
gridColumn: g.x + 1,
gridRow: g.y + 1,
backgroundColor: cellColorFor(cell?.eventId, index),
backgroundImage: cell?.imageSrc ? `url(${cell.imageSrc})` : undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
textShadow: cell?.imageSrc ? '0 1px 2px rgba(0,0,0,0.9)' : undefined,
outline: active ? '2px solid #fde68a' : undefined,
outlineOffset: 1,
}}
>
{index === 0 ? '시작' : cell?.type}
{!showText && (
<span className="absolute top-0.5 right-0.5 rounded bg-black/55 px-0.5 text-[8px] font-normal text-white/70">
텍스트숨김
</span>
)}
</button>
);
})}
</div>
{selectedCell && (
<div className="grid gap-3 rounded-lg bg-black/25 p-3 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<label className="text-xs text-white/60">칸 #{selected} 이름</label>
<input
type="text"
list="cell-type-presets"
disabled={selected === 0}
value={selected === 0 ? '시작' : selectedCell.type}
onChange={(e) => onChange(updateBoardCell(selected, { type: e.target.value }, board))}
className="rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white disabled:opacity-50"
/>
<datalist id="cell-type-presets">
{CELL_TYPE_PRESETS.map((t) => (
<option key={t} value={t} />
))}
</datalist>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-white/60">효과</label>
<select
disabled={selected === 0}
value={selectedCell.eventId ?? ''}
onChange={(e) => {
const value = e.target.value;
onChange(
updateBoardCell(
selected,
{ eventId: value === '' ? null : value },
board,
),
);
}}
className="rounded border border-white/15 bg-black/40 px-2 py-1.5 text-sm text-white disabled:opacity-50"
>
{CELL_EVENT_OPTIONS.map((opt) => (
<option key={String(opt.id)} value={opt.id ?? ''}>
{opt.label}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1 sm:col-span-2">
<label className="inline-flex items-center gap-2 text-sm text-white/85">
<input
type="checkbox"
checked={selectedCell.contentVisible !== false}
onChange={(e) =>
onChange(
updateBoardCell(
selected,
{ contentVisible: e.target.checked },
board,
),
)
}
/>
게임에서 이 칸 이름(텍스트) 표시
</label>
<p className="text-[11px] text-white/45">
끄면 판에서 이름 글자만 숨깁니다. 이미지·색·착지 효과는 그대로입니다.
</p>
</div>
<div className="flex flex-col gap-2 sm:col-span-2">
<div className="flex items-center justify-between gap-2">
<label className="text-xs text-white/60">
칸 이미지 ({ASSET_ASPECT.cell.label} · 선택)
</label>
<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={() => cellImageInputRef.current?.click()}
>
변경
</button>
<button
type="button"
className="rounded bg-white/10 px-2 py-1 text-xs text-white/80 hover:bg-white/20"
onClick={() =>
onChange(updateBoardCell(selected, { imageSrc: null }, board))
}
>
제거
</button>
</div>
</div>
<div
className="h-20 w-20 overflow-hidden rounded-lg bg-black/40"
style={{
backgroundColor: cellColorFor(selectedCell.eventId, selected),
backgroundImage: selectedCell.imageSrc
? `url(${selectedCell.imageSrc})`
: undefined,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<input
ref={cellImageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void onCellImagePick(file).catch(() => undefined);
e.target.value = '';
}}
/>
</div>
</div>
)}
</div>
);
}
export default function Home() {
const router = useRouter();
const [assets, setAssets] = useState<DrinkmarbleAssets>(DEFAULT_ASSETS);
const [board, setBoard] = useState<BoardConfig | null>(null);
const [ready, setReady] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const packInputRef = useRef<HTMLInputElement>(null);
const [roomCheesePerDice, setRoomCheesePerDice] = useState(1000);
const [roomMultiplyByCheese, setRoomMultiplyByCheese] = useState(true);
const [roomMaxMultiplier, setRoomMaxMultiplier] = useState(10);
const [resetDiceQueueOnJoin, setResetDiceQueueOnJoin] = useState(false);
const [joinChannelInput, setJoinChannelInput] = useState('');
const [roomBusy, setRoomBusy] = useState(false);
const [roomError, setRoomError] = useState<string | null>(null);
const [authMode, setAuthMode] = useState<{
allowChannelIdJoin: boolean;
requireChzzkLogin: boolean;
oauthConfigured: boolean;
} | null>(null);
const [authSession, setAuthSession] = useState<{
channelId: string;
channelName: string | null;
} | null>(null);
const reload = () => {
setAssets(getAssets());
setBoard(getOrCreateBoardConfig());
};
const refreshAuth = async () => {
try {
const res = await fetch('/api/auth/session', { cache: 'no-store' });
const json = await res.json();
setAuthMode({
allowChannelIdJoin: Boolean(json.allowChannelIdJoin),
requireChzzkLogin: Boolean(json.requireChzzkLogin),
oauthConfigured: Boolean(json.oauthConfigured),
});
setAuthSession(
json.session
? {
channelId: String(json.session.channelId),
channelName: json.session.channelName ?? null,
}
: null,
);
} catch {
setAuthMode({
allowChannelIdJoin: true,
requireChzzkLogin: false,
oauthConfigured: false,
});
setAuthSession(null);
}
};
useEffect(() => {
reload();
setReady(true);
void refreshAuth();
if (typeof window !== 'undefined') {
const params = new URLSearchParams(window.location.search);
const authError = params.get('authError');
if (authError) setRoomError(authError);
}
}, []);
const roomCheeseOptions = () => ({
cheesePerDice: roomCheesePerDice,
multiplyByCheese: roomMultiplyByCheese,
maxMultiplier: roomMultiplyByCheese ? roomMaxMultiplier : 0,
resetDiceQueue: resetDiceQueueOnJoin,
});
const startChzzkLogin = () => {
const opts = roomCheeseOptions();
const q = new URLSearchParams({
cheesePerDice: String(opts.cheesePerDice),
multiplyByCheese: opts.multiplyByCheese ? '1' : '0',
maxMultiplier: String(opts.maxMultiplier),
resetDiceQueue: opts.resetDiceQueue ? '1' : '0',
});
window.location.href = `/api/chzzk/login?${q.toString()}`;
};
const joinWithSession = async () => {
try {
setRoomBusy(true);
setRoomError(null);
const res = await fetch('/api/rooms/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...roomCheeseOptions(),
}),
});
const json = await res.json();
if (!res.ok) {
setRoomError(json.error || '룸 입장에 실패했습니다.');
return;
}
router.push(`/game?roomId=${json.room.id}`);
} catch (e) {
setRoomError(e instanceof Error ? e.message : '룸 입장에 실패했습니다.');
} finally {
setRoomBusy(false);
}
};
const logoutAuth = async () => {
await fetch('/api/auth/logout', { method: 'POST' });
setAuthSession(null);
};
const joinByChzzkId = async () => {
const value = joinChannelInput.trim();
if (!value) {
setRoomError('치지직 채널 ID 또는 라이브 URL을 입력하세요.');
return;
}
try {
setRoomBusy(true);
setRoomError(null);
const res = await fetch('/api/rooms/join', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
channelIdOrUrl: value,
...roomCheeseOptions(),
}),
});
const json = await res.json();
if (!res.ok) {
setRoomError(json.error || '룸을 찾을 수 없습니다.');
if (json.room?.id) {
router.push(`/game?roomId=${json.room.id}`);
}
return;
}
router.push(`/game?roomId=${json.room.id}`);
} catch (e) {
setRoomError(e instanceof Error ? e.message : '룸 입장에 실패했습니다.');
} finally {
setRoomBusy(false);
}
};
const enterWithoutIntegration = async () => {
try {
setRoomBusy(true);
setRoomError(null);
const res = await fetch('/api/rooms', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const json = await res.json();
if (!res.ok) {
setRoomError(json.error || '룸 생성에 실패했습니다.');
return;
}
router.push(`/game?roomId=${json.room.id}`);
} catch (e) {
setRoomError(e instanceof Error ? e.message : '입장에 실패했습니다.');
} finally {
setRoomBusy(false);
}
};
const save = async (
kind: 'background' | 'chessman' | 'diceFloor' | { slot: DiceSlot; face: DiceFace },
file: File,
) => {
try {
const aspectKind =
kind === 'background' || kind === 'chessman' || kind === 'diceFloor' ? kind : 'dice';
const aspect = ASSET_ASPECT[aspectKind];
const isBg = kind === 'background';
const isFloor = kind === 'diceFloor';
const dataUrl = await fileToAssetDataUrl(file, {
aspectW: aspect.w,
aspectH: aspect.h,
maxW: isBg ? 1920 : isFloor ? 1024 : 512,
maxH: isBg ? 1080 : isFloor ? 1024 : 512,
quality: isBg || isFloor ? 0.85 : 0.9,
});
if (kind === 'background') setBackgroundAsset(dataUrl);
else if (kind === 'chessman') setChessmanAsset(dataUrl);
else if (kind === 'diceFloor') setDiceFloorImage(dataUrl);
else setDiceFaceAsset(kind.slot, kind.face, dataUrl);
reload();
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : '저장에 실패했습니다.');
}
};
const onExport = async () => {
try {
setBusy(true);
setError(null);
await exportAssetPack(assets, board ?? getOrCreateBoardConfig());
} 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 w-full flex-col items-stretch gap-5">
<div className="w-full">
{/*<p className="text-sm text-white/60">알코올 마블</p>*/}
<h1 className="relative mt-1 inline-block w-full text-center text-4xl font-bold tracking-tight">
<span className="relative inline-block pr-12">
알코올 마블!
<span className="pointer-events-none absolute bottom-1 left-full -ml-0.5 whitespace-nowrap text-[11px] font-normal tracking-normal text-white/35">
by kimyu.
</span>
</span>
</h1>
<br></br>
<p className="border-b border-white/10 w-full"></p>
<p className="mt-2 w-full text-sm text-white/65">치지직 온라인 주루마블!</p>
<p className="mt-2 w-full text-sm text-white/65">
이미지와 판 칸 설정은 서버가 아니라 이 브라우저에만 저장되요.
처음이면 추천 기본 배치(9×7)로 채워져요.
</p>
</div>
</header>
<section className="flex flex-col gap-4 rounded-2xl border border-white/10 bg-white/5 p-5">
<div>
<h2 className="text-lg font-semibold">룸 입장</h2>
<p className="mt-1 text-[11px] text-white/50">
{authMode?.requireChzzkLogin
? '릴리즈 모드: 치지직 로그인으로 본인 채널만 입장할 수 있습니다.'
: '개발 모드: 채널 ID 직접 입장과 치지직 로그인을 모두 사용할 수 있습니다.'}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex flex-col gap-1 text-sm">
<span className="text-white/70">몇 치즈(원)마다 주사위 1개</span>
<input
type="number"
min={1}
step={100}
value={roomCheesePerDice}
onChange={(e) => setRoomCheesePerDice(Number(e.target.value) || 1)}
className="rounded-lg border border-white/15 bg-black/40 px-3 py-2"
/>
</label>
<label className="flex items-center gap-2 self-end pb-2 text-sm">
<input
type="checkbox"
checked={roomMultiplyByCheese}
onChange={(e) => setRoomMultiplyByCheese(e.target.checked)}
className="h-4 w-4"
/>
<span>배수 누적 (금액 ÷ 기준만큼 주사위)</span>
</label>
{roomMultiplyByCheese && (
<label className="flex flex-col gap-1 text-sm sm:col-span-2">
<span className="text-white/70">최대 배수 (한 후원당 최대 주사위)</span>
<input
type="number"
min={1}
step={1}
value={roomMaxMultiplier}
onChange={(e) => setRoomMaxMultiplier(Math.max(1, Number(e.target.value) || 1))}
className="rounded-lg border border-white/15 bg-black/40 px-3 py-2"
/>
<span className="text-[11px] text-white/45">
예: 기준 {roomCheesePerDice}원 · 최대 {roomMaxMultiplier}배 → 한 번에 최대{' '}
{roomMaxMultiplier}개
</span>
</label>
)}
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input
type="checkbox"
checked={resetDiceQueueOnJoin}
onChange={(e) => setResetDiceQueueOnJoin(e.target.checked)}
className="h-4 w-4"
/>
<span>남은 주사위 개수를 초기화</span>
</label>
</div>
<div className="flex flex-col gap-2 border-t border-white/10 pt-3">
<p className="text-xs font-medium text-white/70">치지직 로그인으로 입장</p>
{authSession ? (
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm text-emerald-200/90">
로그인됨: {authSession.channelName || authSession.channelId}
</p>
<button
type="button"
disabled={roomBusy}
onClick={() => void joinWithSession()}
className="rounded-full bg-emerald-600 px-4 py-2 text-sm font-semibold text-white hover:bg-emerald-500 disabled:opacity-50"
>
내 채널로 입장
</button>
<button
type="button"
onClick={() => void logoutAuth()}
className="rounded-full bg-white/10 px-3 py-2 text-xs text-white/80 hover:bg-white/20"
>
로그아웃
</button>
</div>
) : (
<button
type="button"
disabled={roomBusy || authMode?.oauthConfigured === false}
onClick={startChzzkLogin}
className="self-start rounded-full bg-sky-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-sky-500 disabled:opacity-50"
>
치지직으로 로그인 후 입장
</button>
)}
{authMode && !authMode.oauthConfigured && (
<p className="text-[11px] text-amber-200/80">
OAuth 미설정: `.env`에 CHZZK_CLIENT_ID / SECRET / REDIRECT_URI 를 넣으면 활성화됩니다.
</p>
)}
</div>
{authMode?.allowChannelIdJoin !== false && (
<div className="flex flex-wrap items-end gap-2 border-t border-white/10 pt-3">
<label className="flex min-w-[220px] flex-1 flex-col gap-1 text-sm">
<span className="text-white/70">
치지직 채널 ID로 입장
<span className="ml-1 text-[10px] text-amber-200/70">(개발용)</span>
</span>
<input
type="text"
placeholder="채널ID 또는 https://chzzk.naver.com/live/…"
value={joinChannelInput}
onChange={(e) => setJoinChannelInput(e.target.value)}
className="rounded-lg border border-white/15 bg-black/40 px-3 py-2 text-sm"
/>
</label>
<button
type="button"
disabled={roomBusy || !joinChannelInput.trim()}
onClick={() => void joinByChzzkId()}
className="rounded-full bg-violet-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-violet-500 disabled:opacity-50"
>
{roomBusy ? '처리 중…' : '입장'}
</button>
</div>
)}
<button
type="button"
disabled={roomBusy}
onClick={() => void enterWithoutIntegration()}
className="self-start rounded-full bg-white/15 px-4 py-2.5 text-sm text-white hover:bg-white/25 disabled:opacity-50"
>
연동 없이 입장
<span className="ml-1 text-white/50">(채팅·치지직 없음 · 수동 굴림)</span>
</button>
{roomError && (
<p className="rounded-lg bg-red-900/70 px-3 py-2 text-sm">{roomError}</p>
)}
</section>
{!ready || !board ? (
<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();
clearBoardConfig();
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, board.json, cells/N.*, background.*, chessman.*,
dice/1|2/면.*, dice-floor.*
</p> */}
{error && (
<p className="rounded bg-red-900/70 px-3 py-2 text-sm text-white">{error}</p>
)}
<BoardEditor
board={board}
onChange={(next) => {
setBoardConfig(next);
setBoard(next);
}}
/>
<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>
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<h3 className="text-sm font-medium text-white/80">주사위 설정</h3>
<div className="flex items-center gap-2">
<span className="text-xs text-white/50">개수</span>
{([1, 2] as DiceCount[]).map((count) => (
<button
key={count}
type="button"
className={`rounded px-3 py-1 text-xs font-medium ${
assets.diceCount === count
? 'bg-amber-500 text-stone-950'
: 'bg-white/10 text-white/80 hover:bg-white/20'
}`}
onClick={() => {
setDiceCount(count);
reload();
}}
>
{count}개
</button>
))}
</div>
</div>
<div className="mb-4 flex flex-col gap-3 rounded-xl border border-white/10 bg-white/5 p-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<p className="text-sm font-medium text-white">주사위 판(바닥)</p>
<p className="text-[11px] text-white/50">
색상 또는 이미지({ASSET_ASPECT.diceFloor.label}) · 이미지가 있으면 색 대신 사용
</p>
</div>
<div className="flex gap-1">
<button
type="button"
className="rounded bg-white/10 px-2 py-1 text-xs text-white/80 hover:bg-white/20"
onClick={() => {
clearDiceFloorColor();
clearDiceFloorImage();
reload();
}}
>
기본
</button>
</div>
</div>
<div className="flex flex-wrap items-end gap-4">
<label className="flex flex-col gap-1 text-xs text-white/60">
바닥 색
<input
type="color"
value={assets.diceFloorColor || DEFAULT_DICE_FLOOR_COLOR}
onChange={(e) => {
setDiceFloorColor(e.target.value);
reload();
}}
className="h-10 w-16 cursor-pointer rounded border border-white/15 bg-transparent"
/>
</label>
<AssetThumb
label="바닥 이미지"
ratioLabel={ASSET_ASPECT.diceFloor.label}
previewClassName="aspect-square w-28"
src={
assets.diceFloorImage ??
`data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="${assets.diceFloorColor}"/></svg>`,
)}`
}
onPick={(file) => void save('diceFloor', file)}
onReset={() => {
clearDiceFloorImage();
reload();
}}
/>
</div>
</div>
{([1, 2] as DiceSlot[])
.filter((slot) => slot === 1 || assets.diceCount === 2)
.map((slot) => {
const faces = slot === 1 ? assets.dice : assets.dice2;
return (
<div key={slot} className="mb-4">
<h4 className="mb-2 text-sm font-medium text-white/70">
{assets.diceCount === 2 ? `${slot}번 주사위 면` : '주사위 면'} (1~6 ·{' '}
{ASSET_ASPECT.dice.label})
</h4>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{DICE_FACES.map((face) => (
<AssetThumb
key={`${slot}-${face}`}
label={`${face}면`}
ratioLabel={ASSET_ASPECT.dice.label}
previewClassName="aspect-square w-full"
src={faces[face]}
onPick={(file) => void save({ slot, face }, file)}
onReset={() => {
clearDiceFaceAsset(slot, face);
reload();
}}
/>
))}
</div>
</div>
);
})}
</div>
</section>
)}
<footer className="mt-auto border-t border-white/10 pt-6 pb-2 text-center text-[12px] text-white/45">
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1">
<Link
href="/privacy"
className="text-white/60 underline decoration-white/25 underline-offset-2 hover:text-white/85"
>
개인정보·쿠키 고지
</Link>
<span className="text-white/25" aria-hidden>
·
</span>
<a
href="mailto:kimyu@kimyu.xyz"
className="text-white/60 underline decoration-white/25 underline-offset-2 hover:text-white/85"
>
연락: kimyu@kimyu.xyz
</a>
</div>
</footer>
</div>
</div>
);
}