feat(遊戲化): 新增大廳、附身、時間流、後日談進度等遊戲化系統功能
新增角色大廳(lobby)邀約與通話、附身(possession)、時鐘(clock)抽象、 時間流與睡眠負債、後日談進度、行為選擇與旁觀反應等模組,並補齊對應冒煙 測試(R~V)與單元測試;同步調整 web/mobile 對應頁面與元件。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
confirmEndingDetection,
|
||||
decideInvitation,
|
||||
decideLobbyCall,
|
||||
enterPossession,
|
||||
exitPossession,
|
||||
getFullLobby,
|
||||
getPossessionStatus,
|
||||
listEndingDetections,
|
||||
listPendingAdjudication,
|
||||
listPendingInvitations,
|
||||
listPendingLobbyCalls,
|
||||
rejectEndingDetection,
|
||||
sayToLobby,
|
||||
type EndingDetectionLog,
|
||||
type LobbyFullSnapshot,
|
||||
type LobbyPresenceState,
|
||||
type PendingInvitation,
|
||||
type PendingLobbyCall,
|
||||
type PossessionStatus,
|
||||
} from "../../lib/api";
|
||||
|
||||
const USER_ID = "seed-user-primary";
|
||||
|
||||
const PRESENCE_LABELS: Record<LobbyPresenceState, string> = {
|
||||
BUILDING: "建置中",
|
||||
SLEEPING: "睡眠中",
|
||||
BUSY: "忙碌中",
|
||||
SEMI_BUSY: "略忙",
|
||||
IDLE: "空閒",
|
||||
IN_SOLO_ROOM: "單獨聊天中",
|
||||
IN_GROUP_ROOM: "多人房中",
|
||||
};
|
||||
|
||||
const UNAVAILABLE_STATES: LobbyPresenceState[] = ["BUILDING", "SLEEPING", "IN_SOLO_ROOM", "IN_GROUP_ROOM"];
|
||||
|
||||
// T19-P2:大廳頁完整版——取代 T19-P1 的最小快照(在場名單+房間清單),改用 T4 正式聚合端點
|
||||
// 一次取得作品分組、角色狀態、各進行中房間未讀數,並提供大廳對話式呼叫(T5)輸入框:答應者開房
|
||||
// 後直接導向該房,全部婉拒或找代答時把角色台詞就地顯示在輸入框上方(不跳轉)。
|
||||
export function LobbyView() {
|
||||
const router = useRouter();
|
||||
const [snapshot, setSnapshot] = useState<LobbyFullSnapshot | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [text, setText] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [lobbyMessage, setLobbyMessage] = useState<string | null>(null);
|
||||
const [pendingAdjudicationByWork, setPendingAdjudicationByWork] = useState<Record<string, number>>({});
|
||||
const [endingDetectionsByWork, setEndingDetectionsByWork] = useState<Record<string, EndingDetectionLog[]>>({});
|
||||
const [possession, setPossession] = useState<PossessionStatus | null>(null);
|
||||
const [pendingInvitations, setPendingInvitations] = useState<PendingInvitation[]>([]);
|
||||
const [pendingCalls, setPendingCalls] = useState<PendingLobbyCall[]>([]);
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [lobby, possessionStatus] = await Promise.all([getFullLobby(USER_ID), getPossessionStatus(USER_ID)]);
|
||||
setSnapshot(lobby);
|
||||
setPossession(possessionStatus);
|
||||
setError(null);
|
||||
} catch {
|
||||
setError("大廳查詢失敗,請確認 api 服務是否已啟動");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
// T19-P5:套用中時查詢待決邀請與待決大廳呼叫——沿用她的身分決定接受/婉拒,不讓人格系統代答。
|
||||
useEffect(() => {
|
||||
if (!possession?.possessingCharacterId) {
|
||||
setPendingInvitations([]);
|
||||
setPendingCalls([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void Promise.all([listPendingInvitations(USER_ID), listPendingLobbyCalls(USER_ID)]).then(([invitations, calls]) => {
|
||||
if (cancelled) return;
|
||||
setPendingInvitations(invitations);
|
||||
setPendingCalls(calls);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [possession]);
|
||||
|
||||
async function handleEnterPossession(characterId: string) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await enterPossession(USER_ID, characterId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "套用失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExitPossession() {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await exitPossession(USER_ID);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "退出套用失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecideInvitation(invitationId: string, accept: boolean) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await decideInvitation(invitationId, accept);
|
||||
const invitations = await listPendingInvitations(USER_ID);
|
||||
setPendingInvitations(invitations);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "邀請決定失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDecideCall(pendingCallId: string, accept: boolean) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
const decision = await decideLobbyCall(pendingCallId, accept);
|
||||
const calls = await listPendingLobbyCalls(USER_ID);
|
||||
setPendingCalls(calls);
|
||||
if (decision.roomId) {
|
||||
router.push(`/room/${decision.roomId}`);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "呼叫決定失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
// T19-P4:作品卡片標題列的「有待裁決項目」提示與結局偵測待確認清單——依目前大廳snapshot 內的
|
||||
// 作品清單批次查詢,snapshot 每次刷新(匯入、裁決、確認結局後)都會連動重新取得。
|
||||
useEffect(() => {
|
||||
if (!snapshot) return;
|
||||
const workIds = snapshot.works.map((w) => w.id);
|
||||
let cancelled = false;
|
||||
void Promise.all(
|
||||
workIds.map(async (workId) => {
|
||||
const [adjudication, endings] = await Promise.all([listPendingAdjudication(workId), listEndingDetections(workId)]);
|
||||
return { workId, pendingCount: adjudication.length, endings };
|
||||
}),
|
||||
).then((results) => {
|
||||
if (cancelled) return;
|
||||
setPendingAdjudicationByWork(Object.fromEntries(results.map((r) => [r.workId, r.pendingCount])));
|
||||
setEndingDetectionsByWork(Object.fromEntries(results.map((r) => [r.workId, r.endings])));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [snapshot]);
|
||||
|
||||
async function handleConfirmEnding(logId: string) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await confirmEndingDetection(logId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "結局確認失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRejectEnding(logId: string) {
|
||||
if (pending) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await rejectEndingDetection(logId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "結局拒絕失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSay() {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || pending || possession?.possessingCharacterId) return;
|
||||
setPending(true);
|
||||
setError(null);
|
||||
setLobbyMessage(null);
|
||||
try {
|
||||
const result = await sayToLobby(USER_ID, trimmed);
|
||||
setText("");
|
||||
if (result.roomId) {
|
||||
router.push(`/room/${result.roomId}`);
|
||||
return;
|
||||
}
|
||||
if (result.message) {
|
||||
setLobbyMessage(result.message);
|
||||
}
|
||||
await refresh();
|
||||
} catch {
|
||||
setError("大廳呼叫失敗,請確認 api 服務是否已啟動");
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-xs" style={{ color: "var(--accent)" }}>
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot) {
|
||||
return (
|
||||
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
載入中……
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const workGroups = snapshot.works.map((work) => ({
|
||||
work,
|
||||
characters: snapshot.characters.filter((c) => c.workId === work.id),
|
||||
}));
|
||||
const unassigned = snapshot.characters.filter((c) => c.workId === null);
|
||||
const possessedCharacterId = possession?.possessingCharacterId ?? null;
|
||||
const possessedCharacterName = possessedCharacterId
|
||||
? snapshot.characters.find((c) => c.id === possessedCharacterId)?.formalName ?? possessedCharacterId
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{possessedCharacterId && (
|
||||
<section
|
||||
className="flex flex-col gap-2 rounded-2xl border-2 p-3"
|
||||
style={{ borderColor: "var(--accent-bright)", background: "var(--chip-bg)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-bold" style={{ color: "var(--accent)" }}>
|
||||
你現在是「{possessedCharacterName}」
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleExitPossession()}
|
||||
disabled={pending}
|
||||
className="rounded-full border px-3 py-1 text-xs font-bold disabled:opacity-50"
|
||||
style={{ borderColor: "var(--accent)", color: "var(--accent)" }}
|
||||
>
|
||||
退出套用
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px]" style={{ color: "var(--ink-soft)" }}>
|
||||
她的自走已暫停;請到她所在的房間以她的身分發話,或在下方決定她收到的邀請與呼叫。
|
||||
</p>
|
||||
|
||||
{pendingCalls.map((call) => (
|
||||
<div key={call.id} className="flex flex-col gap-1 rounded-xl border p-2 text-[11px]" style={{ borderColor: "var(--line)" }}>
|
||||
<p style={{ color: "var(--ink)" }}>有人喊了你:「{call.text}」</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDecideCall(call.id, true)}
|
||||
disabled={pending}
|
||||
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
|
||||
style={{ background: "var(--accent-bright)" }}
|
||||
>
|
||||
接受
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDecideCall(call.id, false)}
|
||||
disabled={pending}
|
||||
className="rounded-full border px-3 py-1 disabled:opacity-50"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
|
||||
>
|
||||
婉拒
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{pendingInvitations.map((invitation) => (
|
||||
<div key={invitation.id} className="flex flex-col gap-1 rounded-xl border p-2 text-[11px]" style={{ borderColor: "var(--line)" }}>
|
||||
<p style={{ color: "var(--ink)" }}>
|
||||
{(invitation.invitedByCharacterId &&
|
||||
snapshot.characters.find((c) => c.id === invitation.invitedByCharacterId)?.formalName) ??
|
||||
"有人"}
|
||||
邀請你到房間裡聊聊
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDecideInvitation(invitation.id, true)}
|
||||
disabled={pending}
|
||||
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
|
||||
style={{ background: "var(--accent-bright)" }}
|
||||
>
|
||||
接受
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleDecideInvitation(invitation.id, false)}
|
||||
disabled={pending}
|
||||
className="rounded-full border px-3 py-1 disabled:opacity-50"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
|
||||
>
|
||||
婉拒
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="flex flex-col gap-2 rounded-2xl border p-3" style={{ borderColor: "var(--line)" }}>
|
||||
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
|
||||
進行中房間({snapshot.activeRooms.length})
|
||||
</h2>
|
||||
{snapshot.activeRooms.length === 0 ? (
|
||||
<p className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
目前沒有進行中的房間。
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{snapshot.activeRooms.map((room) => (
|
||||
<li key={room.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/room/${room.id}`)}
|
||||
className="flex w-full flex-col gap-1 rounded-xl border px-3 py-2 text-left text-xs"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
>
|
||||
<span className="flex items-center justify-between">
|
||||
<span style={{ color: "var(--ink-soft)" }}>{room.mode}</span>
|
||||
{room.unreadCount > 0 && (
|
||||
<span
|
||||
className="rounded-full px-2 py-0.5 text-[10px] font-bold text-white"
|
||||
style={{ background: "var(--accent-bright)" }}
|
||||
>
|
||||
{room.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
{room.memberCharacterIds
|
||||
.map((id) => snapshot.characters.find((c) => c.id === id)?.formalName ?? id)
|
||||
.join("、") || "(無人在場)"}
|
||||
</span>
|
||||
{room.topicSeed && <span style={{ color: "var(--ink-soft)" }}>話題:{room.topicSeed}</span>}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{workGroups.map(({ work, characters }) => {
|
||||
if (characters.length === 0) return null;
|
||||
const pendingCount = pendingAdjudicationByWork[work.id] ?? 0;
|
||||
const endings = endingDetectionsByWork[work.id] ?? [];
|
||||
return (
|
||||
<section key={work.id} className="flex flex-col gap-2 rounded-2xl border p-3" style={{ borderColor: "var(--line)" }}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="flex items-center gap-2 text-sm font-bold" style={{ color: "var(--ink)" }}>
|
||||
{work.title}
|
||||
{work.inEpilogue && (
|
||||
<span className="rounded-full px-2 py-0.5 text-[10px]" style={{ background: "var(--chip-bg)", color: "var(--accent)" }}>
|
||||
後日談
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 text-[10px]">
|
||||
{pendingCount > 0 && (
|
||||
<Link
|
||||
href="/canon-review"
|
||||
className="rounded-full px-2 py-0.5 font-bold text-white"
|
||||
style={{ background: "var(--accent)" }}
|
||||
>
|
||||
有 {pendingCount} 項待裁決
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href={`/canon-import?workId=${work.id}`}
|
||||
className="rounded-full border px-2 py-0.5"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
|
||||
>
|
||||
匯入章節
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{characters.map((character) => (
|
||||
<span
|
||||
key={character.id}
|
||||
className="flex items-center gap-1 rounded-full border px-2 py-1"
|
||||
style={{
|
||||
borderColor: "var(--line)",
|
||||
color: UNAVAILABLE_STATES.includes(character.presenceState) ? "var(--ink-soft)" : "var(--ink)",
|
||||
}}
|
||||
>
|
||||
{character.formalName}
|
||||
<span style={{ color: "var(--ink-soft)" }}>/{PRESENCE_LABELS[character.presenceState]}</span>
|
||||
{character.id === possessedCharacterId ? (
|
||||
<span className="rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white" style={{ background: "var(--accent)" }}>
|
||||
套用中
|
||||
</span>
|
||||
) : (
|
||||
!possessedCharacterId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleEnterPossession(character.id)}
|
||||
disabled={pending}
|
||||
className="rounded-full border px-1.5 py-0.5 text-[9px] disabled:opacity-50"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
|
||||
>
|
||||
套用
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{endings.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{endings.map((ending) => (
|
||||
<div key={ending.id} className="flex flex-col gap-1 rounded-xl border p-2 text-[11px]" style={{ borderColor: "var(--line)" }}>
|
||||
<p style={{ color: "var(--ink)" }}>偵測到可能的結局章節,是否切入後日談?</p>
|
||||
<p style={{ color: "var(--ink-soft)" }}>判定理由:{ending.evidence}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleConfirmEnding(ending.id)}
|
||||
disabled={pending}
|
||||
className="rounded-full px-3 py-1 font-bold text-white disabled:opacity-50"
|
||||
style={{ background: "var(--accent-bright)" }}
|
||||
>
|
||||
確認.切入後日談
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleRejectEnding(ending.id)}
|
||||
disabled={pending}
|
||||
className="rounded-full border px-3 py-1 disabled:opacity-50"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
|
||||
>
|
||||
拒絕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{work.milestones.length > 0 && (
|
||||
<div className="flex flex-col gap-1 text-[11px]" style={{ color: "var(--ink-soft)" }}>
|
||||
{work.milestones.map((milestone, index) => (
|
||||
<p key={`${milestone.characterId}-${index}`}>
|
||||
.{characters.find((c) => c.id === milestone.characterId)?.formalName ?? milestone.characterId}
|
||||
:{milestone.label}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{unassigned.length > 0 && (
|
||||
<section className="flex flex-col gap-2 rounded-2xl border p-3" style={{ borderColor: "var(--line)" }}>
|
||||
<h2 className="text-sm font-bold" style={{ color: "var(--ink)" }}>
|
||||
未分組角色
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
{unassigned.map((character) => (
|
||||
<span
|
||||
key={character.id}
|
||||
className="flex items-center gap-1 rounded-full border px-2 py-1"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
>
|
||||
{character.formalName}
|
||||
<span style={{ color: "var(--ink-soft)" }}>/{PRESENCE_LABELS[character.presenceState]}</span>
|
||||
{character.id === possessedCharacterId ? (
|
||||
<span className="rounded-full px-1.5 py-0.5 text-[9px] font-bold text-white" style={{ background: "var(--accent)" }}>
|
||||
套用中
|
||||
</span>
|
||||
) : (
|
||||
!possessedCharacterId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleEnterPossession(character.id)}
|
||||
disabled={pending}
|
||||
className="rounded-full border px-1.5 py-0.5 text-[9px] disabled:opacity-50"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}
|
||||
>
|
||||
套用
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{lobbyMessage && (
|
||||
<p className="rounded-xl border px-3 py-2 text-xs" style={{ borderColor: "var(--line)", color: "var(--ink)" }}>
|
||||
{lobbyMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{possessedCharacterId ? (
|
||||
<p className="rounded-full border px-3 py-2 text-center text-xs" style={{ borderColor: "var(--line)", color: "var(--ink-soft)" }}>
|
||||
套用中無法以自己的身分喊人,請先退出套用。
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") void handleSay();
|
||||
}}
|
||||
placeholder="喊一下誰的名字……"
|
||||
className="flex-1 rounded-full border bg-transparent px-3 py-2 text-sm outline-none"
|
||||
style={{ borderColor: "var(--line)", color: "var(--ink)" }}
|
||||
disabled={pending}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSay()}
|
||||
disabled={pending || !text.trim()}
|
||||
className="rounded-full px-4 py-2 text-sm font-bold text-white disabled:opacity-50"
|
||||
style={{ background: "var(--accent-bright)" }}
|
||||
>
|
||||
送出
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Link from "next/link";
|
||||
import { LobbyView } from "./LobbyView";
|
||||
|
||||
export default function LobbyPage() {
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-md flex-1 flex-col gap-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-bold" style={{ color: "var(--accent)" }}>
|
||||
大廳
|
||||
</h1>
|
||||
<div className="flex gap-3">
|
||||
<Link href="/tasks" className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
委託
|
||||
</Link>
|
||||
<Link href="/settings" className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
設定
|
||||
</Link>
|
||||
<Link href="/canon-review" className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
待裁決
|
||||
</Link>
|
||||
<Link href="/epilogue" className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
後日談
|
||||
</Link>
|
||||
<Link href="/room" className="text-xs" style={{ color: "var(--ink-soft)" }}>
|
||||
建立房間
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<LobbyView />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user