feat: add feed archive page

This commit is contained in:
moonrailgun 2024-08-31 01:45:19 +08:00
parent 3270164710
commit 87b4000c47
4 changed files with 206 additions and 33 deletions

View File

@ -0,0 +1,136 @@
import React, { useMemo } from 'react';
import { Button } from '../ui/button';
import { LuArchive, LuArchiveRestore } from 'react-icons/lu';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { AppRouterOutput, trpc } from '@/api/trpc';
import { useCurrentWorkspaceId } from '@/store/user';
import { DynamicVirtualList } from '../DynamicVirtualList';
import { get } from 'lodash-es';
import { FeedEventItem } from './FeedEventItem';
import { useTranslation } from '@i18next-toolkit/react';
import { Separator } from '../ui/separator';
import { useEvent } from '@/hooks/useEvent';
import { toast } from 'sonner';
import { AlertConfirm } from '../AlertConfirm';
type FeedItem = AppRouterOutput['feed']['fetchEventsByCursor']['items'][number];
interface FeedArchivePageButtonProps {
channelId: string;
}
export const FeedArchivePageButton: React.FC<FeedArchivePageButtonProps> =
React.memo((props) => {
const workspaceId = useCurrentWorkspaceId();
const channelId = props.channelId;
const { t } = useTranslation();
const unarchiveEventMutation = trpc.feed.unarchiveEvent.useMutation();
const clearAllArchivedEventsMutation =
trpc.feed.clearAllArchivedEvents.useMutation();
const trpcUtils = trpc.useUtils();
const {
data,
isInitialLoading,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = trpc.feed.fetchEventsByCursor.useInfiniteQuery(
{
workspaceId,
channelId,
archived: true,
},
{
refetchOnMount: false,
refetchOnWindowFocus: false,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const handleUnArchive = useEvent(async (event: FeedItem) => {
await unarchiveEventMutation.mutateAsync({
workspaceId,
channelId,
eventId: event.id,
});
trpcUtils.feed.fetchEventsByCursor.refetch();
toast.success(t('Event unarchived'));
});
const handleClear = useEvent(async () => {
const count = await clearAllArchivedEventsMutation.mutateAsync({
workspaceId,
channelId,
});
trpcUtils.feed.fetchEventsByCursor.refetch();
toast.success(t('{{count}} events cleared', { count }));
});
const fullEvents = useMemo(
() => data?.pages.flatMap((p) => p.items) ?? [],
[data]
);
return (
<Popover>
<PopoverTrigger>
<Button size="icon" variant="outline">
<LuArchive />
</Button>
</PopoverTrigger>
<PopoverContent
className="flex h-[50vh] w-96 flex-col overflow-hidden"
side="bottom"
align="end"
>
<div className="flex items-center justify-between">
<h1 className="text-lg font-bold">{t('Archived Events')}</h1>
<AlertConfirm onConfirm={handleClear}>
<Button size="sm">{t('Clear')}</Button>
</AlertConfirm>
</div>
<Separator className="my-2" />
<div className="flex-1">
<DynamicVirtualList
allData={fullEvents}
estimateSize={100}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onFetchNextPage={fetchNextPage}
getItemKey={(index) => get(fullEvents, [index, 'id'])}
renderItem={(item) => (
<FeedEventItem
className="animate-fade-in mb-2"
event={item}
actions={
<Button
size="icon"
variant="secondary"
className="absolute right-0 top-0 h-6 w-6 overflow-hidden"
onClick={() => handleUnArchive(item)}
>
<LuArchiveRestore size={12} />
</Button>
}
/>
)}
renderEmpty={() => (
<div className="w-full overflow-hidden p-4">
<div className="text-muted text-center">
{isInitialLoading
? t('Loading...')
: t('No archived events')}
</div>
</div>
)}
/>
</div>
</PopoverContent>
</Popover>
);
});
FeedArchivePageButton.displayName = 'FeedArchivePageButton';

View File

@ -6,11 +6,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { MarkdownViewer } from '../MarkdownEditor'; import { MarkdownViewer } from '../MarkdownEditor';
import { FeedIcon } from './FeedIcon'; import { FeedIcon } from './FeedIcon';
import { cn } from '@/utils/style'; import { cn } from '@/utils/style';
import { Button } from '../ui/button';
import { LuArchive } from 'react-icons/lu';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId } from '@/store/user';
import { useEvent } from '@/hooks/useEvent';
import { toast } from 'sonner';
import { useTranslation } from '@i18next-toolkit/react'; import { useTranslation } from '@i18next-toolkit/react';
type FeedEventItemType = type FeedEventItemType =
@ -19,22 +15,8 @@ type FeedEventItemType =
export const FeedEventItem: React.FC<{ export const FeedEventItem: React.FC<{
className?: string; className?: string;
event: FeedEventItemType; event: FeedEventItemType;
}> = React.memo(({ className, event }) => { actions?: React.ReactNode;
const workspaceId = useCurrentWorkspaceId(); }> = React.memo(({ className, event, actions }) => {
const archiveEventMutation = trpc.feed.archiveEvent.useMutation();
const trpcUtils = trpc.useUtils();
const { t } = useTranslation();
const handleArchive = useEvent(async () => {
await archiveEventMutation.mutateAsync({
workspaceId,
channelId: event.channelId,
eventId: event.id,
});
trpcUtils.feed.fetchEventsByCursor.refetch();
toast.success(t('Event archived'));
});
return ( return (
<div <div
className={cn( className={cn(
@ -53,14 +35,7 @@ export const FeedEventItem: React.FC<{
</div> </div>
</div> </div>
<Button {actions}
size="icon"
variant="secondary"
className="absolute right-0 top-0 h-6 w-6 overflow-hidden"
onClick={handleArchive}
>
<LuArchive size={12} />
</Button>
<div className="flex justify-between"> <div className="flex justify-between">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">

View File

@ -1,4 +1,9 @@
import { defaultErrorHandler, defaultSuccessHandler, trpc } from '@/api/trpc'; import {
AppRouterOutput,
defaultErrorHandler,
defaultSuccessHandler,
trpc,
} from '@/api/trpc';
import { CommonHeader } from '@/components/CommonHeader'; import { CommonHeader } from '@/components/CommonHeader';
import { CommonWrapper } from '@/components/CommonWrapper'; import { CommonWrapper } from '@/components/CommonWrapper';
import { useCurrentWorkspaceId } from '@/store/user'; import { useCurrentWorkspaceId } from '@/store/user';
@ -7,7 +12,7 @@ import { useTranslation } from '@i18next-toolkit/react';
import { createFileRoute, useNavigate } from '@tanstack/react-router'; import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useEvent } from '@/hooks/useEvent'; import { useEvent } from '@/hooks/useEvent';
import { AlertConfirm } from '@/components/AlertConfirm'; import { AlertConfirm } from '@/components/AlertConfirm';
import { LuPencil, LuTrash, LuWebhook } from 'react-icons/lu'; import { LuArchive, LuPencil, LuTrash, LuWebhook } from 'react-icons/lu';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { FeedApiGuide } from '@/components/feed/FeedApiGuide'; import { FeedApiGuide } from '@/components/feed/FeedApiGuide';
import { FeedEventItem } from '@/components/feed/FeedEventItem'; import { FeedEventItem } from '@/components/feed/FeedEventItem';
@ -17,12 +22,16 @@ import { useSocketSubscribeList } from '@/api/socketio';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { DynamicVirtualList } from '@/components/DynamicVirtualList'; import { DynamicVirtualList } from '@/components/DynamicVirtualList';
import { get, reverse } from 'lodash-es'; import { get, reverse } from 'lodash-es';
import { FeedArchivePageButton } from '@/components/feed/FeedArchivePageButton';
import { toast } from 'sonner';
export const Route = createFileRoute('/feed/$channelId/')({ export const Route = createFileRoute('/feed/$channelId/')({
beforeLoad: routeAuthBeforeLoad, beforeLoad: routeAuthBeforeLoad,
component: PageComponent, component: PageComponent,
}); });
type FeedItem = AppRouterOutput['feed']['fetchEventsByCursor']['items'][number];
function PageComponent() { function PageComponent() {
const { channelId } = Route.useParams<{ channelId: string }>(); const { channelId } = Route.useParams<{ channelId: string }>();
const workspaceId = useCurrentWorkspaceId(); const workspaceId = useCurrentWorkspaceId();
@ -38,6 +47,7 @@ function PageComponent() {
hasNextPage, hasNextPage,
fetchNextPage, fetchNextPage,
isFetchingNextPage, isFetchingNextPage,
refetch,
} = trpc.feed.fetchEventsByCursor.useInfiniteQuery( } = trpc.feed.fetchEventsByCursor.useInfiniteQuery(
{ {
workspaceId, workspaceId,
@ -56,6 +66,7 @@ function PageComponent() {
const trpcUtils = trpc.useUtils(); const trpcUtils = trpc.useUtils();
const navigate = useNavigate(); const navigate = useNavigate();
const archiveEventMutation = trpc.feed.archiveEvent.useMutation();
const handleDelete = useEvent(async () => { const handleDelete = useEvent(async () => {
await deleteMutation.mutateAsync({ workspaceId, channelId }); await deleteMutation.mutateAsync({ workspaceId, channelId });
trpcUtils.feed.channels.refetch(); trpcUtils.feed.channels.refetch();
@ -77,6 +88,16 @@ function PageComponent() {
[realtimeEvents, data] [realtimeEvents, data]
); );
const handleArchive = useEvent(async (event: FeedItem) => {
await archiveEventMutation.mutateAsync({
workspaceId,
channelId: event.channelId,
eventId: event.id,
});
trpcUtils.feed.fetchEventsByCursor.refetch();
toast.success(t('Event archived'));
});
return ( return (
<CommonWrapper <CommonWrapper
header={ header={
@ -93,6 +114,8 @@ function PageComponent() {
</DialogWrapper> </DialogWrapper>
)} )}
<FeedArchivePageButton channelId={channelId} />
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
@ -129,7 +152,20 @@ function PageComponent() {
onFetchNextPage={fetchNextPage} onFetchNextPage={fetchNextPage}
getItemKey={(index) => get(fullEvents, [index, 'id'])} getItemKey={(index) => get(fullEvents, [index, 'id'])}
renderItem={(item) => ( renderItem={(item) => (
<FeedEventItem className="animate-fade-in mb-2" event={item} /> <FeedEventItem
className="animate-fade-in mb-2"
event={item}
actions={
<Button
size="icon"
variant="secondary"
className="absolute right-0 top-0 h-6 w-6 overflow-hidden"
onClick={() => handleArchive(item)}
>
<LuArchive size={12} />
</Button>
}
/>
)} )}
renderEmpty={() => ( renderEmpty={() => (
<div className="w-full overflow-hidden p-4"> <div className="w-full overflow-hidden p-4">

View File

@ -173,6 +173,7 @@ export const feedRouter = router({
channelId: z.string(), channelId: z.string(),
limit: z.number().min(1).max(100).default(50), limit: z.number().min(1).max(100).default(50),
cursor: z.string().optional(), cursor: z.string().optional(),
archived: z.boolean().default(false),
}) })
) )
.output( .output(
@ -182,12 +183,12 @@ export const feedRouter = router({
}) })
) )
.query(async ({ input }) => { .query(async ({ input }) => {
const { channelId, cursor, limit } = input; const { channelId, cursor, limit, archived } = input;
const { items, nextCursor } = await fetchDataByCursor(prisma.feedEvent, { const { items, nextCursor } = await fetchDataByCursor(prisma.feedEvent, {
where: { where: {
channelId, channelId,
archived: false, archived,
}, },
limit, limit,
cursor, cursor,
@ -362,6 +363,31 @@ export const feedRouter = router({
}, },
}); });
}), }),
clearAllArchivedEvents: workspaceOwnerProcedure
.meta(
buildFeedPublicOpenapi({
method: 'PATCH',
path: '/{channelId}/clearAllArchivedEvents',
})
)
.input(
z.object({
channelId: z.string(),
})
)
.output(z.number())
.mutation(async ({ input }) => {
const { channelId } = input;
const res = await prisma.feedEvent.deleteMany({
where: {
channelId,
archived: true,
},
});
return res.count;
}),
integration: feedIntegrationRouter, integration: feedIntegrationRouter,
}); });