Expose Studio cover rendering API and add three source-based layout tools
studio CI and deployment / verify-and-deploy (push) Successful in 3m51s
studio CI and deployment / verify-and-deploy (push) Successful in 3m51s
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import {useState} from 'react';
|
||||
import Head from 'next/head';
|
||||
import {SourceCover,selections,type CoverValues} from './SourceCover';
|
||||
import {tutorialPost} from '../content/tool-links';
|
||||
export function LayoutSelection(){
|
||||
const [active,setActive]=useState(0),[values,setValues]=useState<CoverValues[]>(selections.map(l=>({fields:Object.fromEntries(Object.entries(l.fields).filter(([,v])=>v!==undefined)) as Record<string,string>,color:'#83918a',accent:'#c3cec5'}))),[guides,setGuides]=useState(false),[status,setStatus]=useState('');
|
||||
const layout=selections[active],current=values[active];
|
||||
function update(patch:Partial<CoverValues>){setValues(old=>old.map((v,i)=>i===active?{...v,...patch}:v));}
|
||||
async function download(format:'svg'|'json'){
|
||||
try{setStatus('正在生成…');const r=await fetch('/studio/api/export/layout-selection?format='+format,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({layoutId:layout.id,...current})});if(!r.ok)throw Error();const url=URL.createObjectURL(await r.blob()),a=document.createElement('a');a.href=url;a.download=layout.id+'.'+format;a.click();setTimeout(()=>URL.revokeObjectURL(url),500);setStatus('已导出当前模板。')}catch{setStatus('导出失败,请重试。')}
|
||||
}
|
||||
return <main className="template-trial"><Head><title>三种排版 · Studio 工具</title></Head><header className="trial-nav"><a href={tutorialPost}>← waterfall-wiki 使用教程</a><span>STUDIO / LAYOUT SELECTION</span></header><section className="trial-intro"><div><small>REF-01 / REF-07 / REF-26</small><h1>三个构图,各有位置。</h1><p>切换真实原图对应的模板,分别试文字与配色。图位蒙版与装饰来自教程的分层资产;当前只放占位内容。</p></div></section><div className="trial-workspace"><aside className="trial-controls"><h2>选择与编辑</h2><label>排版<select value={active} onChange={e=>{setActive(Number(e.target.value));setStatus('')}}>{selections.map((l,i)=><option key={l.id} value={i}>{l.id} · {l.name}</option>)}</select></label>{layout.slots.filter(s=>s.kind==='text').map(s=><label key={s.id}>{s.id} · {s.role}<textarea rows={current.fields[s.id]?.includes('\n')?3:1} maxLength={120} value={current.fields[s.id]||''} onChange={e=>update({fields:{...current.fields,[s.id]:e.target.value}})}/></label>)}<div className="trial-colors"><label>主图色<input type="color" value={current.color} onChange={e=>update({color:e.target.value})}/></label><label>副图色<input type="color" value={current.accent} onChange={e=>update({accent:e.target.value})}/></label></div><label className="trial-switch"><input type="checkbox" checked={guides} onChange={e=>setGuides(e.target.checked)}/>显示构件边界</label><div className="trial-actions"><button className="trial-primary" onClick={()=>void download('svg')}>下载当前 SVG</button><button onClick={()=>void download('json')}>下载参数 JSON</button></div><p className="trial-help">每张模板单独保留本页修改,刷新恢复初始值。下载 SVG 为无边界线的正式图;原始装饰为低分辨率图层。</p><p role="status">{status}</p></aside><div><div className="selection-preview"><SourceCover layout={layout} values={current} guides={guides}/></div><div className="selection-thumbs">{selections.map((l,i)=><button key={l.id} aria-pressed={active===i} onClick={()=>setActive(i)}><SourceCover layout={l} values={values[i]}/><span>{l.id}</span></button>)}</div><details className="trial-reference"><summary>原图与构件坐标</summary><div className="trial-reference-body"><img src={layout.referenceUrl} alt={layout.name+'原图'}/><div><a href={layout.tutorialUrl}>阅读这张原图的教程 ↗</a><table><thead><tr><th>ID</th><th>构件</th><th>x,y,w,h</th></tr></thead><tbody>{layout.slots.map(s=><tr key={s.id}><td>{s.id}</td><td>{s.role}</td><td>{s.box.join(', ')}</td></tr>)}</tbody></table></div></div></details></div></div></main>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {useId} from 'react';
|
||||
import selections from '../content/templates/selection.json';
|
||||
export type SourceLayout=typeof selections[number];
|
||||
export type CoverValues={fields:Record<string,string>;color:string;accent:string};
|
||||
export {selections};
|
||||
export function SourceCover({layout,values,guides=false}:{layout:SourceLayout;values:CoverValues;guides?:boolean}){
|
||||
const uid=useId().replace(/:/g,''),W=layout.width*10,H=layout.height*10;
|
||||
return <svg xmlns="http://www.w3.org/2000/svg" viewBox={`0 0 ${W} ${H}`} width={W} height={H} role="img" aria-label={layout.id+' '+layout.name} data-layout-id={layout.id} style={{display:'block',width:'100%',height:'auto'}}>
|
||||
<title>{layout.name} · 封面试版</title><desc>文字为可编辑占位,图位使用原始形状蒙版,装饰沿用原图分层。来源 {layout.tutorialUrl}</desc><rect width={W} height={H} fill="#f4f2eb"/>
|
||||
{['image','decoration','text'].flatMap(kind=>layout.slots.filter(s=>s.kind===kind).map(s=>{
|
||||
const [x,y,w,h]=s.box.map(n=>n*10),raster='raster' in s?s.raster:undefined;
|
||||
if(kind==='image')return <g data-slot={s.id} key={s.id}><defs><mask id={uid+s.id} maskUnits="userSpaceOnUse" x={x} y={y} width={w} height={h} style={{maskType:'alpha'}}><image href={raster} x={x} y={y} width={w} height={h}/></mask></defs><rect x={x} y={y} width={w} height={h} fill={s.id==='I01'?values.color:values.accent} mask={`url(#${uid+s.id})`}/></g>;
|
||||
if(kind==='decoration')return <image data-slot={s.id} key={s.id} href={raster} x={x} y={y} width={w} height={h}/>;
|
||||
const text=values.fields[s.id]||'',lines=text.split('\n').slice(0,4),length=(str:string)=>Array.from(str).reduce((sum,c)=>sum+(/[\u3400-\u9fff]/.test(c)?1:.6),0),size=Math.min(h/(lines.length*1.25),w/Math.max(1,...lines.map(length))*.94);
|
||||
if(s.direction==='vertical'){const letters=Array.from(text.replace(/\n/g,'')),font=Math.min(w*.88,h/Math.max(1,letters.length));return <g data-slot={s.id} key={s.id} fill="#26362f" fontFamily="'PingFang SC',sans-serif">{letters.map((c,i)=><text key={i} x={x+w/2} y={y+font*(i+.85)} textAnchor="middle" fontSize={font}>{c}</text>)}</g>}
|
||||
if(s.direction==='curved')return <g data-slot={s.id} key={s.id}><defs><path id={uid+s.id} d={`M${x} ${y} Q${x+w/2} ${y+h*1.8} ${x+w} ${y}`}/></defs><text fill="#26362f" fontFamily="Arial,sans-serif" fontSize={Math.min(36,w/Math.max(1,text.length*.6))} letterSpacing="1"><textPath href={'#'+uid+s.id} startOffset="50%" textAnchor="middle">{text}</textPath></text></g>;
|
||||
return <g data-slot={s.id} key={s.id} fill="#26362f" fontFamily={h>100?"Georgia,'PingFang SC',serif":"Arial,'PingFang SC',sans-serif"}>{lines.map((line,i)=><text key={i} x={x} y={y+size*(.88+i*1.25)} fontSize={size}>{line}</text>)}</g>;
|
||||
}))}
|
||||
{guides&&<g aria-hidden="true" fontFamily="Arial,sans-serif">{layout.slots.map(s=>{const[x,y,w,h]=s.box.map(n=>n*10),color=({text:'#2c64d8',image:'#d36b30',decoration:'#08875d'} as Record<string,string>)[s.kind];return <g key={s.id}><rect x={x} y={y} width={w} height={h} stroke={color} strokeWidth="2" fill="none"/><rect x={x} y={y} width="66" height="29" fill={color}/><text x={x+4} y={y+22} fontSize="24" fill="white">{s.id}</text></g>})}</g>}
|
||||
</svg>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useState} from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import {tutorialPost} from '../content/tool-links';
|
||||
import {ideaMarkdown,type Idea,type Slide} from '../content/ideas';
|
||||
import {Ref17Cover} from './Ref17Cover';
|
||||
export function TemplateTrial({idea}:{idea:Idea}){
|
||||
@@ -12,7 +12,7 @@ export function TemplateTrial({idea}:{idea:Idea}){
|
||||
async function exportSvg(){await document.fonts.ready;const svg=document.querySelector(`[data-proof="${selected.key}"] svg`);if(!svg)return;download(new XMLSerializer().serializeToString(svg),'image/svg+xml',`ref-17-${selected.key}.svg`);setNotice('已下载当前封面 SVG,可继续编辑文字与图形。')}
|
||||
const reset=()=>{setSlides(idea.slides);setMasthead(template.masthead);setGuides(false);setNotice('已恢复初始试版。')};
|
||||
return <main className="template-trial"><Head><title>{idea.title} · Studio</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head>
|
||||
<header className="trial-nav"><Link href="/">← 我的创作</Link><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
|
||||
<header className="trial-nav"><a href={tutorialPost}>← waterfall-wiki 使用教程</a><span>STUDIO / TEMPLATE PROOF</span><a href={template.tutorialUrl} target="_blank" rel="noreferrer">排版教程 ↗</a></header>
|
||||
<section className="trial-intro"><div><small>REF-17 · 同一母版 / 四张试版</small><h1>先试封面,再填内容。</h1><p>英文刊头、圆形叠标、左下主图与右侧窄栏保持同一套坐标。三组合集先用主题占位,暂不放入具体 Skills。</p></div><div className="trial-source"><b>01 母版</b><span>02 笔记复习</span><span>03 教学资料</span><span>04 英语学习</span></div></section>
|
||||
<div className="trial-workspace"><aside className="trial-controls"><h2>试一试</h2><label>当前封面<select value={active} onChange={e=>{setActive(Number(e.target.value));setNotice('')}}>{slides.map((s,i)=><option value={i} key={s.key}>{s.label}</option>)}</select></label>
|
||||
<fieldset><legend>共享英文刊头</legend><label>第一行<input value={masthead[0]} maxLength={8} onChange={e=>setMasthead([e.target.value.toUpperCase().replace(/[^A-Z ]/g,''),masthead[1]])}/></label><label>第二行<input value={masthead[1]} maxLength={8} onChange={e=>setMasthead([masthead[0],e.target.value.replace(/[^A-Za-z ]/g,'')])}/></label></fieldset>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import ref17 from './templates/ref-17.json';
|
||||
export type Slide={heading:string;body:string;color:string;key?:string;label?:string;accent?:string;kicker?:string};
|
||||
export type Idea={slug:string;title:string;summary:string;slides:Slide[];template?:typeof ref17};
|
||||
export type Idea={slug:string;title:string;summary:string;slides:Slide[];tool?:string;template?:typeof ref17};
|
||||
// Studio owns creative pages. Wiki source tutorials are consumed as text/image data.
|
||||
export const ideas:Idea[]=[{
|
||||
export const ideas:Idea[]=[{slug:'layout-selection',tool:'layout-selection',title:'三种排版 · 模板选择工具',summary:'ref-01、ref-07、ref-26 三种不同构图的可编辑试版。',slides:[]},{
|
||||
slug:'learning-skills-template',title:'学习资料合集 · 封面模板试版',
|
||||
summary:'一张空白母版与三张主题占位封面,共用 ref-17 的同一套构件坐标;先试文案、色调和边界,暂不填具体 Skills。',template:ref17,
|
||||
slides:[
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
||||
// Stable Wiki post ID; the tutorial is published through /api/posts, never registered as a Wiki route.
|
||||
export const tutorialPost='/explore/9ebd8f78d54b4b9db741b4e8';
|
||||
@@ -1,6 +1,19 @@
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
const docs:Record<string,string>={
|
||||
'/studio/api/export/{slug}': '# 导出创作方案\n\n方法:GET\n路径参数:slug,创作方案的稳定标识。\n查询参数:format=markdown 返回 Markdown;format=svg&slide=0 返回模板封面 SVG(slide 为从 0 开始的整数,默认 0);其他值或省略时返回方案 JSON,带附件文件名。普通方案 version:1;模板方案 version:2,包含 template 来源链接、画板比例、原图九个 slots 及 slides 文字和颜色。模板几何共用,图片位为占位色块。\n示例:GET /studio/api/export/{slug}\n错误:400 SVG 模板或 slide 参数无效;404 方案不存在;405 方法不支持。',
|
||||
'/studio/api/export/{slug}': `# Studio 封面工具 API
|
||||
|
||||
方法:GET, POST
|
||||
路径参数:slug 为 learning-skills-template(同一母版四张试版)或 layout-selection(三种不同构图);first-idea 仅提供 GET 内容导出。
|
||||
GET 参数:省略 format 返回方案 JSON;format=markdown 返回说明;format=svg 返回初始封面。四张试版用 slide=0..3(默认 0),多模板工具用 layoutId=ref-01|ref-07|ref-26(默认 ref-01)。
|
||||
JSON:普通方案 version:1;四张试版 version:2;多模板 version:3,layouts 包含源教程、画板、构件坐标及分层蒙版。文字、图位、装饰一一对应,图片位为色块占位。
|
||||
POST:Content-Type: application/json。format=svg(默认)返回独立 SVG;format=json 返回渲染参数及模板信息。请求最大 32KB,无存储副作用。
|
||||
learning-skills-template 参数:slide(整数 0–3,默认 0)、heading(1–6 字)、body(最多 360 字、3 行)、color、accent(#RRGGBB)、masthead(2 项数组,每项 1–8 个英文字母/空格);省略参数沿用初始值。
|
||||
layout-selection 参数:layoutId(必填,ref-01/ref-07/ref-26)、fields(可选,以对应模板 T 构件 ID 为键;每项 1–120 字、最多 4 行)、color、accent(#RRGGBB)。省略文字沿用初始值;拒绝未知参数或构件 ID。
|
||||
示例:POST /studio/api/export/learning-skills-template?format=svg
|
||||
{"slide":1,"heading":"笔记复习","color":"#7a8b7e"}
|
||||
POST /studio/api/export/layout-selection?format=svg
|
||||
{"layoutId":"ref-01","fields":{"T01":"STUDY","T02":"笔记复习\\n主题说明占位"},"color":"#83918a"}
|
||||
错误:400 参数/格式错误;404 方案不存在;405 方法不支持;413 请求过大。SVG 不含编辑辅助边界,文字经过 XML 转义;装饰位分层图片是原始低分辨率图层。`,
|
||||
'/studio/api/health': '# 创作站健康检查\n\n方法:GET, HEAD\n无业务参数。GET 返回 200 JSON:{"status":"ok","service":"prompt-studio"};HEAD 无正文。\n示例:GET /studio/api/health\n错误:405 方法不支持。',
|
||||
};
|
||||
export function serveApiDocumentation(req:NextApiRequest,res:NextApiResponse,path:string){
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {createElement} from 'react';
|
||||
import {ideas} from '../content/ideas';
|
||||
import {Ref17Cover} from '../components/Ref17Cover';
|
||||
import {SourceCover,selections} from '../components/SourceCover';
|
||||
const record=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==='object'&&!Array.isArray(v);
|
||||
function color(value:unknown,fallback:string){if(value===undefined)return fallback;if(typeof value!=='string'||!/^#[0-9a-f]{6}$/i.test(value))throw Error('颜色必须为 #RRGGBB');return value;}
|
||||
function text(value:unknown,fallback:string,max:number,lines=1){if(value===undefined)return fallback;if(typeof value!=='string'||!value.trim()||Array.from(value).length>max||value.split('\n').length>lines)throw Error('文字长度或行数超限');return value;}
|
||||
function keys(input:Record<string,unknown>,allowed:string[]){if(Object.keys(input).some(k=>!allowed.includes(k)))throw Error('包含不支持的参数');}
|
||||
export function renderCover(slug:string,input:unknown){
|
||||
if(!record(input))throw Error('需要 JSON 对象');
|
||||
if(slug==='layout-selection'){
|
||||
keys(input,['layoutId','fields','color','accent']);const layout=selections.find(l=>l.id===input.layoutId);if(!layout)throw Error('layoutId 必须为 ref-01、ref-07 或 ref-26');
|
||||
if(input.fields!==undefined&&!record(input.fields))throw Error('fields 必须为对象');const supplied=(input.fields||{}) as Record<string,unknown>,defaults=Object.fromEntries(Object.entries(layout.fields).filter(([,v])=>v!==undefined)) as Record<string,string>;
|
||||
keys(supplied,Object.keys(defaults));const fields=Object.fromEntries(Object.entries(defaults).map(([key,value])=>[key,text(supplied[key],value,120,4)]));
|
||||
const values={fields,color:color(input.color,'#83918a'),accent:color(input.accent,'#c3cec5')};return {node:createElement(SourceCover,{layout,values}),data:{version:3,tool:slug,layoutId:layout.id,template:layout,...values}};
|
||||
}
|
||||
const idea=ideas.find(i=>i.slug===slug);if(!idea?.template)throw Error('这个方案不支持封面渲染');keys(input,['slide','heading','body','color','accent','masthead']);
|
||||
const index=input.slide??0;if(typeof index!=='number'||!Number.isInteger(index)||!idea.slides[index])throw Error('slide 必须是 0–3 的整数');const initial=idea.slides[index];
|
||||
const slide={...initial,heading:text(input.heading,initial.heading,6),body:text(input.body,initial.body,360,3),color:color(input.color,initial.color),accent:color(input.accent,initial.accent!)};
|
||||
const masthead=input.masthead??idea.template.masthead;if(!Array.isArray(masthead)||masthead.length!==2||!masthead.every(v=>typeof v==='string'&&/^[A-Za-z ]{1,8}$/.test(v)&&v.trim()))throw Error('masthead 为两行、每行 1–8 个英文字母或空格');
|
||||
return {node:createElement(Ref17Cover,{template:idea.template,slide,masthead}),data:{version:2,tool:slug,template:{...idea.template,masthead},slide}};
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
import {createElement} from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {serveApiDocumentation} from '../../../lib/api-documentation';
|
||||
import type {NextApiRequest,NextApiResponse} from 'next';
|
||||
import {ideas,ideaMarkdown} from '../../../content/ideas';
|
||||
import {Ref17Cover} from '../../../components/Ref17Cover';
|
||||
import {selections} from '../../../components/SourceCover';
|
||||
import {renderCover} from '../../../lib/render-cover';
|
||||
import {tutorialPost} from '../../../content/tool-links';
|
||||
export default function handler(req:NextApiRequest,res:NextApiResponse){
|
||||
if(serveApiDocumentation(req,res,'/studio/api/export/{slug}'))return;
|
||||
if(req.method!=='GET')return res.status(405).end();
|
||||
const idea=ideas.find(i=>i.slug===req.query.slug);if(!idea)return res.status(404).end();
|
||||
if(req.query.format==='markdown'){res.setHeader('Content-Type','text/markdown; charset=utf-8');return res.send(ideaMarkdown(idea));}
|
||||
if(req.query.format==='svg'){
|
||||
const raw=req.query.slide??'0',index=typeof raw==='string'&&/^\d+$/.test(raw)?Number(raw):-1;
|
||||
if(!idea.template||!idea.slides[index])return res.status(400).json({error:'SVG requires a template and a valid zero-based slide index.'});
|
||||
res.setHeader('Content-Type','image/svg+xml; charset=utf-8');res.setHeader('Content-Disposition',`inline; filename="${idea.slug}-${index}.svg"`);
|
||||
return res.send(renderToStaticMarkup(createElement(Ref17Cover,{template:idea.template,slide:idea.slides[index]})));
|
||||
if(!['GET','POST'].includes(req.method||'')){res.setHeader('Allow','GET, POST');return res.status(405).end();}
|
||||
const slug=String(req.query.slug||''),idea=ideas.find(i=>i.slug===slug);if(!idea)return res.status(404).end();
|
||||
if(req.method==='POST'||req.query.format==='svg'){
|
||||
if(req.query.format!==undefined&&!['svg','json'].includes(String(req.query.format)))return res.status(400).json({error:'渲染格式必须为 svg 或 json'});
|
||||
try{
|
||||
const raw=req.query.slide??'0';if(req.method==='GET'&&(typeof raw!=='string'||!/^\d+$/.test(raw)))throw Error('无效 slide');
|
||||
const input=req.method==='POST'?req.body:idea.tool?{layoutId:req.query.layoutId??selections[0].id}:{slide:Number(raw)};
|
||||
const result=renderCover(slug,input);res.setHeader('Cache-Control','no-store');
|
||||
res.setHeader('Content-Disposition',`attachment; filename="${slug}.${req.query.format==='json'?'json':'svg'}"`);
|
||||
if(req.query.format==='json')return res.json(result.data);
|
||||
res.setHeader('Content-Type','image/svg+xml; charset=utf-8');return res.send(renderToStaticMarkup(result.node));
|
||||
}catch(error){return res.status(400).json({error:error instanceof Error?error.message:'无效参数'});}
|
||||
}
|
||||
res.setHeader('Content-Disposition',`attachment; filename="${idea.slug}.json"`);return res.json({version:idea.template?2:1,...idea});
|
||||
if(req.query.format==='markdown'){res.setHeader('Content-Type','text/markdown; charset=utf-8');return res.send(ideaMarkdown(idea)+'\n\n使用教程:'+tutorialPost+(idea.tool?'\n\n```json\n'+JSON.stringify(selections,null,2)+'\n```':''));}
|
||||
res.setHeader('Content-Disposition',`attachment; filename="${idea.slug}.json"`);return res.json({version:idea.tool?3:idea.template?2:1,...idea,...(idea.tool?{layouts:selections}:{}),tutorialPost});
|
||||
}
|
||||
export const config={api:{bodyParser:{sizeLimit:'32kb'}}};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {GetStaticPaths,GetStaticProps} from 'next';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import {LayoutSelection} from '../../components/LayoutSelection';
|
||||
import {tutorialPost} from '../../content/tool-links';
|
||||
import {TemplateTrial} from '../../components/TemplateTrial';
|
||||
import {ideas,type Idea} from '../../content/ideas';
|
||||
export default function IdeaPage({idea}:{idea:Idea}){if(idea.template)return <TemplateTrial idea={idea}/>;return <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head><header><Link href="/">← 我的创作</Link><a href={'/studio/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/studio/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>PROMPT STUDIO / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
|
||||
export default function IdeaPage({idea}:{idea:Idea}){if(idea.tool==='layout-selection')return <LayoutSelection/>;if(idea.template)return <TemplateTrial idea={idea}/>;return <main className="editor"><Head><title>{idea.title} · 创作站</title><link rel="alternate" type="text/markdown" href={'/studio/api/export/'+idea.slug+'?format=markdown'}/></Head><header><a href={tutorialPost}>← waterfall-wiki 使用教程</a><a href={'/studio/api/export/'+idea.slug}>导出内容包 ↓</a><a href={'/studio/api/export/'+idea.slug+'?format=markdown'}>Markdown ↓</a><button onClick={()=>window.print()}>打印 / PDF</button></header><h1>{idea.title}</h1><p>{idea.summary}</p><div className="slides">{idea.slides.map((s,i)=><article className="slide" key={i} style={{background:s.color}}><small>PROMPT STUDIO / {String(i+1).padStart(2,'0')}</small><h2>{s.heading}</h2><p>{s.body}</p><footer>记录灵感 · 自由表达</footer></article>)}</div></main>}
|
||||
export const getStaticPaths:GetStaticPaths=async()=>({paths:ideas.map(i=>({params:{slug:i.slug}})),fallback:false});
|
||||
export const getStaticProps:GetStaticProps=async({params})=>{const idea=ideas.find(i=>i.slug===params?.slug);return idea?{props:{idea}}:{notFound:true}};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Link from 'next/link';
|
||||
import {Ref17Cover} from '../components/Ref17Cover';
|
||||
import {ideas} from '../content/ideas';
|
||||
export default function Home(){return <main className="workspace"><aside><b className="logo">PROMPT / 创作</b><p>想法 · 页面 · 作品</p><a href="/">我的创作</a><a href="/studio/api">AI 内容索引 ↗</a></aside><section><small>YOUR CREATIVE WORKSPACE</small><h1>今天,想表达什么?</h1><p>一个页面,装下一个想法。让内容慢慢长成作品。</p><div className="grid">{ideas.map(i=><Link className="card" href={'/ideas/'+i.slug} key={i.slug}><div className={i.template?"cover cover-template":"cover"}>{i.template?<Ref17Cover template={i.template} slide={i.slides[0]}/>:i.title}</div><h2>{i.title}</h2><p>{i.summary}</p><span>打开创作 →</span></Link>)}</div><p className="hint">新增作品:在 src/content/ideas.ts 添加内容,页面与导出会同步生成。</p></section></main>}
|
||||
import type {GetServerSideProps} from 'next';
|
||||
export default function StudioEntry(){return null;}
|
||||
// Studio tools are entered from their Wiki posts; there is no separate catalogue homepage.
|
||||
export const getServerSideProps:GetServerSideProps=async()=>({redirect:{destination:'/',permanent:false,basePath:false}});
|
||||
|
||||
@@ -25,3 +25,4 @@
|
||||
@media(max-width:440px){.trial-grid{grid-template-columns:1fr}.trial-nav{gap:10px}.template-trial{padding:18px}}
|
||||
@page ref17Proof{size:113mm 174mm;margin:0}
|
||||
@media print{.template-trial{padding:0;max-width:none}.trial-nav,.trial-intro,.trial-controls,.trial-reference,.proof-card figcaption{display:none}.trial-workspace,.trial-grid{display:block}.proof-card{page:ref17Proof;break-after:page;width:113mm;height:174mm}.proof-card:last-child{break-after:auto}.proof-select,.proof-active .proof-select{outline:none;box-shadow:none;width:113mm;height:174mm}.proof-select svg{width:113mm!important;height:174mm!important;print-color-adjust:exact}.proof-select svg g[aria-hidden=true]{display:none}}
|
||||
.selection-preview{max-width:540px;margin:auto;box-shadow:0 3px 15px #28362f14}.selection-thumbs{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:30px}.selection-thumbs button{padding:0;border-radius:0;border:1px solid #d8dcd5;background:#fff}.selection-thumbs button[aria-pressed=true]{outline:2px solid #617d65;outline-offset:4px}.selection-thumbs span{display:block;font-size:12px;padding:10px}
|
||||
|
||||
Reference in New Issue
Block a user