Files
probably-rendering-one-more…/apps/waterfall-wiki/src/components/ExploreDetail.tsx
T
codex-bot 8062dbc878
Endoleap brand checks / verify (pull_request) Successful in 2m25s
creative workspace review checks / verify (pull_request) Failing after 2m20s
feat(ui): apply Endoleap themes, brand marks and favicons
2026-11-01 01:44:51 +08:00

50 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,Pencil,Archive,ArchiveRestore} from 'lucide-react';
import type {ExploreEntry} from '@/lib/explore';
import {previewUrl,exploreUrl} from '@/content/page-links';
import {Shell} from './Shell';
import {ExploreFeed} from './ExploreFeed';
import {NoteMarkdown} from './NoteMarkdown';
import {QuickTags} from './QuickTags';
import {NoteEditor} from './NoteEditor';
export function ExploreDetail({entry:initial,background}:{entry:ExploreEntry;background:ExploreEntry[]}){
const [entry,setEntry]=useState(initial),[index,setIndex]=useState(0),[copied,setCopied]=useState(''),[editing,setEditing]=useState(false),[copyError,setCopyError]=useState(''),[archiveBusy,setArchiveBusy]=useState(false);
const router=useRouter();const closeRef=useRef<HTMLButtonElement>(null),detailRef=useRef<HTMLDivElement>(null);const media=entry.media[index];
useEffect(()=>{setEntry(initial);setIndex(0);setCopied('');setEditing(false)},[initial]);
const close=()=>{if(editing){setEditing(false);return;}void router.push('/')};
useEffect(()=>{
const previous=document.activeElement as HTMLElement|null;if(!editing)closeRef.current?.focus();const old=document.body.style.overflow;document.body.style.overflow='hidden';
function key(e:KeyboardEvent){
if(e.key==='Escape'){e.preventDefault();close();return;}
const typing=e.target instanceof HTMLElement&&!!e.target.closest('input,textarea,select,[contenteditable="true"]');
if(!editing&&!typing&&e.key==='ArrowLeft')setIndex(n=>Math.max(0,n-1));
if(!editing&&!typing&&e.key==='ArrowRight')setIndex(n=>Math.max(0,Math.min(entry.media.length-1,n+1)));
if(e.key==='Tab'){const items=Array.from(detailRef.current?.querySelectorAll<HTMLElement>('a[href],button:not(:disabled),input:not(:disabled),textarea:not(:disabled),select:not(:disabled),summary,video,audio')||[]).filter(el=>el.getClientRects().length);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,editing]);
async function copy(mode:'web'|'agent'){
try{const url=window.location.origin+exploreUrl(entry.id)+(mode==='agent'?'?agent_mode=True':'');
if(navigator.clipboard)await navigator.clipboard.writeText(url);else{const input=document.createElement('textarea');input.value=url;document.body.appendChild(input);input.select();const success=document.execCommand('copy');input.remove();if(!success)throw Error('Copy failed');}
setCopied(mode);setCopyError('');
}catch{setCopied('');setCopyError('复制失败,请重试。')}
}
async function archive(){
setArchiveBusy(true);setCopyError('');try{const r=await fetch('/api/posts/'+entry.id+'/archive',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({archived:!entry.archived})});const d=await r.json();if(!r.ok)throw Error(d.error||'操作失败');const state=await fetch('/api/notes/'+entry.id).then(r=>r.json());setEntry(state.entry);void router.replace(router.asPath,undefined,{scroll:false})}catch(e){setCopyError(e instanceof Error?e.message:'操作失败')}finally{setArchiveBusy(false)}
}
return <Shell><Head><title>{entry.title} · 啡跃 Endoleap</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 key={entry.revision} initial={background.map(e=>e.id===entry.id?entry:e)}/></div>
<div className="note-overlay" onClick={()=>{if(!editing)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={editing?'取消编辑':'关闭详情'} 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></>}
</section><section className="note-info"><div className="note-content">
{editing?<NoteEditor key={entry.id} entry={entry} onCancel={()=>setEditing(false)} onSaved={updated=>{setEntry(updated);setEditing(false);setCopied('')}}/>:<><h1 id="note-title">{entry.title}</h1>{entry.archived&&<p className="note-archive-status" role="status">已归档 · 已从列表和搜索中隐藏,内容仍保留。</p>}<p className="note-description">{entry.description}</p><div className="note-tags">{entry.systemTags?.map(t=><Link className="system-tag-pill" key={t.id} href={"/categories?category="+encodeURIComponent(t.name)}>{t.name}</Link>)}{entry.categories.map(c=><Link key={c} href={'/categories?category='+encodeURIComponent(c)}>#{c} </Link>)}<QuickTags entry={entry} onSaved={setEntry}/></div><NoteMarkdown text={entry.markdown.replace(/^# [^\n]*(?:\n|$)\n?/,'')}/><section id="backlinks" className="note-backlinks"><h2>被 {entry.backlinkCount??0} 篇笔记引用</h2>{entry.backlinks?.length?<ul>{entry.backlinks.map(ref=><li key={ref.id}><Link href={exploreUrl(ref.id)}>{ref.title}</Link></li>)}</ul>:<p>还没有笔记引用本文。</p>}</section><div className="note-id">内容 ID · {entry.id}</div></>}
</div>{!editing&&<footer><button onClick={()=>void copy('web')}><LinkIcon size={18}/>{copied==='web'?'网页链接已复制':'复制网页链接'}</button><button onClick={()=>void copy('agent')}><FileText size={18}/>{copied==='agent'?'纯文本链接已复制':'复制纯文本链接'}</button><button onClick={()=>setEditing(true)}><Pencil size={17}/>编辑</button>{entry.postSlug&&<button disabled={archiveBusy} onClick={()=>void archive()}>{entry.archived?<ArchiveRestore size={17}/>:<Archive size={17}/>}{archiveBusy?'处理中…':entry.archived?'恢复':'归档'}</button>}{copyError&&<span role="status">{copyError}</span>}</footer>}</section>
</div></div></Shell>;
}