Flatten note tags and store processed media as ordinary resource links
wiki CI and deployment / verify-and-deploy (push) Successful in 13m27s

This commit is contained in:
2026-10-26 00:23:02 +08:00
parent 742b58013e
commit 9986846c36
31 changed files with 255 additions and 209 deletions
@@ -1,14 +0,0 @@
import {previewUrl} from '@/content/page-links';
import Link from "next/link";
import {useEffect,useState} from "react";
import {ChevronLeft,ChevronRight,RefreshCw,ExternalLink} from "lucide-react";
import {mediaUrl} from "@/lib/model";
type State={pages:{page_index:number;asset_id:string;rendered_at:string}[];job:{status:string;error:string|null}|null};
export function ArticleGallery({slug}:{slug:string}){
const [state,setState]=useState<State>({pages:[],job:null});const[index,setIndex]=useState(1);const[error,setError]=useState('');const[requesting,setRequesting]=useState(false);
const pending=state.job?.status==='pending'||state.job?.status==='processing';
useEffect(()=>{let live=true;const load=async()=>{try{const r=await fetch(`/api/posts/${slug}/snapshots`);if(!r.ok)throw Error();const s=await r.json();if(live){setState(s);setError('');}}catch{if(live)setError('截图状态读取失败,请重试。');}};void load();const timer=setInterval(()=>void load(),3000);return()=>{live=false;clearInterval(timer)};},[slug]);
async function refresh(all=false){setRequesting(true);setError('');try{const r=await fetch(`/api/posts/${slug}/snapshots`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(all?{}:{page:index})});if(!r.ok)throw Error();setState(s=>({...s,job:{status:'pending',error:null}}));}catch{setError('刷新没有完成,请重试。')}finally{setRequesting(false)}}
const selected=state.pages.find(p=>p.page_index===index);const url=`/p/${slug}/pages/${index}`;
return <div className="snapshot-reader"><header><div><b>这篇笔记 · 8 张图</b><p>{pending?'正在从最新网页重新截图…':selected?`截图更新于 ${new Date(selected.rendered_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}`:'等待生成截图'}</p></div><button className="secondary-button" disabled={pending||requesting} onClick={()=>void refresh()}><RefreshCw size={15}/>刷新这张截图</button><button className="text-link" disabled={pending||requesting} onClick={()=>void refresh(true)}>更新全部</button></header>{error&&<p role="alert">{error}</p>}{state.job?.status==='failed'&&<p role="alert">截图未完成,保留上次版本。可以重新刷新。</p>}<div className="snapshot-stage">{selected?<a href={url} target="_blank" rel="noreferrer"><img src={previewUrl(mediaUrl(selected.asset_id),1280)} alt={`笔记第 ${index} 张截图`} width={1080} height={1440}/></a>:<div className="snapshot-placeholder">这张图还没有截图。<Link href={url} target="_blank">先打开网页原稿 ↗</Link></div>}</div><nav><button aria-label="上一张" disabled={index===1} onClick={()=>setIndex(i=>i-1)}><ChevronLeft/></button><span>{index} / 8</span><button aria-label="下一张" disabled={index===8} onClick={()=>setIndex(i=>i+1)}><ChevronRight/></button><Link href={url} target="_blank"><ExternalLink size={15}/>打开这张图的网页</Link></nav><div className="snapshot-thumbs">{Array.from({length:8},(_,i)=>{const p=state.pages.find(p=>p.page_index===i+1);return <button aria-label={`查看第 ${i+1} 张截图`} aria-pressed={index===i+1} onClick={()=>setIndex(i+1)} key={i}>{p?<img src={previewUrl(mediaUrl(p.asset_id),240)} alt=""/>:<span>{i+1}</span>}</button>})}</div></div>;
}
+2 -2
View File
@@ -2,12 +2,12 @@ import Head from 'next/head';
import Link from 'next/link';
import {useEffect,useRef,useState} from 'react';
import {useRouter} from 'next/router';
import {X,ChevronLeft,ChevronRight,Link as LinkIcon,FileText} from 'lucide-react';
import {X,ChevronLeft,ChevronRight,Link as LinkIcon,FileText,ArrowUpRight} from 'lucide-react';
import type {ExploreEntry} from '@/lib/explore';
import {previewUrl,exploreUrl} from '@/content/page-links';
import {Shell} from './Shell';
import {ExploreFeed} from './ExploreFeed';
function TextBody({text}:{text:string}){return <>{text.split(/\n\n+/).map((part,i)=>{if(part.startsWith('# '))return null;if(part.startsWith('```'))return <pre key={i}>{part.replace(/^```[^\n]*\n?|```$/g,'')}</pre>;if(part.startsWith('## '))return <h3 key={i}>{part.slice(3)}</h3>;return <p key={i}>{part.split(/(\[[^\]]+\]\([^)]+\))/).map((piece,j)=>{const m=piece.match(/^\[([^\]]+)\]\(([^)]+)\)$/);return m&&/^(https?:\/\/|\/(?!\/))/.test(m[2])?<a key={j} href={m[2].replace(/\?agent_mode=True$/,'')}>{m[1]}</a>:piece})}</p>})}</>}
function TextBody({text}:{text:string}){return <>{text.split(/\n\n+/).map((part,i)=>{if(part.startsWith('# '))return null;if(part.startsWith('```'))return <pre key={i}>{part.replace(/^```[^\n]*\n?|```$/g,'')}</pre>;if(part.startsWith('## '))return <h3 key={i}>{part.slice(3)}</h3>;return <p key={i}>{part.split(/(\[[^\]]+\]\([^)]+\))/).map((piece,j)=>{const m=piece.match(/^\[([^\]]+)\]\(([^)]+)\)$/);return m&&/^(https?:\/\/|\/(?!\/))/.test(m[2])?<a className="note-inline-link" key={j} href={m[2].replace(/\?agent_mode=True$/,'')}><LinkIcon size={13} aria-hidden="true"/><span>{m[1]}</span>{/^https?:/.test(m[2])&&<ArrowUpRight size={12} aria-hidden="true"/>}</a>:piece})}</p>})}</>}
export function ExploreDetail({entry,background}:{entry:ExploreEntry;background:ExploreEntry[]}){const [index,setIndex]=useState(0);const [copied,setCopied]=useState(false);const router=useRouter();const closeRef=useRef<HTMLButtonElement>(null);const detailRef=useRef<HTMLDivElement>(null);const media=entry.media[index];const close=()=>void router.push('/');useEffect(()=>{setIndex(0);setCopied(false);const previous=document.activeElement as HTMLElement|null;closeRef.current?.focus();const old=document.body.style.overflow;document.body.style.overflow='hidden';function key(e:KeyboardEvent){if(e.key==='Escape')close();if(e.key==='ArrowLeft')setIndex(n=>Math.max(0,n-1));if(e.key==='ArrowRight')setIndex(n=>Math.max(0,Math.min(entry.media.length-1,n+1)));if(e.key==='Tab'){const items=detailRef.current?.querySelectorAll<HTMLElement>('a[href],button:not([disabled]),video,audio');if(!items?.length)return;const first=items[0],last=items[items.length-1];if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus()}else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus()}}}window.addEventListener('keydown',key);return()=>{document.body.style.overflow=old;window.removeEventListener('keydown',key);previous?.focus()};},[entry.id]);return <Shell><Head><title>{entry.title} · PROMPT</title><link rel="canonical" href={exploreUrl(entry.id)}/><link rel="alternate" type="text/plain" href={exploreUrl(entry.id)+'?agent_mode=True'}/></Head><div aria-hidden="true" inert><ExploreFeed initial={background}/></div><div className="note-overlay" onClick={close}><div className="note-dialog" ref={detailRef} role="dialog" aria-modal="true" aria-labelledby="note-title" onClick={e=>e.stopPropagation()}><button className="note-close" ref={closeRef} aria-label="关闭详情" onClick={close}><X/></button><section className={'note-stage '+(!media?'note-text-stage':'')}>
{media?.kind==='image'?<img key={media.url} src={previewUrl(media.url,1280)} alt={media.title} decoding="async"/>:media?.kind==='video'?<video controls playsInline preload="metadata" src={media.url} poster={media.poster?previewUrl(media.poster,960):undefined}/>:media?.kind==='audio'?<div className="note-audio"><span>♫</span><audio controls preload="metadata" src={media.url}/></div>:<div className="note-title-cover" style={{background:entry.color}}><small>{entry.namespace}</small><strong>{entry.title}</strong><span>灵感笔记</span></div>}
{entry.media.length>1&&<><button className="note-prev" aria-label="上一张" disabled={index===0} onClick={()=>setIndex(n=>n-1)}><ChevronLeft/></button><button className="note-next" aria-label="下一张" disabled={index===entry.media.length-1} onClick={()=>setIndex(n=>n+1)}><ChevronRight/></button><div className="note-dots">{entry.media.map((_,i)=><button key={i} className={i===index?'selected':''} aria-label={'查看第 '+(i+1)+' 张'} aria-pressed={i===index} onClick={()=>setIndex(i)}/>)}</div><span className="note-count">{index+1} / {entry.media.length}</span></>}
+1 -1
View File
@@ -29,7 +29,7 @@ function Masonry({posts,includeSkills=false,q=""}:{posts:Post[];includeSkills?:b
export function Feed({initial,section='',tagPath=''}:{initial:FeedData;section?:string;tagPath?:string}){
const router=useRouter();const [posts,setPosts]=useState(initial.posts);const [cursor,setCursor]=useState(initial.nextCursor);const [busy,setBusy]=useState(false);const [error,setError]=useState('');const generation=useRef(0);
useEffect(()=>{generation.current++;setPosts(initial.posts);setCursor(initial.nextCursor);setError('');setBusy(false);},[initial,router.query.q]);
const current=sections.find(s=>s.path===section);const target=initial.tags.find(t=>t.path===tagPath);const children=initial.tags.filter(t=>target?t.parent_id===target.id:!t.parent_id);
const current=sections.find(s=>s.path===section);const target=initial.tags.find(t=>t.path===tagPath);const children=initial.tags;
async function load(){const gen=generation.current;setBusy(true);setError('');try{const q=new URLSearchParams({section,path:tagPath,cursor:cursor||'',q:String(router.query.q||'')});const res=await fetch('/api/feed?'+q);const d=await res.json();if(!res.ok)throw new Error(d.error);if(gen===generation.current){setPosts(old=>[...old,...d.posts.filter((p:Post)=>!old.some(a=>a.id===p.id))]);setCursor(d.nextCursor);}}catch{if(gen===generation.current)setError('加载没有完成,点击重试。');}finally{if(gen===generation.current)setBusy(false);}}
return <Shell><Head><title>{target?.name||current?.name||'灵感首页'} · PROMPT</title><meta name="description" content="自己的图文、视频与声音创作空间"/><link rel="alternate" type="text/markdown" href="/index.md" title="Agent 文档入口"/></Head>
{current&&<header className="page-header feed-heading"><div><h1>{target?.name||current.name}</h1><p>{current.hint}</p></div></header>}
+13
View File
@@ -0,0 +1,13 @@
import {useState} from 'react';
import type {Tag} from '@/lib/model';
export function TagInput({initial,selected,onChange,disabled=false}:{initial:Tag[];selected:string[];onChange:(ids:string[])=>void;disabled?:boolean}){
const [all,setAll]=useState(initial);const [input,setInput]=useState('');const [busy,setBusy]=useState(false);const [error,setError]=useState('');
const name=input.replace(/^#+/,'').trim();
const suggestions=all.filter(t=>!selected.includes(t.id)&&(!name||t.name.toLocaleLowerCase().includes(name.toLocaleLowerCase()))).slice(0,12);
async function add(){if(!name||busy||disabled||selected.length>=50)return;setBusy(true);setError('');try{const res=await fetch('/api/tags',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})});const tag:Tag&{error?:string}=await res.json();if(!res.ok)throw Error(tag.error||'标签创建失败');setAll(a=>a.some(t=>t.id===tag.id)?a:[...a,tag]);if(!selected.includes(tag.id))onChange([...selected,tag.id]);setInput('');}catch(e){setError(e instanceof Error?e.message:'标签创建失败');}finally{setBusy(false);}}
return <div className="form-field"><label htmlFor="tag-input">标签 <small>输入 #标签,按回车添加</small></label>
<div className="tag-input-selected">{all.filter(t=>selected.includes(t.id)).map(t=><button key={t.id} type="button" disabled={disabled||busy} aria-label={'移除标签 '+t.name} onClick={()=>onChange(selected.filter(id=>id!==t.id))}>#{t.name}<span aria-hidden="true">×</span></button>)}</div>
<div className="tag-input-row"><input id="tag-input" value={input} maxLength={61} disabled={disabled||busy} placeholder="#添加标签" onChange={e=>setInput(e.target.value)} onKeyDown={e=>{if(e.key==='Enter'&&!e.nativeEvent.isComposing){e.preventDefault();void add()}}}/><button type="button" className="secondary-button" disabled={!name||busy||disabled||selected.length>=50} onClick={()=>void add()}>{busy?'添加中…':'添加'}</button></div>
<div className="tag-input-suggestions">{suggestions.map(t=><button key={t.id} type="button" disabled={disabled||busy||selected.length>=50} onClick={()=>{onChange([...selected,t.id]);setInput('')}}>#{t.name}</button>)}</div>
{error&&<p role="alert" className="error-message">{error}</p>}</div>;
}
-9
View File
@@ -1,9 +0,0 @@
import type { ComponentType } from "react";
import { LearningSeries } from "./learning-skills/Series";
// Register code-authored pages here, then reference their key in the posts API.
// No page or sample post is inserted into the user's library automatically.
export const works: Record<string, { kind: "article" | "motion"; Component: ComponentType }> = {
"learning-skills-notes": {kind:"article", Component:()=> <LearningSeries chapterKey="learning-skills-notes"/>},
"learning-skills-teaching": {kind:"article", Component:()=> <LearningSeries chapterKey="learning-skills-teaching"/>},
"learning-skills-english": {kind:"article", Component:()=> <LearningSeries chapterKey="learning-skills-english"/>},
};
-1
View File
@@ -9,7 +9,6 @@ export function apiError(res: NextApiResponse,error: unknown) {
const message=error instanceof Error?error.message:"";
const clientMessages:Record<string,string>={
'Character PNG must have transparency':'请上传带真实透明背景的 PNG,透明区域 alpha 为 0,人物内部 alpha 至少为 250',
'Register the coded page before creating its post':'请先在代码中注册对应的作品页面',
'Referenced asset does not exist':'引用的素材不存在',
'A media post needs matching content':'素材包需要包含对应类型的素材',
'Cover must reference an image':'封面需要引用一张图片',
+10
View File
@@ -0,0 +1,10 @@
import type {PoolClient} from 'pg';
// Serialize appending links so a worker and a concurrent editor cannot reuse a position.
export async function linkDerivedAssets(client:PoolClient,postId:string){
await client.query('SELECT id FROM posts WHERE id=$1 FOR UPDATE',[postId]);
await client.query(`INSERT INTO post_assets(post_id,asset_id,position,role)
SELECT $1,a.id,COALESCE((SELECT max(position)+1 FROM post_assets WHERE post_id=$1),0)
+row_number() OVER(ORDER BY a.id)::integer-1,a.derivation
FROM assets a WHERE a.source_asset_id IN (SELECT asset_id FROM post_assets WHERE post_id=$1 AND role='content')
AND NOT EXISTS(SELECT 1 FROM post_assets pa WHERE pa.post_id=$1 AND pa.asset_id=a.id)`,[postId]);
}
+3 -6
View File
@@ -4,8 +4,6 @@ import {wikiPages,wikiMarkdown} from '@/content/wiki';
import {skillCatalog} from '@/content/skills/catalog';
import {pageId,exploreUrl,guidanceUrl} from '@/content/page-links';
import {getPostById,feed} from './library';
import {snapshotState} from './snapshots';
import {chapters} from '@/content/learning-skills/data';
export type ExploreMedia={kind:'image'|'video'|'audio';url:string;title:string;poster?:string};
export type ExploreEntry={id:string;title:string;description:string;imageCount:number;namespace:string;categories:string[];markdown:string;media:ExploreMedia[];color:string;postSlug?:string};
export const staticEntries:ExploreEntry[]=[...guidanceDocuments.map(doc=>{const card=skillCatalog.find(c=>doc.path===c.id);return {id:pageId('guidance:'+doc.path),title:doc.title,description:noteDescriptions[doc.path]||doc.blocks.find(b=>b.type==='paragraph')?.text||doc.title,imageCount:new Set([...(card?[card.image]:[]),...doc.resources.filter(r=>r.kind==='image').map(r=>r.href)]).size,namespace:'Main',categories:[doc.collection],markdown:guidanceMarkdown(doc.path)!,media:Array.from(new Map([...(card?[{kind:'image' as const,url:card.image,title:card.alt}]:[]),...doc.resources.filter(r=>r.kind==='image').map(r=>({kind:'image' as const,url:r.href,title:r.title}))].map(m=>[m.url,m])).values()),color:'#eee5dc'};}),...wikiPages.map(page=>({id:pageId('wiki:'+page.namespace+':'+page.slug),title:page.title,description:page.summary,imageCount:0,namespace:page.namespace,categories:page.categories,markdown:wikiMarkdown(page),media:[],color:page.color}))];
@@ -15,10 +13,9 @@ export async function getEntry(id:string):Promise<ExploreEntry|null>{
const page=staticEntries.find(e=>e.id===id);if(page)return {...page,markdown:canonicalMarkdown(page.markdown)};
if(!/^[0-9a-f-]{36}$/.test(id))return null;
const post=await getPostById(id);if(!post)return null;
let media:ExploreMedia[]=post.assets.filter(a=>a.kind!=='text').map(a=>({kind:a.kind as ExploreMedia['kind'],url:'/api/media/'+a.id,title:a.filename||post.title,...(a.kind==='video'&&a.variants.includes('poster')?{poster:'/api/media/'+a.id+'?variant=poster'}:{})}));
let body=post.assets.filter(a=>a.kind==='text').map(a=>a.body||'').join('\n\n');
if(post.page_key){const chapter=chapters.find(c=>c.key===post.page_key);if(chapter)body+='\n\n'+chapter.subtitle+'\n\n'+chapter.skills.map(skill=>'## '+skill.title+'\n\n'+skill.note+'\n\n输入:'+skill.input+'\n输出:'+skill.output+'\n\n'+skill.prompt+'\n\n[来源]('+skill.source+')').join('\n\n');const state=await snapshotState(post.slug);if(state?.pages.length)media=state.pages.map(p=>({kind:'image' as const,url:'/api/media/'+p.asset_id,title:'图文第 '+p.page_index+' 页'}));}
const media:ExploreMedia[]=post.assets.filter(a=>a.kind!=='text'&&a.role==='content').map(a=>({kind:a.kind as ExploreMedia['kind'],url:'/api/media/'+a.id,title:a.filename||post.title,...(a.kind==='video'&&a.variants.includes('poster')?{poster:'/api/media/'+post.assets.find(p=>p.source_asset_id===a.id&&p.derivation==='poster')?.id}:{})}));
const body=post.assets.filter(a=>a.kind==='text').map(a=>a.body||'').join('\n\n')+'\n\n'+post.assets.filter(a=>a.derivation).map(a=>'['+({poster:'视频封面',audio_track:'独立音轨',silent_video:'静音视频'}[a.derivation||'']||a.derivation)+'](/api/media/'+a.id+')').join(' · ');
return {id:post.id,title:post.title,description:post.description||body.slice(0,180)||post.title,imageCount:media.filter(m=>m.kind==='image').length,namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:post.tags.map(t=>t.name),markdown:'# '+post.title+'\n\n'+body,media,color:'#eee5dc',postSlug:post.slug};
}
export async function exploreFeed(q='',category='',cursor?:string){const data=await feed({q,cursor});return {entries:[...(cursor?[]:staticEntries.filter(e=>(!q||(e.title+e.description).includes(q)))),...data.posts.map(post=>{const cover=post.cover_asset_id||post.assets.find(a=>a.kind==='image')?.id;const video=post.assets.find(a=>a.kind==='video');return {id:post.id,title:post.title,description:post.description||post.assets.find(a=>a.kind==='text')?.body?.slice(0,180)||post.title,imageCount:post.snapshot_count||post.assets.filter(a=>a.kind==='image').length,namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:post.tags.map(t=>t.name),markdown:'',media:cover?[{kind:'image' as const,url:'/api/media/'+cover,title:post.title}]:video?.variants.includes('poster')?[{kind:'image' as const,url:'/api/media/'+video.id+'?variant=poster',title:post.title}]:[],color:'#eee5dc'};})].filter(e=>!category||e.categories.includes(category)||e.namespace===category),nextCursor:data.nextCursor};}
export async function exploreFeed(q='',category='',cursor?:string){const data=await feed({q,cursor});return {entries:[...(cursor?[]:staticEntries.filter(e=>(!q||(e.title+e.description).includes(q)))),...data.posts.map(post=>{const cover=post.cover_asset_id||post.assets.find(a=>a.kind==='image'&&a.role==='content')?.id;const video=post.assets.find(a=>a.kind==='video');return {id:post.id,title:post.title,description:post.description||post.assets.find(a=>a.kind==='text')?.body?.slice(0,180)||post.title,imageCount:post.assets.filter(a=>a.kind==='image'&&a.role==='content').length,namespace:['image','video','audio'].includes(post.kind)?'File':'Main',categories:post.tags.map(t=>t.name),markdown:'',media:cover?[{kind:'image' as const,url:'/api/media/'+cover,title:post.title}]:video?.variants.includes('poster')?[{kind:'image' as const,url:'/api/media/'+post.assets.find(a=>a.source_asset_id===video.id&&a.derivation==='poster')?.id,title:post.title}]:[],color:'#eee5dc'};})].filter(e=>!category||e.categories.includes(category)||e.namespace===category),nextCursor:data.nextCursor};}
export function entryText(entry:ExploreEntry){return `${entry.markdown}\n\nDescription: ${entry.description}\nNamespace: ${entry.namespace}\nCategories: ${entry.categories.join(', ')}\n\n`+entry.media.map(m=>`- ${m.kind}: [${m.title}](${m.url})`).join('\n')+'\n\n[网页]('+exploreUrl(entry.id)+')\n';}
+6 -8
View File
@@ -1,10 +1,9 @@
import { db } from "./db";
import { decodeCursor, encodeCursor, type FeedData, type Post, type PostKind, type Tag } from "./model";
import { works } from "../content/registry";
const tagTree = `WITH RECURSIVE tree AS (SELECT *,slug::text AS path FROM tags WHERE parent_id IS NULL UNION ALL SELECT t.*,tree.path||'/'||t.slug FROM tags t JOIN tree ON t.parent_id=tree.id) SELECT * FROM tree`;
export async function tags(): Promise<Tag[]> { return (await db().query(`${tagTree} ORDER BY path`)).rows; }
const expand = `SELECT p.*,(SELECT count(*)::integer FROM page_snapshots ps WHERE ps.post_id=p.id) AS snapshot_count,
COALESCE((SELECT jsonb_agg(ae ORDER BY ae.position) FROM (SELECT a.*,pa.position,pa.role,COALESCE((SELECT jsonb_agg(v.role) FROM asset_variants v WHERE v.asset_id=a.id),'[]') AS variants FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id) ae),'[]') AS assets,
export async function tags(): Promise<Tag[]> { return (await db().query('SELECT *,slug AS path FROM tags ORDER BY name,id')).rows; }
const expand = `SELECT p.*,
COALESCE((SELECT jsonb_agg(ae ORDER BY ae.position) FROM (SELECT a.*,pa.position,pa.role,COALESCE((SELECT jsonb_agg(v.derivation) FROM assets v WHERE v.source_asset_id=a.id),'[]') AS variants FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id) ae),'[]') AS assets,
COALESCE((SELECT jsonb_agg(t) FROM tags t JOIN post_tags pt ON pt.tag_id=t.id WHERE pt.post_id=p.id),'[]') AS tags FROM posts p`;
function serialize(posts: Post[], allTags: Tag[]): Post[] {
return posts.map(p => ({...p, created_at: new Date(p.created_at).toISOString(), updated_at:new Date(p.updated_at).toISOString(), tags:p.tags.map(t=>allTags.find(a=>a.id===t.id)!).filter(Boolean)}));
@@ -21,10 +20,9 @@ export async function feed(options: {kind?: PostKind; path?: string; cursor?: st
const allTags=await tags(); const target=options.path ? allTags.find(t=>t.path===options.path) : undefined;
if(options.path&&!target) throw new Error("Tag not found");
const cursor=decodeCursor(options.cursor); const values: unknown[]=[];
const where=[`((p.kind IN ('article','motion') AND p.page_key=ANY($1::text[])) OR EXISTS(SELECT 1 FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id AND pa.role='content' AND a.kind=p.kind AND a.storage_key IS NOT NULL AND a.byte_size>0))`];
values.push(Object.keys(works));
const where=[`EXISTS(SELECT 1 FROM post_assets pa JOIN assets a ON a.id=pa.asset_id WHERE pa.post_id=p.id AND pa.role IN ('content','caption') AND ((a.storage_key IS NOT NULL AND a.byte_size>0) OR (a.kind='text' AND length(a.body)>0)))`];
if(options.kind){values.push(options.kind);where.push(`p.kind=$${values.length}`);}
if(target){values.push(allTags.filter(t=>t.path===target.path||t.path.startsWith(target.path+"/")).map(t=>t.id));where.push(`EXISTS(SELECT 1 FROM post_tags WHERE post_id=p.id AND tag_id=ANY($${values.length}::uuid[]))`);}
if(target){values.push([target.id]);where.push(`EXISTS(SELECT 1 FROM post_tags WHERE post_id=p.id AND tag_id=ANY($${values.length}::uuid[]))`);}
if(options.q){values.push(`%${options.q.replace(/[\\%_]/g,"\\$&")}%`);where.push(`p.title ILIKE $${values.length}`);}
if(cursor){values.push(...cursor);where.push(`(p.created_at,p.id)<($${values.length-1}::timestamptz,$${values.length}::uuid)`);}
const rows=(await db().query(`${expand} WHERE ${where.join(' AND ')} ORDER BY p.created_at DESC,p.id DESC LIMIT 25`,values)).rows as Post[];
+3 -3
View File
@@ -6,9 +6,9 @@ export const sections = [
{ path: "motion", kind: "motion", name: "动态作品", hint: "让网页本身成为时间里的画面" },
] as const;
export type PostKind = typeof sections[number]["kind"];
export type Asset = { id: string; kind: "image" | "video" | "audio" | "text"; filename: string | null; body: string | null; width: number | null; height: number | null; duration: number | null; has_audio: boolean | null; processing_status: string; variants: string[] };
export type Tag = { id: string; parent_id: string | null; name: string; slug: string; path: string };
export type Post = { description: string; snapshot_count: number; id: string; slug: string; kind: PostKind; title: string; created_at: string; updated_at: string; page_key: string | null; cover_asset_id: string | null; assets: Asset[]; tags: Tag[] };
export type Asset = { id: string; kind: "image" | "video" | "audio" | "text"; filename: string | null; body: string | null; width: number | null; height: number | null; duration: number | null; has_audio: boolean | null; processing_status: string; variants: string[]; role: string; source_asset_id: string | null; derivation: string | null };
export type Tag = { id: string; name: string; slug: string; path: string };
export type Post = { description: string; id: string; slug: string; kind: PostKind; title: string; created_at: string; updated_at: string; cover_asset_id: string | null; assets: Asset[]; tags: Tag[] };
export type FeedData = { posts: Post[]; tags: Tag[]; contributions: { day: string; total: number }[]; nextCursor: string | null; unavailable?: boolean };
export function validSection(path: string) { return sections.find(s => s.path === path); }
export const mediaUrl = (id: string, variant?: string) => `/api/media/${id}${variant ? `?variant=${variant}` : ""}`;
+14 -10
View File
@@ -1,5 +1,7 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import {guidanceDocuments} from "../content/skills/guidance";
import {wikiPages} from "../content/wiki";
import { chapters } from "../content/learning-skills/data";
import { transaction } from "./db";
import { putFile } from "./storage";
@@ -22,23 +24,25 @@ export async function publishLearningSeries() {
await client.query(`INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height)
VALUES($1,'image','学习资料自己做-系列共用封面.png',$2,'image/png',$3,$4,1080,1440)
ON CONFLICT(id) DO UPDATE SET storage_key=excluded.storage_key,byte_size=excluded.byte_size,sha256=excluded.sha256`,[coverId,storageKey,cover.length,digest]);
const root=(await client.query(`INSERT INTO tags(id,parent_id,slug,name) VALUES($1,NULL,'learning-skills','学习工具')
ON CONFLICT(parent_id,slug) DO UPDATE SET name=excluded.name RETURNING id`,[stableId("tag-root")])).rows[0].id;
const root=(await client.query(`INSERT INTO tags(id,slug,name) VALUES($1,'learning-skills','学习工具')
ON CONFLICT(lower(btrim(name))) DO UPDATE SET name=tags.name RETURNING id`,[stableId("tag-root")])).rows[0].id;
for(const name of new Set([...guidanceDocuments.map(d=>d.collection),...wikiPages.flatMap(p=>p.categories)])){
await client.query(`INSERT INTO tags(id,slug,name) VALUES($1,$2,$3) ON CONFLICT(lower(btrim(name))) DO NOTHING`,[stableId('tag:'+name),'tag-'+stableId('tag:'+name),name]);
}
const published:string[]=[];
for(const chapter of chapters){
const child=(await client.query(`INSERT INTO tags(id,parent_id,slug,name) VALUES($1,$2,$3,$4)
ON CONFLICT(parent_id,slug) DO UPDATE SET name=excluded.name RETURNING id`,[stableId(`tag-${chapter.key}`),root,chapter.number==="01"?"notes":chapter.number==="02"?"teaching":"english",chapter.number==="01"?"笔记复习":chapter.number==="02"?"教学资料":"英语学习"])).rows[0].id;
const result=await client.query(`INSERT INTO posts(id,slug,kind,title,page_key,cover_asset_id,description) VALUES($1,$2,'article',$3,$4,$5,$6)
ON CONFLICT(slug) DO UPDATE SET title=excluded.title,description=excluded.description,cover_asset_id=COALESCE((SELECT asset_id FROM page_snapshots WHERE post_id=posts.id AND page_index=1),excluded.cover_asset_id),updated_at=now()
WHERE posts.page_key=excluded.page_key AND posts.kind='article' RETURNING id,cover_asset_id`,[stableId(chapter.key),chapter.slug,chapter.title,chapter.key,coverId,chapter.subtitle]);
const child=(await client.query(`INSERT INTO tags(id,slug,name) VALUES($1,$2,$3)
ON CONFLICT(lower(btrim(name))) DO UPDATE SET name=tags.name RETURNING id`,[stableId(`tag-${chapter.key}`),chapter.number==="01"?"notes":chapter.number==="02"?"teaching":"english",chapter.number==="01"?"笔记复习":chapter.number==="02"?"教学资料":"英语学习"])).rows[0].id;
const result=await client.query(`INSERT INTO posts(id,slug,kind,title,cover_asset_id,description) VALUES($1,$2,'article',$3,$4,$5)
ON CONFLICT(slug) DO UPDATE SET title=excluded.title,description=excluded.description,cover_asset_id=COALESCE(posts.cover_asset_id,excluded.cover_asset_id),updated_at=now()
WHERE posts.id=excluded.id AND posts.kind='article' RETURNING id,cover_asset_id`,[stableId(chapter.key),chapter.slug,chapter.title,coverId,chapter.subtitle]);
if(!result.rows[0])throw new Error(`Publication slug is already used by another post: ${chapter.slug}`);
const postId=result.rows[0].id;const captionId=stableId(`caption-${chapter.key}`);
const caption=`学习资料自己做 · 第 ${chapter.number} 辑\n${chapter.subtitle}。三篇共用系列封面,每篇 8 页,介绍 10 项工具。\n\n${chapter.skills.map((skill,i)=>`${String(i+1).padStart(2,"0")} ${skill.name} — ${skill.title}`).join("\n")}\n\n根据 GitHub 项目说明整理,尚未逐项安装实测。内页小样为排版示意;可在末页访问全部来源。`;
const caption=`学习资料自己做 · 第 ${chapter.number} 辑\n\n${chapter.subtitle}\n\n${chapter.skills.map(skill=>`## ${skill.title}\n\n${skill.note}\n\n输入:${skill.input}\n输出:${skill.output}\n\n${skill.prompt}\n\n[来源](${skill.source})`).join("\n\n")}\n\n根据 GitHub 项目说明整理,尚未逐项安装实测。`;
await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2) ON CONFLICT(id) DO UPDATE SET body=excluded.body",[captionId,caption]);
await client.query(`INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,0,'content'),($1,$3,1,'caption')
ON CONFLICT(post_id,position) DO UPDATE SET asset_id=excluded.asset_id,role=excluded.role`,[postId,result.rows[0].cover_asset_id,captionId]);
await client.query("INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2) ON CONFLICT DO NOTHING",[postId,child]);
await client.query(`INSERT INTO render_jobs(post_id,status,revision) VALUES($1,'pending',$2) ON CONFLICT(post_id) DO UPDATE SET status='pending',revision=excluded.revision,attempts=0,target_page=NULL,error=NULL WHERE render_jobs.revision IS DISTINCT FROM excluded.revision`,[postId,process.env.RELEASE_SHA||'development']);
await client.query("INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2),($1,$3) ON CONFLICT DO NOTHING",[postId,child,root]);
published.push(chapter.slug);
}
return {published,coverId};
-57
View File
@@ -1,57 +0,0 @@
import {chromium} from "playwright-core";
import {createHash,randomUUID} from "node:crypto";
import {mkdtemp,rm} from "node:fs/promises";
import {tmpdir} from "node:os";
import {join} from "node:path";
import {db,transaction} from "./db";
import {putFile} from "./storage";
import {chapters} from "../content/learning-skills/data";
export async function snapshotState(slug:string){
const post=(await db().query('SELECT id,page_key FROM posts WHERE slug=$1',[slug])).rows[0];
if(!post||!chapters.some(c=>c.key===post.page_key))return null;
const pages=(await db().query('SELECT page_index,asset_id,rendered_at,revision FROM page_snapshots WHERE post_id=$1 ORDER BY page_index',[post.id])).rows;
const job=(await db().query('SELECT status,error,revision,attempts,target_page FROM render_jobs WHERE post_id=$1',[post.id])).rows[0]||null;
return {postId:post.id,pages,job};
}
export async function queueSnapshots(postId:string,pageIndex?:number){
await db().query(`INSERT INTO render_jobs(post_id,status,revision,target_page) VALUES($1,'pending',$2,$3) ON CONFLICT(post_id) DO UPDATE SET status='pending',revision=excluded.revision,target_page=excluded.target_page,attempts=0,error=NULL WHERE render_jobs.status NOT IN ('pending','processing')`,[postId,process.env.RELEASE_SHA||'development',pageIndex??null]);
}
export async function renderOne(){
const job=await transaction(async c=>(await c.query(`UPDATE render_jobs SET status='processing',attempts=attempts+1,locked_at=now() WHERE post_id=(SELECT post_id FROM render_jobs WHERE status='pending' OR (status='processing' AND locked_at<now()-interval '10 minutes') ORDER BY post_id FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`)).rows[0]);
if(!job)return false;
const dir=await mkdtemp(join(tmpdir(),'prompt-snapshots-'));let browser;
try{
const post=(await db().query('SELECT slug,page_key FROM posts WHERE id=$1',[job.post_id])).rows[0];
if(!chapters.some(c=>c.key===post.page_key&&c.slug===post.slug))throw Error('Unknown coded page');
browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox','--disable-dev-shm-usage']});
const context=await browser.newContext({viewport:{width:1080,height:1440},deviceScaleFactor:1,locale:'zh-CN',timezoneId:'Asia/Shanghai',reducedMotion:'reduce'});
const origin=new URL(process.env.RENDER_ORIGIN||'http://web:3000').origin;
await context.route('**/*',route=>new URL(route.request().url()).origin===origin?route.continue():route.abort());
const page=await context.newPage();const output:{index:number;id:string}[]=[];
for(let index=job.target_page||1;index<=(job.target_page||8);index++){
console.log('Rendering canvas',post.slug,index);
const response=await page.goto(`${origin}/p/${post.slug}/pages/${index}`,{waitUntil:'networkidle',timeout:45000});
if(!response?.ok())throw Error('Canvas page unavailable');
await page.locator('[data-render-page]').waitFor();await page.evaluate(async()=>{await document.fonts.ready;});
console.log('Canvas loaded',post.slug,index);
const overflow=await page.evaluate(()=>{const sheet=document.querySelector('.learning-sheet');if(!sheet)return false;const footer=sheet.querySelector('footer')!;return Array.from(sheet.children).filter(c=>c!==footer).some(c=>c.getBoundingClientRect().bottom>footer.getBoundingClientRect().top-2)});
if(overflow)throw Error(`Canvas ${index} overlaps its footer`);
const path=join(dir,`${index}.png`);const bytes=await page.locator('[data-render-page]').screenshot({path,animations:'disabled'});
const hash=createHash('sha256').update(bytes).digest('hex');const key=`snapshots/${hash}.png`;const size=await putFile(key,path,'image/png');
const asset=(await db().query(`INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height) VALUES($1,'image',$2,$3,'image/png',$4,$5,1080,1440) ON CONFLICT(storage_key) DO UPDATE SET storage_key=excluded.storage_key RETURNING id`,[randomUUID(),`图文-${index}.png`,key,size,hash])).rows[0];output.push({index,id:asset.id});console.log('Canvas stored',post.slug,index);
}
await transaction(async c=>{
const current=(await c.query('SELECT revision FROM render_jobs WHERE post_id=$1 FOR UPDATE',[job.post_id])).rows[0];
if(current.revision!==job.revision)return;
for(const result of output){
await c.query(`INSERT INTO page_snapshots(post_id,page_index,asset_id,revision) VALUES($1,$2,$3,$4) ON CONFLICT(post_id,page_index) DO UPDATE SET asset_id=excluded.asset_id,revision=excluded.revision,rendered_at=now()`,[job.post_id,result.index,result.id,job.revision]);
await c.query(`INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,'content') ON CONFLICT(post_id,position) DO UPDATE SET asset_id=excluded.asset_id,role='content'`,[job.post_id,result.id,result.index===1?0:result.index+1]);
}
// Position zero remains the shared series cover reference; captions remain at one.
if(output[0].index===1)await c.query('UPDATE posts SET cover_asset_id=$2,updated_at=now() WHERE id=$1',[job.post_id,output[0].id]);
await c.query("UPDATE render_jobs SET status='ready',error=NULL WHERE post_id=$1",[job.post_id]);
});
}catch(e){await db().query("UPDATE render_jobs SET status=$2,error=$3 WHERE post_id=$1 AND revision=$4",[job.post_id,job.attempts<3?'pending':'failed',e instanceof Error?e.message:'Screenshot failed',job.revision]);console.error('Page screenshot job failed',job.post_id,e instanceof Error?e.message:'');await new Promise(r=>setTimeout(r,3000));}
finally{await browser?.close();await rm(dir,{recursive:true,force:true});}
return true;
}
+4 -4
View File
@@ -1,28 +1,28 @@
import { randomUUID } from "node:crypto";
import { z } from "zod";
import { transaction } from "./db";
import { works } from "../content/registry";
import {linkDerivedAssets} from "./asset-links";
export const postInput=z.object({
slug:z.string().regex(/^[a-z0-9][a-z0-9-]{0,119}$/).optional(),
kind:z.enum(["image","video","audio","article","motion"]),title:z.string().trim().min(1).max(200),
assets:z.array(z.object({id:z.uuid(),role:z.enum(["content","caption"]).default("content")})).max(100).default([]),
description:z.string().trim().max(1000).default(""),
text:z.string().trim().max(100000).optional(),tags:z.array(z.uuid()).max(50).default([]),
coverAssetId:z.uuid().optional(),pageKey:z.string().max(120).optional(),
coverAssetId:z.uuid().optional(),
});
export async function createPost(input:z.infer<typeof postInput>) {
const id=randomUUID(); const slug=input.slug||id;
if(["article","motion"].includes(input.kind)&&(!input.pageKey||works[input.pageKey]?.kind!==input.kind)) throw new Error("Register the coded page before creating its post");
return transaction(async client=>{
const ids=[...new Set(input.assets.map(a=>a.id))];
const assets=ids.length?(await client.query('SELECT id,kind FROM assets WHERE id=ANY($1::uuid[])',[ids])).rows:[];
if(assets.length!==ids.length) throw new Error("Referenced asset does not exist");
if(["image","video","audio"].includes(input.kind)&&!input.assets.some(link=>link.role==='content'&&assets.some(a=>a.id===link.id&&a.kind===input.kind))) throw new Error("A media post needs matching content");
if(input.coverAssetId && !(await client.query("SELECT 1 FROM assets WHERE id=$1 AND kind='image'",[input.coverAssetId])).rowCount) throw new Error("Cover must reference an image");
await client.query('INSERT INTO posts(id,slug,kind,title,page_key,cover_asset_id,description) VALUES($1,$2,$3,$4,$5,$6,$7)',[id,slug,input.kind,input.title,input.pageKey||null,input.coverAssetId||null,input.description||input.text?.slice(0,200)||input.title]);
await client.query('INSERT INTO posts(id,slug,kind,title,cover_asset_id,description) VALUES($1,$2,$3,$4,$5,$6)',[id,slug,input.kind,input.title,input.coverAssetId||null,input.description||input.text?.slice(0,200)||input.title]);
for(const [position,a] of input.assets.entries()) await client.query('INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,$4)',[id,a.id,position,a.role]);
if(input.text){const textId=randomUUID();await client.query("INSERT INTO assets(id,kind,body) VALUES($1,'text',$2)",[textId,input.text]);await client.query("INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,'caption')",[id,textId,input.assets.length]);}
for(const tag of new Set(input.tags)) await client.query('INSERT INTO post_tags(post_id,tag_id) VALUES($1,$2)',[id,tag]);
await linkDerivedAssets(client,id);
return {id,slug};
});
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default async function handler(req:NextApiRequest,res:NextApiResponse){
try{
const variant=String(req.query.variant||'');
if(variant&&!['poster','silent_video','audio_track'].includes(variant))return res.status(404).end();
const result=variant?await db().query('SELECT storage_key,mime_type FROM asset_variants WHERE asset_id=$1 AND role=$2',[req.query.id,variant]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[req.query.id]);
const result=variant?await db().query('SELECT storage_key,mime_type FROM assets WHERE source_asset_id=$1 AND derivation=$2',[req.query.id,variant]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[req.query.id]);
if(!result.rows[0]?.storage_key)return res.status(404).end();
const object=await getObject(result.rows[0].storage_key,req.headers.range);
res.status(object.ContentRange?206:200);
@@ -1,8 +0,0 @@
import type {NextApiRequest,NextApiResponse} from "next";
import {snapshotState,queueSnapshots} from "@/lib/snapshots";
import {sameOrigin,apiError} from "@/lib/api";
export default async function handler(req:NextApiRequest,res:NextApiResponse){
res.setHeader('Cache-Control','no-store');
if(!['GET','POST'].includes(req.method||''))return res.status(405).end();
try{const state=await snapshotState(String(req.query.slug));if(!state)return res.status(404).end();if(req.method==='POST'){sameOrigin(req);const page=req.body?.page;if(page!==undefined&&(!Number.isInteger(page)||page<1||page>8))return res.status(400).json({error:'页码应为 1—8'});await queueSnapshots(state.postId,page);return res.status(202).json({queued:true});}return res.json(state);}catch(e){return apiError(res,e);}
}
+1 -1
View File
@@ -2,5 +2,5 @@ import type {NextApiRequest,NextApiResponse} from 'next';
import {db} from '@/lib/db';
import {getObject} from '@/lib/storage';
import {previewWidth,cachedPreview,loadStaticPreview} from '@/lib/preview';
export default async function handler(req:NextApiRequest,res:NextApiResponse){if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();const width=previewWidth(req.query.width);if(!width||typeof req.query.source!=='string')return res.status(400).end();try{const source=req.query.source;let input:{identity:string;load:()=>Promise<Buffer>};if(source.startsWith('/api/media/')){const match=source.match(/^\/api\/media\/([0-9a-f-]{36})(?:\?variant=(poster))?$/);if(!match)return res.status(400).end();const result=match[2]?await db().query('SELECT storage_key,mime_type FROM asset_variants WHERE asset_id=$1 AND role=$2',[match[1],match[2]]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[match[1]]);const row=result.rows[0];if(!row?.storage_key||!row.mime_type?.startsWith('image/'))return res.status(404).end();input={identity:row.storage_key,load:async()=>{const object=await getObject(row.storage_key);if((object.ContentLength||0)>64*1024*1024)throw Error('Image too large');const chunks:Buffer[]=[];let length=0;for await(const chunk of object.Body as AsyncIterable<Uint8Array>){length+=chunk.length;if(length>64*1024*1024)throw Error('Image too large');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);}};}else input=await loadStaticPreview(source);const {bytes,etag}=await cachedPreview(input.identity,width,input.load);res.setHeader('ETag',etag);res.setHeader('Cache-Control','public, max-age=3600');res.setHeader('Content-Type','image/webp');res.setHeader('X-Content-Type-Options','nosniff');if(req.headers['if-none-match']===etag)return res.status(304).end();res.setHeader('Content-Length',bytes.length);return req.method==='HEAD'?res.end():res.send(bytes);}catch(e){const message=(e as Error).message;if(message.includes('Invalid source'))return res.status(400).end();if((e as NodeJS.ErrnoException).code==='ENOENT')return res.status(404).end();res.setHeader('Retry-After','5');return res.status(503).end();}}
export default async function handler(req:NextApiRequest,res:NextApiResponse){if(!['GET','HEAD'].includes(req.method||''))return res.status(405).end();const width=previewWidth(req.query.width);if(!width||typeof req.query.source!=='string')return res.status(400).end();try{const source=req.query.source;let input:{identity:string;load:()=>Promise<Buffer>};if(source.startsWith('/api/media/')){const match=source.match(/^\/api\/media\/([0-9a-f-]{36})(?:\?variant=(poster))?$/);if(!match)return res.status(400).end();const result=match[2]?await db().query('SELECT storage_key,mime_type FROM assets WHERE source_asset_id=$1 AND derivation=$2',[match[1],match[2]]):await db().query('SELECT storage_key,mime_type FROM assets WHERE id=$1',[match[1]]);const row=result.rows[0];if(!row?.storage_key||!row.mime_type?.startsWith('image/'))return res.status(404).end();input={identity:row.storage_key,load:async()=>{const object=await getObject(row.storage_key);if((object.ContentLength||0)>64*1024*1024)throw Error('Image too large');const chunks:Buffer[]=[];let length=0;for await(const chunk of object.Body as AsyncIterable<Uint8Array>){length+=chunk.length;if(length>64*1024*1024)throw Error('Image too large');chunks.push(Buffer.from(chunk));}return Buffer.concat(chunks);}};}else input=await loadStaticPreview(source);const {bytes,etag}=await cachedPreview(input.identity,width,input.load);res.setHeader('ETag',etag);res.setHeader('Cache-Control','public, max-age=3600');res.setHeader('Content-Type','image/webp');res.setHeader('X-Content-Type-Options','nosniff');if(req.headers['if-none-match']===etag)return res.status(304).end();res.setHeader('Content-Length',bytes.length);return req.method==='HEAD'?res.end():res.send(bytes);}catch(e){const message=(e as Error).message;if(message.includes('Invalid source'))return res.status(400).end();if((e as NodeJS.ErrnoException).code==='ENOENT')return res.status(404).end();res.setHeader('Retry-After','5');return res.status(503).end();}}
export const config={api:{responseLimit:false}};
+11 -8
View File
@@ -1,15 +1,18 @@
import type { NextApiRequest,NextApiResponse } from "next";
import { z } from "zod";
import { randomUUID } from "node:crypto";
import { db } from "@/lib/db";
import { tags } from "@/lib/library";
import { sameOrigin,apiError } from "@/lib/api";
import type {NextApiRequest,NextApiResponse} from 'next';
import {randomUUID} from 'node:crypto';
import {z} from 'zod';
import {db} from '@/lib/db';
import {tags} from '@/lib/library';
import {sameOrigin,apiError} from '@/lib/api';
export default async function handler(req:NextApiRequest,res:NextApiResponse){
try{
if(req.method==='GET')return res.json(await tags());
if(req.method!=='POST')return res.status(405).end();
sameOrigin(req);
const data=z.object({name:z.string().trim().min(1).max(60),slug:z.string().regex(/^[a-z0-9][a-z0-9-]{0,59}$/),parentId:z.uuid().nullable().default(null)}).parse(req.body);
const id=randomUUID(); await db().query('INSERT INTO tags(id,name,slug,parent_id) VALUES($1,$2,$3,$4)',[id,data.name,data.slug,data.parentId]);return res.status(201).json({id});
const data=z.object({name:z.string().trim().transform(v=>v.replace(/^#+/,'').trim()).pipe(z.string().min(1).max(60).regex(/^[^#\s,,]+$/)),slug:z.string().regex(/^[a-z0-9][a-z0-9-]{0,59}$/).optional()}).parse(req.body);
const id=randomUUID();
const result=await db().query(`INSERT INTO tags(id,name,slug) VALUES($1,$2,$3)
ON CONFLICT(lower(btrim(name))) DO UPDATE SET name=tags.name RETURNING *,slug AS path`,[id,data.name,data.slug||'tag-'+id]);
return res.status(result.rows[0].id===id?201:200).json(result.rows[0]);
}catch(e){return apiError(res,e);}
}
+4 -3
View File
@@ -3,6 +3,7 @@ import Head from "next/head";
import { useState,type FormEvent } from "react";
import { useRouter } from "next/router";
import { Upload,Image,Film,AudioLines,ArrowUpRight,X,Check } from "lucide-react";
import {TagInput} from "@/components/TagInput";
import { Shell } from "@/components/Shell";
import { tags } from "@/lib/library";
import type { Tag } from "@/lib/model";
@@ -10,15 +11,15 @@ export default function Import({allTags}:{allTags:Tag[]}){
const router=useRouter();const [kind,setKind]=useState(['image','video','audio'].includes(String(router.query.kind))?String(router.query.kind):'image');
const [files,setFiles]=useState<File[]>([]);const [title,setTitle]=useState('');const [description,setDescription]=useState('');const [selected,setSelected]=useState<string[]>([]);const [busy,setBusy]=useState(false);const [error,setError]=useState('');
function choose(list:FileList|null){if(!list)return;const items=Array.from(list);setFiles(items);if(!title&&items[0])setTitle(items[0].name.replace(/\.[^.]+$/,''));}
async function submit(e:FormEvent){e.preventDefault();setError('');setBusy(true);try{const data=new FormData();data.set('kind',kind);data.set('title',title);data.set('description',description);data.set('tags',JSON.stringify(selected));files.forEach(f=>data.append('files',f));const res=await fetch('/api/upload',{method:'POST',body:data});const result=await res.json();if(!res.ok)throw new Error(result.error);await router.push('/p/'+result.slug);}catch(e){setError(e instanceof Error?e.message:'上传失败');}finally{setBusy(false);}}
async function submit(e:FormEvent){e.preventDefault();setError('');setBusy(true);try{const data=new FormData();data.set('kind',kind);data.set('title',title);data.set('description',description);data.set('tags',JSON.stringify(selected));files.forEach(f=>data.append('files',f));const res=await fetch('/api/upload',{method:'POST',body:data});const result=await res.json();if(!res.ok)throw new Error(result.error);await router.push('/explore/'+result.id);}catch(e){setError(e instanceof Error?e.message:'上传失败');}finally{setBusy(false);}}
return <Shell><Head><title>素材入库 · PROMPT</title></Head><header className="page-header"><div><span className="eyebrow">MAKE ROOM FOR INSPIRATION</span><h1>导入素材</h1><p>支持多文件导入。同一组素材收录为一个素材包,每份文件都可以再次引用。</p></div></header><form className="editor-panel" onSubmit={submit}>
<div className="kind-picker">{[{id:'image',name:'图片',Icon:Image},{id:'video',name:'视频',Icon:Film},{id:'audio',name:'声音',Icon:AudioLines}].map(t=><button type="button" key={t.id} disabled={busy} className={kind===t.id?'selected':''} onClick={()=>{setKind(t.id);setFiles([]);}}><t.Icon size={19}/>{t.name}</button>)}</div>
<label className="drop-zone" onDragOver={e=>e.preventDefault()} onDrop={e=>{e.preventDefault();if(!busy)choose(e.dataTransfer.files);}}><input type="file" multiple disabled={busy} accept={kind==='image'?'image/jpeg,image/png,image/webp,image/gif,image/avif':kind+'/*'} onChange={e=>choose(e.target.files)}/><span className="upload-circle"><Upload size={24}/></span><strong>选择文件或拖放到这里</strong><span>最多 16 个文件,单个不超过 250 MB</span></label>
{files.length>0&&<ul className="file-list">{files.map((f,i)=><li key={i}><Check size={15}/><span>{f.name}</span><small>{(f.size/1024/1024).toFixed(1)} MB</small><button type="button" aria-label={`移除 ${f.name}`} disabled={busy} onClick={()=>setFiles(a=>a.filter((_,j)=>i!==j))}><X size={14}/></button></li>)}</ul>}
<div className="form-field"><label htmlFor="title">标题</label><input id="title" required maxLength={200} value={title} onChange={e=>setTitle(e.target.value)} placeholder="添加标题"/></div>
<div className="form-field"><label htmlFor="description">描述 <small>选填</small></label><textarea id="description" maxLength={100000} rows={4} value={description} onChange={e=>setDescription(e.target.value)} placeholder="拍摄场景、灵感、下次想怎么用…"/></div>
{allTags.length>0&&<div className="form-field"><label>放进哪些分类 <small>可多选</small></label><div className="tag-picker">{allTags.map(t=><label key={t.id}><input type="checkbox" checked={selected.includes(t.id)} onChange={e=>setSelected(s=>e.target.checked?[...s,t.id]:s.filter(id=>id!==t.id))}/>{allTags.filter(a=>t.path.startsWith(a.path+'/')).map(a=>a.name+' / ')}{t.name}</label>)}</div></div>}
{kind==='video'&&<p className="form-note">视频入库后会自动分离画面和声音。有声、无声都会标记;分离出的音轨始终跟随原视频。</p>}
<TagInput initial={allTags} selected={selected} onChange={setSelected} disabled={busy}/>
{kind==='video'&&<p className="form-note">视频入库后会自动分离画面和声音。有声、无声都会标记;生成的静音视频、音轨和封面会作为资源链接附在这篇笔记中。</p>}
{error&&<p role="alert" className="error-message">{error}</p>}<div className="form-actions"><span>原文件独立保存,素材包只记录引用。</span><button className="primary-button" disabled={!files.length||busy}>{busy?'正在入库,请稍候…':'导入素材'}<ArrowUpRight size={16}/></button></div>
</form></Shell>;
}
+3 -34
View File
@@ -1,35 +1,4 @@
import {previewUrl} from '@/content/page-links';
import type { GetServerSideProps } from "next";
import Head from "next/head";
import Link from "next/link";
import { useEffect,useState } from "react";
import { useRouter } from "next/router";
import { ArrowLeft,ChevronLeft,ChevronRight,Volume2,VolumeX,Download,Copy,Layers } from "lucide-react";
import { ArticleGallery } from "@/components/ArticleGallery";
import { Shell } from "@/components/Shell";
import { getPost } from "@/lib/library";
import { mediaUrl,sections,type Post } from "@/lib/model";
import { works } from "@/content/registry";
export default function PostDetail({post}:{post:Post}){
const [index,setIndex]=useState(0);const [copied,setCopied]=useState(false);const router=useRouter();
const media=post.assets.filter(a=>a.kind!=='text');const selected=media[index];const section=sections.find(s=>s.kind===post.kind)!;
useEffect(()=>{if(post.assets.some(a=>['pending','processing'].includes(a.processing_status))){const timer=setTimeout(()=>{void router.replace(router.asPath,undefined,{scroll:false});},3000);return ()=>clearTimeout(timer);}},[post,router]);
const Work=post.page_key?works[post.page_key]?.Component:undefined;
return <Shell><Head><title>{post.title} · PROMPT</title><link rel="alternate" type="text/markdown" href={`/api/wiki-post/${post.slug}`}/></Head><div className="detail-top"><Link href={`/${section.path}`}><ArrowLeft size={17}/>返回{section.name}</Link><span>{['image','video','audio'].includes(post.kind)?'File':'Main'}:{post.slug}</span></div>
<div className={`detail-layout ${Work?'coded-detail':''}`}><section className="detail-media">
{Work?<ArticleGallery key={post.slug} slug={post.slug}/>:selected?<><div className={`media-stage stage-${selected.kind}`}>
{selected.kind==='image'&&<img src={previewUrl(mediaUrl(selected.id),1280)} alt={selected.filename||post.title}/>}
{selected.kind==='video'&&<video key={selected.id} controls playsInline preload="metadata" poster={selected.variants.includes('poster')?mediaUrl(selected.id,'poster'):undefined} src={mediaUrl(selected.id,selected.variants.includes('silent_video')?'silent_video':undefined)}/>}
{selected.kind==='audio'&&<div className="audio-stage"><div className="vinyl"><span/></div><h3>{selected.filename}</h3><audio key={selected.id} controls preload="metadata" src={mediaUrl(selected.id)}/></div>}
{media.length>1&&<><button className="gallery-prev icon-button" disabled={index===0} aria-label="上一个素材" onClick={()=>setIndex(i=>i-1)}><ChevronLeft/></button><button className="gallery-next icon-button" disabled={index===media.length-1} aria-label="下一个素材" onClick={()=>setIndex(i=>i+1)}><ChevronRight/></button><span className="gallery-position">{index+1} / {media.length}</span></>}
</div>
{selected.kind==='video'&&<div className="video-companion"><div><span className="pill">{selected.has_audio?<><Volume2 size={14}/>原视频有声音</>:<><VolumeX size={14}/>原视频无声音</>}</span><small>{selected.processing_status==='ready'?'画面已分离,可独立预览与复用':selected.processing_status==='failed'?'音画分离未完成,原文件仍可使用':'正在后台拆分画面与声音…'}</small></div>{selected.variants.includes('audio_track')&&<div className="bound-audio"><span><Volume2 size={16}/>这段视频的声音</span><audio controls preload="metadata" src={mediaUrl(selected.id,'audio_track')}/><small>音轨随视频保存,不属于独立声音素材。</small></div>}</div>}
{media.length>1&&<div className="thumbnail-strip">{media.map((a,i)=><button key={a.id} className={i===index?'selected':''} onClick={()=>setIndex(i)} aria-label={`查看第 ${i+1} 个素材`}>{a.kind==='image'?<img src={previewUrl(mediaUrl(a.id),240)} alt=""/>:a.variants.includes('poster')?<img src={previewUrl(mediaUrl(a.id,'poster'),240)} alt=""/>:<span>{a.kind==='audio'?'声音':'视频'} {i+1}</span>}</button>)}</div>}</>:<div className="empty-state"><Layers/><p>这个素材包还没有可展示的内容。</p></div>}
</section><aside className="detail-info"><span className="eyebrow">A PIECE OF YOUR COLLECTION</span><h1>{post.title}</h1><div className="detail-date">{new Date(post.created_at).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}<span>· {Work?8:media.length} {Work?'张图':'份素材'}</span></div>
<div className="description">{post.assets.filter(a=>a.kind==='text').map(a=><p key={a.id}>{a.body}</p>)}</div>
<div className="tag-list">{post.tags.map(t=><Link key={t.id} href={`/${section.path}/${t.path}`}># {t.name}</Link>)}</div>
{selected&&<div className="asset-facts"><h3>素材信息</h3><dl><dt>原文件</dt><dd>{selected.filename}</dd>{selected.width&&<><dt>尺寸</dt><dd>{selected.width} × {selected.height}</dd></>}{selected.duration&&<><dt>时长</dt><dd>{selected.duration.toFixed(1)} 秒</dd></>}<dt>复用方式</dt><dd>引用同一素材,无需再次上传</dd></dl><button className="secondary-button" onClick={async()=>{await navigator.clipboard.writeText(selected.id);setCopied(true);setTimeout(()=>setCopied(false),2000);}}><Copy size={14}/>{copied?'已复制素材 ID':'复制素材 ID'}</button><a className="text-link" href={mediaUrl(selected.id)} download><Download size={14}/>打开原文件</a></div>}
</aside></div>
</Shell>;
}
import type {GetServerSideProps} from 'next';
import {getPost} from '@/lib/library';
export default function LegacyPost(){return null;}
export const getServerSideProps:GetServerSideProps=async({params,query})=>{const post=await getPost(String(params?.slug||''));return post?{redirect:{destination:'/explore/'+post.id+(String(query.agent_mode||'').toLowerCase()==='true'?'?agent_mode=True':''),permanent:true}}:{notFound:true};};
@@ -1,6 +0,0 @@
import Head from "next/head";
import type {GetServerSideProps} from "next";
import {chapters} from "@/content/learning-skills/data";
import {LearningSeries} from "@/content/learning-skills/Series";
export default function Canvas({chapterKey,page,title}:{chapterKey:string;page:number;title:string}){return <><Head><title>{`${title} · 第 ${page+1} 张`}</title><meta name="robots" content="noindex"/></Head><LearningSeries key={`${chapterKey}-${page}`} chapterKey={chapterKey} pageNumber={page} standalone/></>}
export const getServerSideProps:GetServerSideProps=async({params})=>{const chapter=chapters.find(c=>c.slug===params?.slug);const n=String(params?.page);if(!chapter||!/^([1-8])$/.test(n))return {notFound:true};return {props:{chapterKey:chapter.key,page:Number(n)-1,title:chapter.title}}};
+9
View File
@@ -18,3 +18,12 @@
/* Independent notes: description, topic tags and image count, with no author chrome. */
.discovery-note-link{display:block;color:inherit;text-decoration:none}.discovery-description{font-size:13px;line-height:1.8;color:#777;margin:0 2px 9px;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:3;overflow:hidden;overflow-wrap:anywhere}.discovery-tags{display:flex;gap:5px 12px;flex-wrap:wrap;margin:0 2px;font-size:12px;line-height:1.7;color:#466478}.discovery-tags a:hover{text-decoration:underline}.discovery-cover>.discovery-image-count{position:absolute;z-index:1;right:12px;top:12px;display:flex;align-items:center;gap:5px;padding:4px 8px;border-radius:14px;background:#0007;color:#fff;opacity:1;letter-spacing:0;font-size:12px;line-height:1.4;backdrop-filter:blur(4px)}.note-info>.note-content{padding-top:32px}.note-content>.note-description{color:#7b7b7b;font-size:14px;line-height:1.8;margin:0 0 12px}.note-content>.note-tags{display:flex;gap:6px 12px;flex-wrap:wrap;margin-bottom:25px;font-size:13px}.note-content>.note-tags a{color:#466478}@media(max-width:760px){.discovery-description{font-size:12px;-webkit-line-clamp:3}.discovery-tags{font-size:11px;gap:3px 8px}.discovery-cover>.discovery-image-count{right:9px;top:9px;font-size:11px}.note-info>.note-content{padding-top:24px}}
.note-content a.note-inline-link{display:inline-flex;align-items:baseline;gap:4px;max-width:100%;padding:0 5px;border-radius:5px;background:#f2f5f7;color:#456278;text-decoration:none;vertical-align:baseline;overflow-wrap:anywhere}
.note-inline-link svg{flex-shrink:0;align-self:center;opacity:.65}
.note-content a.note-inline-link:hover{background:#e8eff4;text-decoration:underline;text-underline-offset:3px}
.note-content a.note-inline-link:focus-visible{outline:2px solid #6888a0;outline-offset:2px}
.tag-input-selected,.tag-input-suggestions{display:flex;gap:8px;flex-wrap:wrap;margin:8px 0}
.tag-input-selected button,.tag-input-suggestions button{border:0;background:#f2f3f4;color:#526373;border-radius:16px;padding:6px 10px;cursor:pointer;font-size:13px}
.tag-input-selected span{margin-left:8px;color:#888}
.tag-input-row{display:flex;align-items:center;gap:10px}.tag-input-row input{min-width:0;flex:1}.tag-input-row button{flex-shrink:0}
+9 -3
View File
@@ -8,7 +8,8 @@ import { setTimeout } from "node:timers/promises";
import { db,transaction } from "./lib/db";
import { getObject,putFile } from "./lib/storage";
import { probe,run } from "./lib/probe";
import {renderOne} from "./lib/snapshots";
import {randomUUID} from "node:crypto";
import {linkDerivedAssets} from "./lib/asset-links";
let stopping=false;
process.on('SIGTERM',()=>{stopping=true;});process.on('SIGINT',()=>{stopping=true;});
async function processOne(){
@@ -27,7 +28,12 @@ async function processOne(){
const outputs:{role:string;mime:string;file:string;args:string[];key:string;size:number}[]=[];
for(const v of variants){const path=join(dir,v.file);await run(process.env.FFMPEG_PATH||'ffmpeg',['-nostdin','-y','-v','error','-protocol_whitelist','file,pipe','-i',input,...v.args,path],{timeout:10*60*1000,maxBuffer:2*1024*1024});const key=`derived/${asset.id}/${v.file}`;const size=await putFile(key,path,v.mime);outputs.push({...v,key,size});}
await transaction(async client=>{
for(const v of outputs)await client.query('INSERT INTO asset_variants(asset_id,role,storage_key,mime_type,byte_size) VALUES($1,$2,$3,$4,$5) ON CONFLICT(asset_id,role) DO UPDATE SET storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size',[asset.id,v.role,v.key,v.mime,v.size]);
for(const v of outputs)await client.query(`INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,source_asset_id,derivation)
VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(source_asset_id,derivation) WHERE source_asset_id IS NOT NULL
DO UPDATE SET storage_key=excluded.storage_key,mime_type=excluded.mime_type,byte_size=excluded.byte_size`,
[randomUUID(),v.role==='poster'?'image':v.role==='audio_track'?'audio':'video',v.file,v.key,v.mime,v.size,asset.id,v.role]);
const posts=(await client.query("SELECT DISTINCT post_id FROM post_assets WHERE asset_id=$1 AND role='content' ORDER BY post_id",[asset.id])).rows;
for(const post of posts)await linkDerivedAssets(client,post.post_id);
await client.query("UPDATE assets SET has_audio=$2,processing_status='ready',processing_error=NULL WHERE id=$1",[asset.id,hasAudio]);
await client.query("UPDATE media_jobs SET status='ready',last_error=NULL WHERE id=$1",[job.id]);
});
@@ -44,7 +50,7 @@ async function processOne(){
async function main(){
const heartbeat=()=>writeFile('/tmp/prompt-worker-health',String(Date.now())).catch(()=>console.error('Worker heartbeat failed'));
await heartbeat();const timer=setInterval(()=>{void heartbeat();},15000);
try{while(!stopping){try{if(!await processOne()&&!await renderOne())await setTimeout(2000);}catch{console.error('Worker connection error; retrying');await setTimeout(5000);}}}
try{while(!stopping){try{if(!await processOne())await setTimeout(2000);}catch{console.error('Worker connection error; retrying');await setTimeout(5000);}}}
finally{clearInterval(timer);await db().end();}
}
main().catch(()=>process.exit(1));