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>;
}