diff --git a/README.md b/README.md index 3c529ea..8b7a3f0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 本命饭 PK(独立版) -只开放 `rice` 测试的原生小红书小程序,同时提供 Next.js 公网预览。打开即进入饭类主题,可重复测试、查看自己的饭类历史、输入综合版生成的一次性绑定码,并生成可编辑的完整晋级图。 +只开放 `rice` 测试的原生小红书小程序,同时提供 Next.js、React 与严格 TypeScript 公网预览。打开即进入饭类主题,可重复测试、查看自己的饭类历史、输入综合版生成的一次性绑定码,并生成可编辑的完整晋级图。 将 `app.js` 的 `apiBase` 改成生产 HTTPS API,在小红书后台配置合法域名并绑定独立版 App ID。CI 默认把 Next.js standalone 服务常驻部署到宿主机 `3002`,并加入 `pk-lab-public` Docker 网络;浏览器固定同源请求 `/api`,由 Next.js Route Handler 转发到 `pk-lab-api:8000`。`DEPLOY_PORT` 可覆盖。 diff --git a/scripts/check.mjs b/scripts/check.mjs index 7ee0faa..18cc4d9 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -1,5 +1,5 @@ import { readFileSync, existsSync } from 'node:fs'; -const required = ['app.js','app.json','pages/index/index.xhsml','pages/test/test.js','pages/result/result.js','assets/test-covers/rice.jpg','vendor/poster-editor.cjs','web/package.json','web/next.config.mjs','web/app/page.jsx','web/app/api/[...path]/route.js','web/components/RiceArena.jsx','web/components/RicePosterEditor.jsx']; +const required = ['app.js','app.json','pages/index/index.xhsml','pages/test/test.js','pages/result/result.js','assets/test-covers/rice.jpg','vendor/poster-editor.cjs','web/package.json','web/tsconfig.json','web/next.config.mjs','web/app/page.tsx','web/app/api/[...path]/route.ts','web/components/RiceArena.tsx','web/components/RicePosterEditor.tsx']; for (const file of required) if (!existsSync(file)) throw new Error(`Missing ${file}`); JSON.parse(readFileSync('app.json','utf8')); if (!readFileSync('web/next.config.mjs','utf8').includes("output: 'standalone'")) throw new Error('Next.js standalone output is required'); diff --git a/scripts/check.py b/scripts/check.py index 1c8b0df..06e1f82 100644 --- a/scripts/check.py +++ b/scripts/check.py @@ -10,11 +10,12 @@ required = [ "assets/test-covers/rice.jpg", "vendor/poster-editor.cjs", "web/package.json", + "web/tsconfig.json", "web/next.config.mjs", - "web/app/page.jsx", - "web/app/api/[...path]/route.js", - "web/components/RiceArena.jsx", - "web/components/RicePosterEditor.jsx", + "web/app/page.tsx", + "web/app/api/[...path]/route.ts", + "web/components/RiceArena.tsx", + "web/components/RicePosterEditor.tsx", ] missing = [name for name in required if not Path(name).is_file()] if missing: diff --git a/web/app/api/[...path]/route.js b/web/app/api/[...path]/route.ts similarity index 90% rename from web/app/api/[...path]/route.js rename to web/app/api/[...path]/route.ts index 718c444..07b994a 100644 --- a/web/app/api/[...path]/route.js +++ b/web/app/api/[...path]/route.ts @@ -1,5 +1,5 @@ const API_BASE = process.env.API_INTERNAL_URL || 'http://pk-lab-api:8000'; -async function forward(request, context) { +async function forward(request:Request, context:{params:Promise<{path:string[]}>}):Promise { const { path } = await context.params; const incoming = new URL(request.url); const target = new URL(`/api/${path.join('/')}${incoming.search}`, API_BASE); diff --git a/web/app/globals.css b/web/app/globals.css index 96eae3a..bc4d9e3 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -1,2 +1,5 @@ :root{--paper:#fff8f2;--ink:#201d1b;--orange:#ff6338;--blue:#2766df;--muted:#887d75;--pink:#ff3156}*{box-sizing:border-box}html,body{margin:0;min-height:100%;background:var(--paper);color:var(--ink);font-family:Inter,system-ui,-apple-system,"PingFang SC",sans-serif}button,input{font:inherit}button{border:0;cursor:pointer}a{color:inherit;text-decoration:none}.homeShell{width:min(960px,calc(100% - 28px));margin:auto;padding:18px 0 80px}.topbar{display:flex;justify-content:space-between;align-items:center;margin-bottom:18px}.topbar strong{letter-spacing:3px}.topbar span{color:var(--muted);font-size:13px}.hero{display:grid;grid-template-columns:minmax(280px,44%) 1fr;overflow:hidden;background:var(--orange);color:#fff}.hero img{width:100%;height:100%;min-height:520px;object-fit:cover}.heroCopy{display:flex;flex-direction:column;justify-content:center;padding:50px}.heroCopy small{letter-spacing:4px;opacity:.75}.heroCopy h1{margin:28px 0 18px;font-size:clamp(50px,7vw,88px);line-height:.92}.heroCopy p{font-size:17px;line-height:1.6}.primary,.centerState button{align-self:flex-start;margin-top:20px;padding:14px 25px;border-radius:99px;background:var(--ink);color:#fff;font-weight:850}.sectionHead{display:flex;align-items:center;justify-content:space-between;margin:45px 0 14px}.sectionHead h2{margin:0}.sectionHead span{color:var(--muted)}.resultRow{display:flex;align-items:center;gap:16px;margin:10px 0;padding:18px;background:#fff}.resultRow>b{display:grid;width:56px;height:56px;place-items:center;background:var(--orange);color:#fff}.resultRow>span{display:flex;flex:1;flex-direction:column}.resultRow small{color:var(--muted)}.resultRow strong{margin-top:3px;font-size:23px}.resultRow i{font-style:normal}.empty{padding:55px;text-align:center;color:var(--muted);background:#fff}.bindPanel{margin-top:28px;padding:28px;background:#f4e8ff}.bindPanel h2{margin-top:0}.bindPanel p{color:var(--muted)}.bindPanel div{display:flex;gap:8px}.bindPanel input{flex:1;min-width:0;padding:14px;border:0;letter-spacing:4px}.bindPanel button{padding:12px 18px;background:var(--ink);color:#fff;font-weight:800}.bindPanel output{display:block;margin-top:12px}.centerState{display:grid;min-height:100dvh;place-content:center;text-align:center}.centerState button{justify-self:center}.arenaPage{height:100dvh;overflow:hidden;padding:18px 26px;background:#fff}.arenaHeader{display:grid;grid-template-columns:1fr auto 1fr;align-items:center}.arenaHeader button{justify-self:start;background:transparent;font-size:28px}.arenaHeader small{justify-self:end;color:var(--muted)}.progress{height:7px;margin-top:10px;background:#eee}.progress i{display:block;height:100%;background:var(--orange);transition:width .25s}.modes{display:flex;width:min(430px,80vw);margin:18px auto;padding:5px;background:#eee}.modes button{flex:1;padding:9px;background:transparent;color:#777}.modes button.active{background:var(--ink);color:#fff}.pair{position:relative;display:grid;grid-template-columns:1fr 1fr;gap:14px;height:calc(100dvh - 145px);max-height:850px;touch-action:none}.choice{position:relative;display:flex;flex-direction:column;justify-content:center;padding:28px;text-align:left;color:#fff}.choice.warm{background:var(--orange)}.choice.cool{background:var(--blue)}.choice small{position:absolute;top:24px;left:24px;display:grid;width:48px;height:48px;place-items:center;border:2px solid #fff9;border-radius:50%}.choice strong{font-size:clamp(34px,6vw,66px);line-height:1.08}.choice>span{position:absolute;bottom:26px;left:28px;opacity:.75}.pair.swipe .warm{transform:rotate(-1.5deg);transform-origin:bottom right}.pair.swipe .cool{transform:rotate(1.5deg);transform-origin:bottom left}.versus{position:absolute;z-index:2;left:50%;top:50%;display:grid;width:66px;height:66px;place-items:center;transform:translate(-50%,-50%);border:5px solid var(--ink);border-radius:50%;background:#fff}.multiArena{display:grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;gap:8px;height:calc(100dvh - 145px);max-height:850px}.miniMatch{display:grid;grid-template-columns:1fr 38px 1fr;min-height:0;overflow:hidden;border:2px solid var(--ink)}.miniMatch button{padding:12px;background:#fff;font-weight:850}.miniMatch span{display:grid;place-items:center;background:var(--ink);color:#fff;font-size:12px}.miniMatch.resolved{display:grid;place-items:center;text-align:center;background:var(--ink);color:#fff}.toast{position:fixed;left:50%;bottom:25px;padding:13px 20px;transform:translateX(-50%);border-radius:99px;background:var(--ink);color:#fff}.editorPage{min-height:100vh;padding:18px;background:#151515;color:#fff}.editorHeader{display:grid;grid-template-columns:45px 1fr auto;align-items:center;gap:12px;max-width:1100px;margin:0 auto 16px}.editorHeader>button:first-child{background:transparent;color:#fff;font-size:25px}.editorHeader div{display:flex;min-width:0;flex-direction:column}.editorHeader small{overflow:hidden;color:#888;text-overflow:ellipsis;white-space:nowrap}.export{padding:10px 19px;border-radius:99px;background:var(--pink);color:#fff;font-weight:800}.editorWorkspace{display:grid;grid-template-columns:minmax(0,750px) 330px;gap:22px;max-width:1100px;margin:auto}.editorWorkspace canvas{width:100%;height:auto;background:#222}.editorWorkspace aside{min-width:0}.scrollRow,.toolPanel{display:flex;gap:7px;overflow-x:auto}.scrollRow button,.toolPanel button{flex:none;padding:10px 13px;background:#292929;color:#aaa}.scrollRow button.active{background:#fff;color:#111}.mediaRow{display:flex;gap:10px;margin-top:17px}.mediaRow>span,.mediaRow>label{display:flex;width:96px;height:96px;flex-direction:column;align-items:center;justify-content:center;border:2px solid #555}.mediaRow>span{border-color:#fff;background:#f3eadf;color:#111;font-weight:800}.mediaRow label{background:#222;cursor:pointer}.mediaRow small{margin-top:5px;color:#999}.mediaRow input{display:none}.toolRow{display:flex;justify-content:space-between;margin-top:20px;overflow-x:auto}.toolRow button{display:flex;min-width:58px;flex-direction:column;align-items:center;background:transparent;color:#888}.toolRow button.active{color:#fff}.toolRow i{display:grid;width:40px;height:40px;place-items:center;margin-bottom:6px;border:2px solid currentColor;font-style:normal}.toolPanel{margin-top:16px;padding:14px;background:#222}.toolPanel>input{width:100%;padding:12px;border:0}.toolPanel p{margin:0;color:#aaa;font-size:13px}.adjust{width:100%}.adjust label{display:grid;grid-template-columns:80px 1fr;align-items:center;color:#aaa;font-size:12px} @media(max-width:720px){.homeShell{width:min(100% - 18px,960px)}.hero{grid-template-columns:1fr}.hero img{min-height:0;aspect-ratio:4/3}.heroCopy{padding:28px}.heroCopy h1{font-size:48px}.arenaPage{padding:14px 9px}.pair,.multiArena{height:calc(100dvh - 135px)}.choice{padding:17px}.choice strong{font-size:clamp(27px,8vw,48px)}.choice>span{left:17px}.multiArena{gap:5px}.miniMatch{grid-template-columns:1fr 27px 1fr}.miniMatch button{padding:6px;font-size:13px}.editorPage{padding:11px}.editorWorkspace{grid-template-columns:1fr}.editorHeader{grid-template-columns:34px 1fr auto}.editorWorkspace aside{padding-bottom:20px}} + +.editorPage{min-height:100dvh;padding:0 18px 24px;background:#17171b;color:#f7f7f8}.editorHeader{position:sticky;top:0;z-index:5;display:grid;grid-template-columns:44px 1fr auto;max-width:1180px;height:68px;margin:auto;padding:8px 0;background:#17171bf2;backdrop-filter:blur(18px)}.editorHeader .backButton{display:grid;width:40px;height:40px;place-items:center;align-self:center;border-radius:50%;background:transparent;color:#fff;font-size:38px}.editorHeader strong{font-size:15px}.editorHeader small{margin-top:3px;color:#7f7f88;font-size:11px}.export{padding:0 23px;height:42px;border-radius:23px;background:#ff2442;font-size:14px}.editorWorkspace{grid-template-columns:minmax(0,760px) 360px;gap:22px;max-width:1180px;align-items:start}.canvasStage{display:grid;min-height:calc(100dvh - 94px);place-items:center;padding:18px;background:#101013}.editorWorkspace canvas{display:block;width:auto;max-width:100%;height:auto;max-height:calc(100dvh - 130px);box-shadow:0 16px 60px #0008}.editorWorkspace aside{position:sticky;top:86px;padding:16px;border:1px solid #2b2b31;border-radius:18px;background:#202025}.viewTabs{padding-bottom:12px;border-bottom:1px solid #303036}.scrollRow button{padding:8px 12px;border-radius:16px;background:transparent;color:#888891;font-size:12px}.scrollRow button.active{background:#f4f4f5;color:#151515}.mediaRow{margin-top:14px}.mediaRow>span,.mediaRow>label{width:78px;height:78px;border-radius:9px}.mediaRow>span{border-color:#fff;background:#fff;color:#17171b}.mediaRow b{font-size:22px}.mediaRow small{font-size:10px}.toolRow{gap:4px;margin-top:18px}.toolRow button{min-width:44px;font-size:10px}.toolRow i{width:34px;height:34px;margin-bottom:5px;border:0;border-radius:50%;background:#29292f;font-size:18px}.toolRow button.active i{background:#f4f4f5;color:#17171b}.toolPanel{min-height:72px;margin-top:14px;padding:12px;border-radius:12px;background:#19191d}.presetRow{display:flex;gap:8px;width:100%}.presetRow button{display:flex;flex:1;align-items:center;gap:7px;padding:8px;border:1px solid transparent;border-radius:9px;background:#25252a;color:#aaa}.presetRow button.selected{border-color:#eee;color:#fff}.swatch{width:22px;height:22px;border:1px solid #777;border-radius:50%}.swatch.clean{background:#f4f4f4}.swatch.cream{background:#eee9df}.swatch.night{background:#111114}.settingButton{display:flex!important;width:100%;justify-content:space-between}.toolPanel input{background:#2a2a30;color:#fff}.toolPanel p{color:#787881} +@media(max-width:820px){.editorPage{height:100dvh;overflow:hidden;padding:0}.editorHeader{height:58px;padding:6px 12px}.editorWorkspace{display:flex;height:calc(100dvh - 58px);flex-direction:column;gap:0}.canvasStage{min-height:0;height:58%;padding:10px}.editorWorkspace canvas{max-height:100%;max-width:100%}.editorWorkspace aside{position:static;width:100%;height:42%;padding:10px 12px calc(10px + env(safe-area-inset-bottom));overflow:hidden;border:0;border-top:1px solid #2b2b31;border-radius:0;background:#1d1d21}.viewTabs{padding-bottom:7px}.mediaRow{margin-top:8px}.mediaRow>span,.mediaRow>label{width:58px;height:58px}.toolRow{margin-top:9px}.toolPanel{min-height:54px;margin-top:8px;padding:9px}.toolRow i{width:30px;height:30px}.export{height:38px}.presetRow button{padding:6px;font-size:11px}} diff --git a/web/app/health/route.js b/web/app/health/route.js deleted file mode 100644 index 75dc10b..0000000 --- a/web/app/health/route.js +++ /dev/null @@ -1 +0,0 @@ -export function GET() { return Response.json({ status: 'ok' }); } diff --git a/web/app/health/route.ts b/web/app/health/route.ts new file mode 100644 index 0000000..3e24fac --- /dev/null +++ b/web/app/health/route.ts @@ -0,0 +1 @@ +export function GET():Response { return Response.json({ status: 'ok' }); } diff --git a/web/app/layout.jsx b/web/app/layout.jsx deleted file mode 100644 index 465ef3d..0000000 --- a/web/app/layout.jsx +++ /dev/null @@ -1,4 +0,0 @@ -import './globals.css'; -export const metadata = { title: '本命饭 PK', description: '各种各样的饭,二选一。' }; -export const viewport = { width: 'device-width', initialScale: 1, themeColor: '#fff8f2' }; -export default function RootLayout({ children }) { return {children}; } diff --git a/web/app/layout.tsx b/web/app/layout.tsx new file mode 100644 index 0000000..12bd1c2 --- /dev/null +++ b/web/app/layout.tsx @@ -0,0 +1,6 @@ +import './globals.css'; +import type { Metadata, Viewport } from 'next'; +import type { ReactNode } from 'react'; +export const metadata:Metadata = { title: '本命饭 PK', description: '各种各样的饭,二选一。' }; +export const viewport:Viewport = { width: 'device-width', initialScale: 1, themeColor: '#fff8f2' }; +export default function RootLayout({ children }:{children:ReactNode}) { return {children}; } diff --git a/web/app/page.jsx b/web/app/page.jsx deleted file mode 100644 index 765ee09..0000000 --- a/web/app/page.jsx +++ /dev/null @@ -1,2 +0,0 @@ -import RiceHome from '../components/RiceHome'; -export default function HomePage() { return ; } diff --git a/web/app/page.tsx b/web/app/page.tsx new file mode 100644 index 0000000..7cdf734 --- /dev/null +++ b/web/app/page.tsx @@ -0,0 +1,2 @@ +import RiceHome from '../components/RiceHome'; +export default function HomePage():React.ReactElement { return ; } diff --git a/web/app/results/[id]/page.jsx b/web/app/results/[id]/page.jsx deleted file mode 100644 index e50226b..0000000 --- a/web/app/results/[id]/page.jsx +++ /dev/null @@ -1,2 +0,0 @@ -import RicePosterEditor from '../../../components/RicePosterEditor'; -export default async function ResultPage({ params }) { const { id } = await params; return ; } diff --git a/web/app/results/[id]/page.tsx b/web/app/results/[id]/page.tsx new file mode 100644 index 0000000..60ad6aa --- /dev/null +++ b/web/app/results/[id]/page.tsx @@ -0,0 +1,2 @@ +import RicePosterEditor from '../../../components/RicePosterEditor'; +export default async function ResultPage({ params }:{params:Promise<{id:string}>}) { const { id } = await params; return ; } diff --git a/web/app/test/page.jsx b/web/app/test/page.jsx deleted file mode 100644 index 80a990f..0000000 --- a/web/app/test/page.jsx +++ /dev/null @@ -1,2 +0,0 @@ -import RiceArena from '../../components/RiceArena'; -export default function TestPage() { return ; } diff --git a/web/app/test/page.tsx b/web/app/test/page.tsx new file mode 100644 index 0000000..bd5bc1d --- /dev/null +++ b/web/app/test/page.tsx @@ -0,0 +1,2 @@ +import RiceArena from '../../components/RiceArena'; +export default function TestPage():React.ReactElement { return ; } diff --git a/web/components/RiceArena.jsx b/web/components/RiceArena.jsx deleted file mode 100644 index 8937154..0000000 --- a/web/components/RiceArena.jsx +++ /dev/null @@ -1,18 +0,0 @@ -'use client'; -import { AnimatePresence, motion } from 'motion/react'; -import { useRouter } from 'next/navigation'; -import { useEffect, useMemo, useRef, useState } from 'react'; -import { apiRequest, ensureLogin } from '../lib/api'; -import { chooseMatch, createBracket, firstOpenMatch } from '../lib/bracket'; -const MODES=[['single','一对'],['multi','多对'],['swipe','滑动']]; -export default function RiceArena(){ - const router=useRouter(),pointerStart=useRef(null);const[test,setTest]=useState(null),[bracket,setBracket]=useState(null),[mode,setMode]=useState('single'),[error,setError]=useState(''),[saving,setSaving]=useState(false); - useEffect(()=>{apiRequest('/api/v1/tests/rice').then(data=>{setTest(data);setBracket(createBracket(data.candidates));}).catch(reason=>setError(reason.message));},[]); - const total=test?test.candidates.length-1:1,currentIndex=bracket?firstOpenMatch(bracket):-1,current=currentIndex>=0?bracket.matches[currentIndex]:null,visible=useMemo(()=>bracket?.matches.slice(bracket.offset,bracket.offset+4)||[],[bracket]); - async function complete(finalState){setSaving(true);const champion=finalState.pool[0],last=finalState.decisions.at(-1),runner=last.left_id===champion.id?last.right_id:last.left_id;try{await ensureLogin();const result=await apiRequest('/api/v1/tests/rice/runs',{method:'POST',body:{app_key:'rice',champion_id:champion.id,runner_up_id:runner,ranking_ids:[champion,...finalState.eliminated].map(item=>item.id),decisions:finalState.decisions}});sessionStorage.setItem('rice_last_result',JSON.stringify(result));router.replace(`/results/${result.id}`);}catch(reason){setError(reason.message);setSaving(false);}} - function select(index,side){if(saving)return;const outcome=chooseMatch(bracket,index,side);let next=outcome.state;if(!outcome.finished&&mode==='multi'){const block=next.matches.slice(next.offset,next.offset+4);if(block.length&&block.every(match=>match.winner))next={...next,offset:next.offset+4};}setBracket(next);if(outcome.finished)complete(next);} - function pointerUp(event){if(mode!=='swipe'||pointerStart.current===null)return;const delta=event.clientX-pointerStart.current;pointerStart.current=null;if(Math.abs(delta)>=55)select(currentIndex,delta<0?'left':'right');} - if(error)return

暂时无法继续

{error}

;if(!test||!bracket)return
正在抽签…
; - return
第 {bracket.round} 轮{bracket.decisions.length} / {total}
{MODES.map(([id,label])=>)}
- {mode==='multi'?
{visible.map((match,index)=>{const matchIndex=bracket.offset+index;return {match.winner?晋级
{match.winner.name}
:<>VS}
;})}
:
{pointerStart.current=event.clientX;}} onPointerUp={pointerUp}>mode!=='swipe'&&select(currentIndex,'left')} className="choice warm">A{current.left.name}{mode==='swipe'?'向左滑选它':'点按晋级'}VSmode!=='swipe'&&select(currentIndex,'right')} className="choice cool">B{current.right.name}{mode==='swipe'?'向右滑选它':'点按晋级'}
}{saving?
正在保存你的冠军…
:null}
; -} diff --git a/web/components/RiceArena.tsx b/web/components/RiceArena.tsx new file mode 100644 index 0000000..44816ac --- /dev/null +++ b/web/components/RiceArena.tsx @@ -0,0 +1,3 @@ +'use client'; +import{AnimatePresence,motion}from'motion/react';import{useRouter}from'next/navigation';import{useEffect,useMemo,useRef,useState}from'react';import{apiRequest,ensureLogin}from'../lib/api';import{chooseMatch,createBracket,firstOpenMatch}from'../lib/bracket';import type{BracketState,ChoiceSide,TestDetail,TestResult}from'../lib/types';type Mode='single'|'multi'|'swipe';const MODES:readonly{id:Mode;label:string}[]=[{id:'single',label:'一对'},{id:'multi',label:'多对'},{id:'swipe',label:'滑动'}]; +export default function RiceArena(){const router=useRouter(),pointerStart=useRef(null);const[test,setTest]=useState(null),[bracket,setBracket]=useState(null),[mode,setMode]=useState('single'),[error,setError]=useState(''),[saving,setSaving]=useState(false);useEffect(()=>{apiRequest('/api/v1/tests/rice').then(data=>{setTest(data);setBracket(createBracket(data.candidates));}).catch((reason:Error)=>setError(reason.message));},[]);const total=test?test.candidates.length-1:1,currentIndex=bracket?firstOpenMatch(bracket):-1,current=bracket&¤tIndex>=0?bracket.matches[currentIndex]:null,visible=useMemo(()=>bracket?.matches.slice(bracket.offset,bracket.offset+4)||[],[bracket]);async function complete(finalState:BracketState){setSaving(true);try{const champion=finalState.pool[0],last=finalState.decisions.at(-1);if(!champion||!last)throw new Error('晋级结果不完整');const runner=last.left_id===champion.id?last.right_id:last.left_id;await ensureLogin();const result=await apiRequest('/api/v1/tests/rice/runs',{method:'POST',body:{app_key:'rice',champion_id:champion.id,runner_up_id:runner,ranking_ids:[champion,...finalState.eliminated].map(item=>item.id),decisions:finalState.decisions}});sessionStorage.setItem('rice_last_result',JSON.stringify(result));router.replace(`/results/${result.id}`);}catch(reason){setError(reason instanceof Error?reason.message:'保存失败');setSaving(false);}}function select(index:number,side:ChoiceSide){if(saving||!bracket)return;const outcome=chooseMatch(bracket,index,side);let next=outcome.state;if(!outcome.finished&&mode==='multi'){const block=next.matches.slice(next.offset,next.offset+4);if(block.length&&block.every(match=>match.winner))next={...next,offset:next.offset+4};}setBracket(next);if(outcome.finished)complete(next);}function pointerUp(event:React.PointerEvent){if(mode!=='swipe'||pointerStart.current===null)return;const delta=event.clientX-pointerStart.current;pointerStart.current=null;if(Math.abs(delta)>=55)select(currentIndex,delta<0?'left':'right');}if(error)return

暂时无法继续

{error}

;if(!test||!bracket)return
正在抽签…
;if(!current&&mode!=='multi')return
正在生成晋级图…
;return
第 {bracket.round} 轮{bracket.decisions.length} / {total}
{MODES.map(item=>)}
{mode==='multi'?
{visible.map((match,index)=>{const matchIndex=bracket.offset+index;return{match.winner?晋级
{match.winner.name}
:<>VS}
;})}
:
{pointerStart.current=event.clientX;}} onPointerUp={pointerUp}>mode!=='swipe'&&select(currentIndex,'left')} className="choice warm">A{current!.left.name}{mode==='swipe'?'向左滑选它':'点按晋级'}VSmode!=='swipe'&&select(currentIndex,'right')} className="choice cool">B{current!.right!.name}{mode==='swipe'?'向右滑选它':'点按晋级'}
}{saving?
正在保存你的冠军…
:null}
;} diff --git a/web/components/RiceHome.jsx b/web/components/RiceHome.tsx similarity index 70% rename from web/components/RiceHome.jsx rename to web/components/RiceHome.tsx index 94311d8..80321b3 100644 --- a/web/components/RiceHome.jsx +++ b/web/components/RiceHome.tsx @@ -4,11 +4,12 @@ import Image from 'next/image'; import Link from 'next/link'; import { useEffect, useState } from 'react'; import { apiRequest, ensureLogin } from '../lib/api'; +import type { TestDetail, TestResult } from '../lib/types'; export default function RiceHome() { - const [test,setTest]=useState(null),[results,setResults]=useState([]),[code,setCode]=useState(''),[message,setMessage]=useState(''),[error,setError]=useState(''); - useEffect(()=>{Promise.all([apiRequest('/api/v1/tests/rice'),ensureLogin().then(()=>apiRequest('/api/v1/me/results?test_slug=rice'))]).then(([data,history])=>{setTest(data);setResults(history);}).catch(reason=>setError(reason.message));},[]); - async function bind(){if(code.trim().length!==6){setMessage('请输入 6 位绑定码');return;}try{const data=await apiRequest('/api/v1/me/link-code/confirm',{method:'POST',body:{code:code.trim().toUpperCase()}});localStorage.setItem('rice_token',data.token);setMessage('档案已连接');setResults(await apiRequest('/api/v1/me/results?test_slug=rice'));}catch(reason){setMessage(reason.message);}} + const [test,setTest]=useState(null),[results,setResults]=useState([]),[code,setCode]=useState(''),[message,setMessage]=useState(''),[error,setError]=useState(''); + useEffect(()=>{Promise.all([apiRequest('/api/v1/tests/rice'),ensureLogin().then(()=>apiRequest('/api/v1/me/results?test_slug=rice'))]).then(([data,history])=>{setTest(data);setResults(history);}).catch((reason:Error)=>setError(reason.message));},[]); + async function bind(){if(code.trim().length!==6){setMessage('请输入 6 位绑定码');return;}try{const data=await apiRequest<{token:string}>('/api/v1/me/link-code/confirm',{method:'POST',body:{code:code.trim().toUpperCase()}});localStorage.setItem('rice_token',data.token);setMessage('档案已连接');setResults(await apiRequest('/api/v1/me/results?test_slug=rice'));}catch(reason){setMessage(reason instanceof Error?reason.message:'连接失败');}} if(error)return

暂时没接上服务

{error}

; if(!test)return
正在摆好饭桌…
; return
diff --git a/web/components/RicePosterEditor.jsx b/web/components/RicePosterEditor.jsx deleted file mode 100644 index b82058c..0000000 --- a/web/components/RicePosterEditor.jsx +++ /dev/null @@ -1,18 +0,0 @@ -'use client'; -import { motion } from 'motion/react'; -import { useRouter } from 'next/navigation'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { apiRequest, ensureLogin } from '../lib/api'; -const THEMES={celebration:['#ff6038','#fffaf4','#25211f','#ffe568'],editorial:['#f3eadf','#201d1b','#fff','#285ee8'],notebook:['#c9f06b','#1e2810','#ff72a7','#fff']}; -const VIEWS=[['overview','总览'],['left','左半区'],['right','右半区'],['top16','16 强'],['top8','8 强'],['final','决赛']]; -const TOOLS=[['filter','滤镜'],['text','文字'],['sticker','贴纸'],['crop','裁剪'],['adjust','调整'],['layout','布局'],['brush','画笔']]; -export default function RicePosterEditor({runId}){ - const router=useRouter(),canvasRef=useRef(null);const[result,setResult]=useState(null),[error,setError]=useState(''),[theme,setTheme]=useState('celebration'),[layout,setLayout]=useState('symmetric'),[view,setView]=useState('overview'),[tool,setTool]=useState('filter'),[caption,setCaption]=useState(''),[sticker,setSticker]=useState(true),[background,setBackground]=useState(null),[adjust,setAdjust]=useState({brightness:100,contrast:100,saturate:100}); - useEffect(()=>{async function loadResult(){const cached=sessionStorage.getItem('rice_last_result');if(cached){const parsed=JSON.parse(cached);if(parsed.id===runId)return parsed;}await ensureLogin();return apiRequest(`/api/v1/me/results/${runId}`);}loadResult().then(setResult).catch(reason=>setError(reason.message));},[runId]); - const draw=useCallback(()=>{if(!result||!canvasRef.current)return;const canvas=canvasRef.current,ctx=canvas.getContext('2d'),colors=THEMES[theme];ctx.clearRect(0,0,750,1000);ctx.fillStyle=colors[0];ctx.fillRect(0,0,750,1000);if(background){ctx.save();ctx.filter=`brightness(${adjust.brightness}%) contrast(${adjust.contrast}%) saturate(${adjust.saturate}%)`;cover(ctx,background,0,0,750,1000);ctx.restore();ctx.fillStyle=`${colors[0]}d8`;ctx.fillRect(0,0,750,1000);}ctx.fillStyle=colors[1];ctx.font='700 24px system-ui';ctx.fillText('RICE PK / BRACKET',42,62);ctx.font='900 48px system-ui';ctx.fillText(result.test_title,42,125);ctx.fillStyle=colors[2];ctx.fillRect(28,165,694,690);drawBracket(ctx,result,colors,layout,view);ctx.fillStyle=colors[1];ctx.font='800 25px system-ui';ctx.fillText(caption||`冠军 ${result.champion_name}`,42,910);if(sticker){ctx.fillStyle=colors[3];ctx.beginPath();ctx.arc(660,100,48,0,Math.PI*2);ctx.fill();ctx.fillStyle=colors[2];ctx.font='900 21px system-ui';ctx.fillText('开饭',636,108);}ctx.fillStyle=colors[1];ctx.font='18px system-ui';ctx.fillText('保存完整晋级路线,分享你的最终选择',42,962);},[adjust,background,caption,layout,result,sticker,theme,view]);useEffect(()=>{draw();},[draw]); - function pick(event){const file=event.target.files?.[0];if(!file)return;const image=new Image();image.onload=()=>setBackground(image);image.src=URL.createObjectURL(file);}function save(){const link=document.createElement('a');link.download=`我的本命饭-${result.champion_name}.png`;link.href=canvasRef.current.toDataURL('image/png');link.click();} - if(error)return

结果读取失败

{error}

;if(!result)return
正在打开编辑器…
; - return
分享图编辑器{result.test_title} · {result.champion_name}
; -} -function cover(ctx,image,x,y,width,height){const ratio=Math.max(width/image.width,height/image.height),w=image.width*ratio,h=image.height*ratio;ctx.drawImage(image,x+(width-w)/2,y+(height-h)/2,w,h);} -function drawBracket(ctx,result,colors,layout,view){const decisions=result.decisions||[],names=result.candidate_names||{};if(!decisions.length){ctx.fillStyle=colors[1];ctx.font='900 72px system-ui';ctx.fillText(result.champion_name,70,500);return;}const maxRound=Math.max(...decisions.map(item=>item.round)),minRound=view==='final'?maxRound:view==='top8'?Math.max(1,maxRound-2):view==='top16'?Math.max(1,maxRound-3):1;let visible=decisions.filter(item=>item.round>=minRound);if(view==='left'||view==='right')visible=visible.filter((item,index)=>item.round===maxRound||index%2===(view==='left'?0:1));const groups={},positions={};visible.forEach(item=>(groups[item.round]||=[]).push(item));Object.entries(groups).forEach(([roundValue,items])=>{const round=Number(roundValue);items.forEach((item,index)=>{const symmetric=layout==='symmetric',side=symmetric&&round{const parentDecision=visible.find(item=>item.round>position.item.round&&[item.left_id,item.right_id].includes(position.item.winner_id)),parent=parentDecision&&positions[`${parentDecision.winner_id}:${parentDecision.round}`];if(!parent)return;ctx.beginPath();ctx.moveTo(position.x,position.y);ctx.lineTo((position.x+parent.x)/2,position.y);ctx.lineTo((position.x+parent.x)/2,parent.y);ctx.lineTo(parent.x,parent.y);ctx.stroke();});Object.values(positions).forEach(position=>{const label=names[position.item.winner_id]||result.champion_name;ctx.fillStyle=position.item.round===maxRound?colors[3]:colors[0];ctx.fillRect(position.x-56,position.y-20,112,40);ctx.fillStyle=colors[1];ctx.font=`${label.length>6?13:16}px system-ui`;ctx.fillText(label.slice(0,8),position.x-47,position.y+6);});} diff --git a/web/components/RicePosterEditor.tsx b/web/components/RicePosterEditor.tsx new file mode 100644 index 0000000..81ebb6d --- /dev/null +++ b/web/components/RicePosterEditor.tsx @@ -0,0 +1,4 @@ +'use client'; +import{motion}from'motion/react';import{useRouter}from'next/navigation';import{useCallback,useEffect,useRef,useState,type ChangeEvent}from'react';import{apiRequest,ensureLogin}from'../lib/api';import{drawPoster}from'../lib/bracketPoster';import type{TestResult}from'../lib/types'; +type Theme='clean'|'cream'|'night';type Layout='symmetric'|'single';type View='overview'|'left'|'right'|'top16'|'top8'|'final';type Tool='filter'|'text'|'sticker'|'crop'|'adjust'|'layout'|'brush';type Adjust={brightness:number;contrast:number;saturate:number};const TOOLS:readonly[Tool,string,string][]=[['filter','滤镜','◌'],['text','文字','T'],['sticker','贴纸','▣'],['crop','裁剪','⌗'],['adjust','调整','☷'],['layout','布局','▦'],['brush','画笔','⌁']];const VIEWS:readonly[View,string][]=[['overview','总览'],['left','左半区'],['right','右半区'],['top16','16 强'],['top8','8 强'],['final','决赛']]; +export default function RicePosterEditor({runId}:{runId:string}){const router=useRouter(),canvasRef=useRef(null);const[result,setResult]=useState(null),[error,setError]=useState(''),[theme,setTheme]=useState('clean'),[layout,setLayout]=useState('symmetric'),[view,setView]=useState('overview'),[tool,setTool]=useState('filter'),[caption,setCaption]=useState(''),[sticker,setSticker]=useState(true),[background,setBackground]=useState(null),[adjust,setAdjust]=useState({brightness:100,contrast:100,saturate:100});useEffect(()=>{async function load(){const cached=sessionStorage.getItem('rice_last_result');if(cached){const parsed=JSON.parse(cached)as TestResult;if(parsed.id===runId)return parsed;}await ensureLogin();return apiRequest(`/api/v1/me/results/${runId}`);}load().then(setResult).catch((reason:Error)=>setError(reason.message));},[runId]);const draw=useCallback(()=>{const canvas=canvasRef.current;if(!result||!canvas)return;const ctx=canvas.getContext('2d');if(ctx)drawPoster(ctx,result,{themeId:theme,layout,view,caption,sticker,background,adjust});},[adjust,background,caption,layout,result,sticker,theme,view]);useEffect(()=>draw(),[draw]);function pick(event:ChangeEvent){const file=event.target.files?.[0];if(!file)return;const image=new Image(),url=URL.createObjectURL(file);image.onload=()=>{setBackground(image);URL.revokeObjectURL(url);};image.src=url;}function save(){const canvas=canvasRef.current;if(!canvas||!result)return;const link=document.createElement('a');link.download=`我的本命饭晋级图-${result.champion_name}.png`;link.href=canvas.toDataURL('image/png');link.click();}if(error)return

结果读取失败

{error}

;if(!result)return
正在打开编辑器…
;return
编辑分享图{result.test_title} · {result.champion_name}
;} diff --git a/web/lib/api.js b/web/lib/api.ts similarity index 64% rename from web/lib/api.js rename to web/lib/api.ts index 5848ebf..bdc6ec1 100644 --- a/web/lib/api.js +++ b/web/lib/api.ts @@ -1,18 +1,19 @@ 'use client'; -export async function apiRequest(path, options = {}) { +interface ApiOptions{method?:string;headers?:Record;body?:unknown} +export async function apiRequest(path:string,options:ApiOptions={}):Promise { const token = typeof window === 'undefined' ? null : localStorage.getItem('rice_token'); - const headers = { Accept: 'application/json', ...(options.headers || {}) }; + const headers:Record = { Accept: 'application/json', ...(options.headers || {}) }; if (options.body !== undefined) headers['Content-Type'] = 'application/json'; if (token) headers.Authorization = `Bearer ${token}`; const response = await fetch(path, { method: options.method || 'GET', headers, body: options.body === undefined ? undefined : JSON.stringify(options.body), cache: 'no-store' }); const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : '请求失败'); - return data; + return data as T; } -export async function ensureLogin() { +export async function ensureLogin():Promise { if (localStorage.getItem('rice_token')) return; let previewId = localStorage.getItem('rice_preview_id'); if (!previewId) { previewId = `${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`; localStorage.setItem('rice_preview_id', previewId); } - const session = await apiRequest('/api/v1/auth/xhs', { method: 'POST', body: { app_key: 'rice', code: `dev:web-${previewId}` } }); + const session = await apiRequest<{token:string}>('/api/v1/auth/xhs', { method: 'POST', body: { app_key: 'rice', code: `dev:web-${previewId}` } }); localStorage.setItem('rice_token', session.token); } diff --git a/web/lib/bracket.js b/web/lib/bracket.js deleted file mode 100644 index 56573e5..0000000 --- a/web/lib/bracket.js +++ /dev/null @@ -1,4 +0,0 @@ -export function buildMatches(pool) { const matches=[]; for(let index=0;indexi===index?{...item,winner}:item); let next={...state,matches,eliminated:[loser,...state.eliminated],decisions:[...state.decisions,{round:state.round,left_id:match.left.id,right_id:match.right.id,winner_id:winner.id}]}; if(matches.every(item=>item.winner)){const pool=matches.map(item=>item.winner);if(pool.length===1)return{state:{...next,pool},finished:true};next={...next,round:state.round+1,pool,matches:buildMatches(pool),offset:0};}return{state:next,finished:false}; } -export function firstOpenMatch(state) { return state.matches.findIndex(match=>match.right&&!match.winner); } diff --git a/web/lib/bracket.ts b/web/lib/bracket.ts new file mode 100644 index 0000000..df1b414 --- /dev/null +++ b/web/lib/bracket.ts @@ -0,0 +1,5 @@ +import type{BracketState,Candidate,ChoiceSide,Match}from'./types'; +export function buildMatches(pool:Candidate[]):Match[]{const matches:Match[]=[];for(let index=0;indexi===index?{...item,winner}:item);let next:BracketState={...state,matches,eliminated:[loser,...state.eliminated],decisions:[...state.decisions,{round:state.round,left_id:match.left.id,right_id:match.right.id,winner_id:winner.id}]};if(matches.every(item=>item.winner)){const pool=matches.map(item=>item.winner).filter((item):item is Candidate=>Boolean(item));if(pool.length===1)return{state:{...next,pool},finished:true};next={...next,round:state.round+1,pool,matches:buildMatches(pool),offset:0};}return{state:next,finished:false};} +export function firstOpenMatch(state:BracketState):number{return state.matches.findIndex(match=>match.right&&!match.winner);} diff --git a/web/lib/bracketPoster.ts b/web/lib/bracketPoster.ts new file mode 100644 index 0000000..936da3e --- /dev/null +++ b/web/lib/bracketPoster.ts @@ -0,0 +1,10 @@ +import type{TestResult}from'./types'; +export const POSTER_THEMES={clean:{page:'#f4f4f4',paper:'#fff',ink:'#151515',muted:'#929292',grid:'#d8d8d8',soft:'#f7f7f7',accent:'#ff2442'},cream:{page:'#eee9df',paper:'#fbf8f2',ink:'#24211d',muted:'#8f887e',grid:'#d5cec3',soft:'#f1ece4',accent:'#e34d35'},night:{page:'#111114',paper:'#1b1b1f',ink:'#f7f7f7',muted:'#888891',grid:'#414148',soft:'#26262b',accent:'#ff3855'}}as const; +type ThemeId=keyof typeof POSTER_THEMES;type LayoutId='symmetric'|'single';type ViewId='overview'|'left'|'right'|'top16'|'top8'|'final';type Theme=(typeof POSTER_THEMES)[ThemeId];interface Node{id:string;name:string;round:number;children:Node[];x:number;y:number}interface Box{x:number;y:number;width:number;height:number}interface Options{themeId?:ThemeId;layout?:LayoutId;view?:ViewId;caption?:string;sticker?:boolean;background?:HTMLImageElement|null;adjust?:Partial>} +export function drawPoster(ctx:CanvasRenderingContext2D,result:TestResult,{themeId='clean',layout='symmetric',view='overview',caption='',sticker=true,background=null,adjust={}}:Options={}):void{const theme=POSTER_THEMES[themeId],W=1080,H=1440;ctx.clearRect(0,0,W,H);ctx.fillStyle=theme.page;ctx.fillRect(0,0,W,H);if(background){ctx.save();ctx.filter=`brightness(${adjust.brightness??100}%) contrast(${adjust.contrast??100}%) saturate(${adjust.saturate??100}%)`;cover(ctx,background,0,0,W,H);ctx.restore();ctx.fillStyle=themeId==='night'?'#111114e8':'#f4f4f4e8';ctx.fillRect(0,0,W,H);}ctx.fillStyle=theme.ink;ctx.font='700 22px system-ui';ctx.fillText('RICE PK / MY BRACKET',54,54);ctx.font='800 48px system-ui';ctx.fillText(result.test_title,54,115);ctx.fillStyle=theme.muted;ctx.font='400 20px system-ui';ctx.fillText('每一次选择,都留在这张晋级路线里',54,151);rounded(ctx,48,184,984,1110,18);ctx.fillStyle=theme.paper;ctx.fill();const tree=selectTree(buildTree(result),view);if(layout==='symmetric'&&view!=='left'&&view!=='right'&&tree.children.length===2)drawSymmetric(ctx,tree,{x:72,y:250,width:936,height:980},theme);else drawSingle(ctx,tree,{x:72,y:250,width:936,height:980},theme);ctx.fillStyle=theme.ink;ctx.font='700 26px system-ui';ctx.fillText(caption||`最终选择 ${result.champion_name}`,54,1350);ctx.fillStyle=theme.muted;ctx.font='400 17px system-ui';ctx.fillText('保存图片,分享你的完整晋级路线',54,1385);if(sticker){rounded(ctx,884,50,142,48,24);ctx.fillStyle=theme.accent;ctx.fill();ctx.fillStyle='#fff';ctx.font='800 17px system-ui';ctx.textAlign='center';ctx.fillText('NO.1 PICK',955,81);ctx.textAlign='left';}} +function buildTree(result:TestResult):Node{const decisions=[...(result.decisions||[])],names=result.candidate_names||{},max=Math.max(1,...decisions.map(item=>item.round));const build=(id:string,before:number):Node=>{const decision=decisions.filter(item=>item.winner_id===id&&item.roundb.round-a.round)[0];return decision?{id,name:names[id]||id,round:decision.round,children:[build(decision.left_id,decision.round),build(decision.right_id,decision.round)],x:0,y:0}:{id,name:names[id]||id,round:0,children:[],x:0,y:0};};return build(result.champion_id||decisions.at(-1)?.winner_id||'',max+1);} +function selectTree(tree:Node,view:ViewId):Node{if(view==='left'&&tree.children[0])return tree.children[0];if(view==='right'&&tree.children[1])return tree.children[1];const levels:Partial>={top16:3,top8:2,final:1},level=levels[view];if(!level)return tree;const min=Math.max(0,tree.round-level),prune=(node:Node):Node=>node.round<=min?{...node,children:[]}:{...node,children:node.children.map(prune)};return prune(tree);} +function drawSingle(ctx:CanvasRenderingContext2D,tree:Node,box:Box,theme:Theme):void{const leaves:Node[]=[];collect(tree,leaves);const min=Math.min(...leaves.map(n=>n.round)),max=Math.max(tree.round,min+1);leafY(leaves,box.y+48,box.y+box.height-38);innerY(tree);walk(tree,n=>{n.x=box.x+22+(n.round-min)/(max-min)*(box.width-154);});ctx.fillStyle=theme.muted;ctx.font='600 12px system-ui';for(let round=min;round<=max;round+=1){const x=box.x+22+(round-min)/(max-min)*(box.width-154);ctx.fillText(round===min?'候选':round===max?'冠军':`第 ${round} 轮`,x,box.y+17);}edges(ctx,tree,theme);nodes(ctx,tree,theme,122,'left');} +function drawSymmetric(ctx:CanvasRenderingContext2D,tree:Node,box:Box,theme:Theme):void{const[left,right]=tree.children;if(!left||!right)return;const middle=box.x+box.width/2,side=box.width/2-46,ll:Node[]=[],rl:Node[]=[];collect(left,ll);collect(right,rl);leafY(ll,box.y+48,box.y+box.height-38);leafY(rl,box.y+48,box.y+box.height-38);innerY(left);innerY(right);tree.y=(left.y+right.y)/2;tree.x=middle;const depth=Math.max(1,tree.round);walk(left,n=>{n.x=middle-32-(tree.round-n.round)/depth*side;});walk(right,n=>{n.x=middle+32+(tree.round-n.round)/depth*side;});ctx.fillStyle=theme.muted;ctx.font='600 13px system-ui';ctx.fillText('LEFT BRACKET',box.x+4,box.y+17);ctx.textAlign='right';ctx.fillText('RIGHT BRACKET',box.x+box.width-4,box.y+17);ctx.textAlign='left';edges(ctx,tree,theme);nodes(ctx,tree,theme,76,'center');} +function edges(ctx:CanvasRenderingContext2D,tree:Node,theme:Theme):void{ctx.strokeStyle=theme.grid;ctx.lineWidth=1.4;walk(tree,parent=>parent.children.forEach(child=>{const turn=child.x+(parent.x-child.x)*.52;ctx.beginPath();ctx.moveTo(child.x,child.y);ctx.lineTo(turn,child.y);ctx.lineTo(turn,parent.y);ctx.lineTo(parent.x,parent.y);ctx.stroke();}));}function nodes(ctx:CanvasRenderingContext2D,tree:Node,theme:Theme,width:number,align:'left'|'center'):void{walk(tree,node=>{const champion=node===tree,h=24,x=align==='center'?node.x-width/2:node.x,y=node.y-h/2;ctx.fillStyle=champion?theme.accent:theme.soft;ctx.strokeStyle=champion?theme.accent:theme.grid;ctx.fillRect(x,y,width,h);ctx.strokeRect(x,y,width,h);ctx.fillStyle=champion?'#fff':theme.ink;ctx.font=`${champion?'700':'500'} ${width<90?10:12}px system-ui`;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(clip(node.name,width<90?5:8),x+width/2,node.y);ctx.textAlign='left';ctx.textBaseline='alphabetic';});} +function leafY(leaves:Node[],start:number,end:number):void{const gap=(end-start)/Math.max(1,leaves.length-1);leaves.forEach((n,i)=>{n.y=leaves.length===1?(start+end)/2:start+i*gap;});}function innerY(node:Node):number{if(!node.children.length)return node.y;node.children.forEach(innerY);node.y=node.children.reduce((sum,n)=>sum+n.y,0)/node.children.length;return node.y;}function collect(node:Node,out:Node[]):void{if(!node.children.length)out.push(node);else node.children.forEach(n=>collect(n,out));}function walk(node:Node,visit:(node:Node)=>void):void{visit(node);node.children.forEach(n=>walk(n,visit));}function clip(text:string,n:number):string{return text.length>n?`${text.slice(0,n-1)}…`:text;}function rounded(ctx:CanvasRenderingContext2D,x:number,y:number,w:number,h:number,r:number):void{ctx.beginPath();ctx.roundRect(x,y,w,h,r);}function cover(ctx:CanvasRenderingContext2D,image:HTMLImageElement,x:number,y:number,w:number,h:number):void{const ratio=Math.max(w/image.width,h/image.height),dw=image.width*ratio,dh=image.height*ratio;ctx.drawImage(image,x+(w-dw)/2,y+(h-dh)/2,dw,dh);} diff --git a/web/lib/types.ts b/web/lib/types.ts new file mode 100644 index 0000000..dec0450 --- /dev/null +++ b/web/lib/types.ts @@ -0,0 +1 @@ +export interface Candidate{id:string;name:string}export interface Decision{round:number;left_id:string;right_id:string;winner_id:string}export interface TestDetail{slug:string;title:string;subtitle:string;description:string;candidate_count:number;run_count:number;candidates:Candidate[]}export interface TestResult{id:string;test_slug:string;test_title:string;champion_id:string;champion_name:string;runner_up_name?:string|null;completed_at:string;ranking_names?:string[];decisions:Decision[];candidate_names:Record}export interface Match{left:Candidate;right:Candidate|null;winner:Candidate|null}export interface BracketState{round:number;pool:Candidate[];matches:Match[];eliminated:Candidate[];decisions:Decision[];offset:number}export type ChoiceSide='left'|'right'; diff --git a/web/next-env.d.ts b/web/next-env.d.ts new file mode 100644 index 0000000..ce4e94a --- /dev/null +++ b/web/next-env.d.ts @@ -0,0 +1,7 @@ +/// +/// +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web/package-lock.json b/web/package-lock.json index c101f04..8d80d02 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -14,8 +14,12 @@ "react-dom": "19.3.0" }, "devDependencies": { + "@types/node": "^24.7.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", "eslint": "^9.35.0", - "eslint-config-next": "16.3.5" + "eslint-config-next": "16.3.5", + "typescript": "^5.9.3" } }, "node_modules/@babel/code-frame": { @@ -1347,6 +1351,36 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "24.13.6", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.6.tgz", + "integrity": "sha512-SGrw/h3KPFshy3OE6ZL53LMBG5vGQQ8/gIpiqz/kRZhPJ7HgwCEs8LBuNtWLa8dvGZVpSF7+Bf+c11HUrCb/yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.70.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", @@ -2481,6 +2515,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -5867,12 +5908,11 @@ } }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5924,6 +5964,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/unrs-resolver": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.12.2.tgz", diff --git a/web/package.json b/web/package.json index 2425ead..ed93766 100644 --- a/web/package.json +++ b/web/package.json @@ -4,5 +4,5 @@ "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "eslint ." }, "dependencies": { "motion": "^12.23.24", "next": "16.3.5", "react": "19.3.0", "react-dom": "19.3.0" }, - "devDependencies": { "eslint": "^9.35.0", "eslint-config-next": "16.3.5" } + "devDependencies": { "@types/node": "^24.7.0", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "eslint": "^9.35.0", "eslint-config-next": "16.3.5", "typescript": "^5.9.3" } } diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..3b9a871 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"target":"ES2022","lib":["dom","dom.iterable","esnext"],"allowJs":false,"skipLibCheck":true,"strict":true,"noEmit":true,"esModuleInterop":true,"module":"esnext","moduleResolution":"bundler","resolveJsonModule":true,"isolatedModules":true,"jsx":"react-jsx","incremental":true,"plugins":[{"name":"next"}]},"include":["next-env.d.ts","**/*.ts","**/*.tsx",".next/types/**/*.ts",".next/dev/types/**/*.ts"],"exclude":["node_modules"]}