43 lines
7.1 KiB
TypeScript
43 lines
7.1 KiB
TypeScript
import {previewUrl} from '@/content/page-links';
|
||
import {SkillCard} from "./SkillCard";
|
||
import {skillCatalog} from "@/content/skills/catalog";
|
||
import Head from "next/head";
|
||
import Link from "next/link";
|
||
import { useEffect,useRef,useState } from "react";
|
||
import { useRouter } from "next/router";
|
||
import { Search,Image as ImageIcon,ArrowUpRight,AudioLines,Film,Layers,ArrowDown,Upload } from "lucide-react";
|
||
import { Shell } from "./Shell";
|
||
import { sections,mediaUrl,type FeedData,type Post } from "@/lib/model";
|
||
export function PostCard({post}:{post:Post}){
|
||
const image=post.assets.find(a=>a.kind==='image');const video=post.assets.find(a=>a.kind==='video');const cover=post.cover_asset_id||image?.id;
|
||
const label=sections.find(s=>s.kind===post.kind)?.name;const count=post.assets.filter(a=>a.kind!=='text').length;
|
||
const source=cover?mediaUrl(cover):video?.variants.includes('poster')?mediaUrl(video.id,'poster'):null;
|
||
return <Link className="post-card" href={`/explore/${post.id}`}><div className={`post-cover cover-${post.kind}`} style={image?.width&&image?.height?{aspectRatio:`${image.width}/${image.height}`,maxHeight:460}:undefined}>
|
||
{source?<img src={previewUrl(source,480)} alt={post.title} loading="lazy"/>:post.kind==='audio'?<div className="waveform">{Array.from({length:36},(_,i)=><i key={i} style={{height:12+((i*31)%65)}}/>)}</div>:<div className="cover-symbol">{post.kind==='video'?<Film size={42}/>:<Layers size={42}/>}<span>{label}</span></div>}
|
||
{count>1&&<span className="cover-count"><Layers size={12}/>{count}</span>}{video&&<span className="cover-badge">{video.has_audio?'有声音':'无声音'}</span>}
|
||
</div><h3 title={post.title}>{post.title}</h3><div className="post-meta"><span><i className={`type-dot ${post.kind}`}/>{label}</span><time>{new Date(post.created_at).toLocaleDateString('zh-CN',{month:'2-digit',day:'2-digit',timeZone:'Asia/Shanghai'})}</time></div></Link>;
|
||
}
|
||
function Masonry({posts,includeSkills=false,q=""}:{posts:Post[];includeSkills?:boolean;q?:string}){
|
||
const element=useRef<HTMLDivElement>(null);const [width,setWidth]=useState(1100);
|
||
useEffect(()=>{if(!element.current)return;const observer=new ResizeObserver(entries=>setWidth(entries[0].contentRect.width));observer.observe(element.current);return ()=>observer.disconnect();},[]);
|
||
const count=width>1250?5:width>1000?4:width>720?3:2;
|
||
type Item={post?:Post;skill?:typeof skillCatalog[number];date:string};const columns:Item[][]=Array.from({length:count},()=>[]);const items:Item[]=[...posts.map(post=>({post,date:post.created_at})),...(includeSkills?skillCatalog.filter(s=>(s.title+s.description+s.category).toLowerCase().includes(q.toLowerCase())).map(skill=>({skill,date:skill.created_at})):[])].sort((a,b)=>b.date.localeCompare(a.date));const heights=Array(count).fill(0);const cardWidth=(width-(width<=720?14:20)*(count-1))/count;
|
||
for(const item of items){const image=item.post?.assets.find(a=>a.kind==='image');const ratio=item.skill?1.25:image?.width&&image.height?image.height/image.width:item.post?.kind==='audio'?1:1.25;const index=heights.indexOf(Math.min(...heights));columns[index].push(item);heights[index]+=Math.min(cardWidth*ratio,item.skill?440:460)+78;}
|
||
|
||
return <div ref={element} className="masonry" style={{gridTemplateColumns:`repeat(${count}, minmax(0,1fr))`}}>{columns.map((items,i)=><div className="masonry-column" key={i}>{items.map(item=>item.skill?<SkillCard skill={item.skill} key={item.skill.id}/>:<PostCard post={item.post!} key={item.post!.id}/>)}</div>)}</div>;
|
||
}
|
||
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;
|
||
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||'灵感首页'} · 啡跃 Endoleap</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>}
|
||
{tagPath&&<div className="breadcrumbs"><Link href={`/${section}`}>全部{current?.name}</Link>{tagPath.split('/').map((_,i)=>{const path=tagPath.split('/').slice(0,i+1).join('/');return <span key={path}> / <Link href={`/${section}/${path}`}>{initial.tags.find(t=>t.path===path)?.name}</Link></span>;})}</div>}
|
||
<div className="feed-toolbar"><div className="feed-tabs">{!section?<><Link className="selected" href="/">全部</Link>{sections.map(s=><Link key={s.path} href={`/${s.path}`}>{s.name}</Link>)}</>:<><Link className={!tagPath?'selected':''} href={`/${section}`}>全部</Link>{children.map(t=><Link key={t.id} href={`/${section}/${t.path}`}>{t.name}</Link>)}</>}</div><span className="sort-label"><ArrowDown size={13}/>最近收录</span></div>
|
||
{initial.unavailable?<div className="empty-state"><Layers/><h2>素材库暂时没有连接上</h2><p>请稍后刷新,已保存的内容不会因此丢失。</p></div>:(posts.length||(!section&&skillCatalog.some(s=>(s.title+s.description+s.category).toLowerCase().includes(String(router.query.q||'').toLowerCase()))))?<Masonry posts={posts} includeSkills={!section} q={String(router.query.q||'')}/>:<div className="empty-state"><div className="empty-art"><span/><ImageIcon size={42} strokeWidth={1.2}/><i><AudioLines size={18}/></i><b><Film size={19}/></b></div><span className="eyebrow">A LITTLE SPACE FOR SOMETHING GOOD</span><h2>{router.query.q?'还没有找到这份灵感':'这里还没有内容'}</h2><p>{router.query.q?'换一个词试试看。':'导入图片、视频或声音,开始整理你的素材。'}</p><Link className="primary-button" href={`/library/import${current&&['image','video','audio'].includes(current.kind)?'?kind='+current.kind:''}`}><Upload size={16}/>导入素材<ArrowUpRight size={16}/></Link><small>图文和动态作品通过代码加入,自由决定每一个像素。</small></div>}
|
||
{cursor&&<div className="load-more"><button disabled={busy} onClick={load}>{busy?'正在翻找…':error||'再看一些'}<ArrowDown size={14}/></button></div>}
|
||
<footer className="page-footer">啡跃 <span>·</span> Endoleap</footer>
|
||
</Shell>;
|
||
}
|