gitdataai/src/page/workspace/channel/article-feed.tsx

326 lines
10 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from "react";
import {
Loader2,
Plus,
Newspaper,
FileText,
} from "lucide-react";
import { api } from "@/client";
import { useChannelSocket } from "@/hooks/use-channel";
import { Button } from "@/components/ui/button";
import ArticleCard from "./article-card";
import ArticleDetail from "./article-detail";
import type { ArticleItem, ArticleDetail as ArticleDetailType } from "./article-types";
type Props = {
roomId: string;
roomName: string;
currentUserId?: string;
onCompose?: () => void;
};
export default function ArticleFeed({ roomId, roomName, currentUserId, onCompose }: Props) {
const [articles, setArticles] = useState<ArticleItem[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [detailId, setDetailId] = useState<string | null>(null);
const [detail, setDetail] = useState<ArticleDetailType | null>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
const loaderRef = useRef<HTMLDivElement>(null);
const loadArticles = useCallback(
async (before?: string) => {
try {
const params: Record<string, string | number> = { limit: 20 };
if (before) params.before = before;
const res = await api.get<
Record<string, unknown>
>(`/api/v1/ws/channels/${roomId}/articles`, { params });
const data = res.data as Record<string, unknown>;
const list = (data.articles ?? []) as ArticleItem[];
const more = (data.has_more ?? false) as boolean;
if (before) {
setArticles((prev) => [...prev, ...list]);
} else {
setArticles(list);
}
setHasMore(more);
} catch {
// ignore
} finally {
setLoading(false);
}
},
[roomId],
);
// Reset on room change and (re)load
useEffect(() => {
let cancelled = false;
const doLoad = async () => {
setLoading(true);
setArticles([]);
setHasMore(true);
setDetailId(null);
setDetail(null);
try {
const params: Record<string, string | number> = { limit: 20 };
const res = await api.get<Record<string, unknown>>(
`/api/v1/ws/channels/${roomId}/articles`,
{ params },
);
if (cancelled) return;
const data = res.data as Record<string, unknown>;
setArticles((data.articles ?? []) as ArticleItem[]);
setHasMore((data.has_more ?? false) as boolean);
} catch {
// ignore
} finally {
if (!cancelled) setLoading(false);
}
};
doLoad();
return () => {
cancelled = true;
};
}, [roomId]);
// Load detail when detailId changes
useEffect(() => {
if (!detailId) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- clear on deselect
setDetail(null);
return;
}
let cancelled = false;
setLoadingDetail(true);
api
.get<Record<string, unknown>>(
`/api/v1/ws/channels/${roomId}/articles/${detailId}`,
)
.then((res) => {
if (cancelled) return;
const d = res.data as unknown as ArticleDetailType;
setDetail(d);
setArticles((prev) =>
prev.map((a) =>
a.id === detailId ? { ...a, view_count: d.view_count } : a,
),
);
})
.catch(() => setDetailId(null))
.finally(() => {
if (!cancelled) setLoadingDetail(false);
});
return () => {
cancelled = true;
};
}, [detailId, roomId]);
// Intersection observer for infinite scroll
useEffect(() => {
const el = loaderRef.current;
if (!el || !hasMore || loading) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMore && !loading && articles.length > 0) {
const last = articles[articles.length - 1];
loadArticles(last.id);
}
},
{ threshold: 0.1 },
);
observer.observe(el);
return () => observer.disconnect();
}, [hasMore, loading, articles, loadArticles]);
// Listen to websocket events for article updates
const { onEvent } = useChannelSocket();
useEffect(() => {
return onEvent((event) => {
if (!('room' in event) || !event.room || event.room.id !== roomId) return;
if (event.type === "article.liked" || event.type === "article.unliked") {
const d = event.data as { article_id: string; like_count: number };
setArticles((prev) =>
prev.map((a) =>
a.id === d.article_id ? { ...a, like_count: d.like_count } : a,
),
);
if (detail?.id === d.article_id) {
setDetail((prev) =>
prev ? { ...prev, like_count: d.like_count } : null,
);
}
}
if (event.type === "article.comment.created") {
const d = event.data as {
comment: { article: string };
comment_count: number;
};
setArticles((prev) =>
prev.map((a) =>
a.id === d.comment.article
? { ...a, comment_count: d.comment_count }
: a,
),
);
if (detail?.id === d.comment.article) {
setDetail((prev) =>
prev ? { ...prev, comment_count: d.comment_count } : null,
);
}
}
if (event.type === "article.comment.deleted") {
const d = event.data as { article_id: string; comment_count: number };
setArticles((prev) =>
prev.map((a) =>
a.id === d.article_id
? { ...a, comment_count: d.comment_count }
: a,
),
);
if (detail?.id === d.article_id) {
setDetail((prev) =>
prev ? { ...prev, comment_count: d.comment_count } : null,
);
}
}
});
}, [roomId, onEvent, detail?.id]);
return (
<div className="relative flex min-h-0 flex-1 flex-col">
{/* Header */}
<div className="flex shrink-0 items-center gap-3 border-b border-border/40 px-5 py-3">
<Newspaper className="size-5 text-primary/50" />
<div className="min-w-0 flex-1">
<h2 className="truncate text-sm font-semibold">{roomName}</h2>
<p className="text-[11px] text-muted-foreground/40">
{articles.length} articles
</p>
</div>
<Button
className="h-8 cursor-pointer gap-1.5 rounded-lg"
onClick={onCompose}
size="sm"
>
<Plus className="size-4" />
Write
</Button>
</div>
{/* Article grid */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{loading ? (
<div className="flex justify-center py-20">
<Loader2 className="size-5 animate-spin text-muted-foreground/25" />
</div>
) : articles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="grid size-16 place-items-center rounded-2xl bg-muted/20 ring-1 ring-border/20">
<FileText className="size-7 text-muted-foreground/15" />
</div>
<p className="mt-4 text-sm font-medium text-muted-foreground/40">
No articles yet
</p>
<p className="mt-1 text-[12px] text-muted-foreground/30">
Be the first to share
</p>
<Button
className="mt-4 cursor-pointer"
onClick={onCompose}
size="sm"
variant="outline"
>
<Plus className="mr-1.5 size-4" />
Write
</Button>
</div>
) : (
<>
{/* Masonry grid */}
<div className="columns-1 gap-4 p-4 sm:columns-2 lg:columns-3 xl:columns-4">
{articles.map((article) => (
<div
className="mb-4 break-inside-avoid"
key={article.id}
>
<ArticleCard
article={article}
onClick={(id) => setDetailId(id)}
/>
</div>
))}
</div>
{/* Loader sentinel */}
<div ref={loaderRef} className="flex justify-center py-6">
{hasMore && (
<Loader2 className="size-4 animate-spin text-muted-foreground/20" />
)}
{!hasMore && articles.length > 0 && (
<span className="text-[11px] text-muted-foreground/25">
You have reached the end
</span>
)}
</div>
</>
)}
</div>
{/* Article detail panel */}
{detailId && (
<>
<div
className="absolute inset-0 z-20 bg-background/60 backdrop-blur-sm"
onClick={() => setDetailId(null)}
/>
<div className="absolute inset-0 right-0 z-30 overflow-y-auto bg-card shadow-2xl sm:inset-y-2 sm:right-2 sm:w-[min(640px,90vw)] sm:rounded-2xl sm:border sm:border-border/30">
{loadingDetail ? (
<div className="flex justify-center py-20">
<Loader2 className="size-5 animate-spin text-muted-foreground/25" />
</div>
) : detail ? (
<ArticleDetail
article={detail}
currentUserId={currentUserId}
onClose={() => setDetailId(null)}
onUpdated={(updated) => {
setDetail(updated);
setArticles((prev) =>
prev.map((a) =>
a.id === updated.id
? {
...a,
title: updated.title,
summary: updated.summary,
cover_url: updated.cover_url,
tags: updated.tags,
like_count: updated.like_count,
comment_count: updated.comment_count,
view_count: updated.view_count,
is_pinned: updated.is_pinned,
updated_at: updated.updated_at,
}
: a,
),
);
}}
/>
) : null}
</div>
</>
)}
</div>
);
}