치지직 앱으로 연동하고, 연동 확인된 ID만 접속할 수 있도록 수정, 개발용으로는 채널ID를 수동으로 접속할 수 있도록 변경.

최초 추천 고정 배치 추가.
This commit is contained in:
kimyu
2026-09-18 04:09:31 +09:00
parent 6f72824598
commit 73520f9f06
26 changed files with 1272 additions and 162 deletions

View File

@@ -38,7 +38,7 @@ import {
clearBoardConfig,
getOrCreateBoardConfig,
perimeterLength,
randomizeAndSaveBoard,
resetToDefaultBoard,
resizeBoardConfig,
setAllCellsContentVisible,
setBoardConfig,
@@ -169,9 +169,9 @@ function BoardEditor({
<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(randomizeAndSaveBoard(board.width, board.height, board))}
onClick={() => onChange(resetToDefaultBoard())}
>
랜덤 다시 배치
추천 배치로 되돌리기
</button>
</div>
</div>
@@ -379,15 +379,57 @@ export default function Home() {
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 = () => ({
@@ -396,6 +438,45 @@ export default function Home() {
maxMultiplier: roomMultiplyByCheese ? roomMaxMultiplier : 0,
});
const startChzzkLogin = () => {
const opts = roomCheeseOptions();
const q = new URLSearchParams({
cheesePerDice: String(opts.cheesePerDice),
multiplyByCheese: opts.multiplyByCheese ? '1' : '0',
maxMultiplier: String(opts.maxMultiplier),
});
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) {
@@ -510,10 +591,10 @@ export default function Home() {
<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>*/}
{/*<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>
@@ -521,9 +602,10 @@ export default function Home() {
</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>
@@ -532,7 +614,9 @@ export default function Home() {
<div>
<h2 className="text-lg font-semibold">룸 입장</h2>
<p className="mt-1 text-[11px] text-white/50">
치즈 규칙을 정한 뒤, 치지직 채널로 입장하세요. 룸이 없으면 자동으로 만들어집니다.
{authMode?.requireChzzkLogin
? '릴리즈 모드: 치지직 로그인으로 본인 채널만 입장할 수 있습니다.'
: '개발 모드: 채널 ID 직접 입장과 치지직 로그인을 모두 사용할 수 있습니다.'}
</p>
</div>
@@ -576,27 +660,72 @@ export default function Home() {
)}
</div>
<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>
<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 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}