23 lines
5.6 KiB
TypeScript
23 lines
5.6 KiB
TypeScript
'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 ThemeId='clean'|'cream'|'night';type LayoutId='symmetric'|'single';type ViewId='overview'|'left'|'right'|'top16'|'top8'|'final';type ToolId='filter'|'text'|'sticker'|'crop'|'adjust'|'layout'|'brush';type Adjustments={brightness:number;contrast:number;saturate:number};
|
||
const TOOLS:readonly [ToolId,string,string][]=[['filter','滤镜','◌'],['text','文字','T'],['sticker','贴纸','▣'],['crop','裁剪','⌗'],['adjust','调整','☷'],['layout','布局','▦'],['brush','画笔','⌁']];
|
||
const VIEWS:readonly [ViewId,string][]=[['overview','总览'],['left','左半区'],['right','右半区'],['top16','16 强'],['top8','8 强'],['final','决赛']];
|
||
|
||
export default function PosterEditor({runId}:{runId:string}){
|
||
const router=useRouter(),canvasRef=useRef<HTMLCanvasElement>(null);const[result,setResult]=useState<TestResult|null>(null),[error,setError]=useState(''),[theme,setTheme]=useState<ThemeId>('clean'),[layout,setLayout]=useState<LayoutId>('symmetric'),[view,setView]=useState<ViewId>('overview'),[tool,setTool]=useState<ToolId>('filter'),[sticker,setSticker]=useState(true),[caption,setCaption]=useState(''),[background,setBackground]=useState<HTMLImageElement|null>(null),[adjust,setAdjust]=useState<Adjustments>({brightness:100,contrast:100,saturate:100});
|
||
useEffect(()=>{async function load(){const cached=sessionStorage.getItem('pk_last_result');if(cached){const parsed=JSON.parse(cached) as TestResult;if(parsed.id===runId)return parsed;}await ensureLogin();return apiRequest<TestResult>(`/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 pickBackground(event:ChangeEvent<HTMLInputElement>){const file=event.target.files?.[0];if(!file)return;const image=new Image();const 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=`我的PK晋级图-${result.champion_name}.png`;link.href=canvas.toDataURL('image/png');link.click();}
|
||
if(error)return <main className="arenaState"><h1>结果读取失败</h1><p>{error}</p><button onClick={()=>router.push('/')}>返回首页</button></main>;if(!result)return <main className="arenaState">正在打开编辑器…</main>;
|
||
return <main className="editorPage"><header className="editorHeader"><button className="backButton" onClick={()=>router.push('/me')} aria-label="返回">‹</button><div><strong>编辑分享图</strong><small>{result.test_title} · {result.champion_name}</small></div><button className="exportButton" onClick={save}>保存</button></header><section className="editorWorkspace"><div className="canvasStage"><canvas ref={canvasRef} width="1080" height="1440"/></div><aside className="editorControls"><div className="scrollRow viewTabs"><button className={layout==='symmetric'?'active':''} onClick={()=>setLayout('symmetric')}>对称</button><button className={layout==='single'?'active':''} onClick={()=>setLayout('single')}>单边</button>{VIEWS.map(([id,label])=><button key={id} className={view===id?'active':''} onClick={()=>setView(id)}>{label}</button>)}</div><div className="mediaRow"><span className="bracketThumb"><b>树</b><small>晋级图</small></span><label className="addMedia"><b>+</b><small>添加底图</small><input type="file" accept="image/*" onChange={pickBackground}/></label></div><nav className="toolRow">{TOOLS.map(([id,label,icon])=><button key={id} className={tool===id?'active':''} onClick={()=>setTool(id)}><i>{icon}</i><span>{label}</span></button>)}</nav><motion.div key={tool} className="toolPanel" initial={{opacity:0,y:6}} animate={{opacity:1,y:0}}>{tool==='filter'?<div className="presetRow"><button className={theme==='clean'?'selected':''} onClick={()=>setTheme('clean')}><i className="swatch clean"/>清透</button><button className={theme==='cream'?'selected':''} onClick={()=>setTheme('cream')}><i className="swatch cream"/>暖白</button><button className={theme==='night'?'selected':''} onClick={()=>setTheme('night')}><i className="swatch night"/>夜色</button></div>:null}{tool==='text'?<input value={caption} maxLength={24} onChange={event=>setCaption(event.target.value)} placeholder="添加一句话…"/>:null}{tool==='sticker'?<button className="settingButton" onClick={()=>setSticker(value=>!value)}><span>冠军标记</span><b>{sticker?'开启':'关闭'}</b></button>:null}{tool==='adjust'?<AdjustControls value={adjust} onChange={setAdjust}/>:null}{['crop','layout','brush'].includes(tool)?<p>更多预设将从素材服务加载;当前不会破坏原始晋级数据。</p>:null}</motion.div></aside></section></main>;
|
||
}
|
||
function AdjustControls({value,onChange}:{value:Adjustments;onChange:(value:Adjustments)=>void}){return <div className="adjustControls">{Object.entries(value).map(([name,amount])=><label key={name}><span>{name}</span><input type="range" min="50" max="150" value={amount} onChange={event=>onChange({...value,[name]:Number(event.target.value)})}/></label>)}</div>;}
|