비행기(세계여행) 칸 선택 이동, 주사위 무게감, 설정 패널·주사위 초기화, 개인정보 고지·파비콘·탭 제목을 정리하고 런타임 로그는 추적에서 제외한다. Co-authored-by: Cursor <cursoragent@cursor.com>
980 lines
36 KiB
TypeScript
980 lines
36 KiB
TypeScript
'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>
|
||
);
|
||
}
|