feat: add feed archive page
This commit is contained in:
parent
3270164710
commit
87b4000c47
136
src/client/components/feed/FeedArchivePageButton.tsx
Normal file
136
src/client/components/feed/FeedArchivePageButton.tsx
Normal 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';
|
@ -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">
|
||||||
|
@ -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">
|
||||||
|
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user