- Add ArticleFeed component for article-based channels - Implement ArticleComposer with draft persistence - Add Newspaper icon for article room type - Update ChannelPage to conditionally render article feed vs message view - Add article-related API endpoints and models - Reset thread view when switching rooms - Add room type check in channel sidebar - Update CSS to hide scrollbars globally - Add gRPC message size limit configuration - Fix git diff tree handling
326 lines
10 KiB
TypeScript
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 (!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>
|
|
);
|
|
}
|