feat: add feed page

This commit is contained in:
moonrailgun 2024-06-24 22:39:36 +08:00
parent f459c6beea
commit 96a5a33ad6
16 changed files with 923 additions and 4 deletions

View File

@ -1167,6 +1167,45 @@ export class FeedService {
});
}
/**
* @param data The data for the request.
* @param data.workspaceId
* @param data.channelId
* @returns unknown Successful response
* @throws ApiError
*/
public static feedChannelInfo(data: $OpenApiTs['/workspace/{workspaceId}/feed/{channelId}/info']['get']['req']): CancelablePromise<$OpenApiTs['/workspace/{workspaceId}/feed/{channelId}/info']['get']['res'][200]> {
return __request(OpenAPI, {
method: 'GET',
url: '/workspace/{workspaceId}/feed/{channelId}/info',
path: {
workspaceId: data.workspaceId,
channelId: data.channelId
}
});
}
/**
* @param data The data for the request.
* @param data.workspaceId
* @param data.channelId
* @param data.requestBody
* @returns unknown Successful response
* @throws ApiError
*/
public static feedUpdateChannelInfo(data: $OpenApiTs['/workspace/{workspaceId}/feed/{channelId}/update']['post']['req']): CancelablePromise<$OpenApiTs['/workspace/{workspaceId}/feed/{channelId}/update']['post']['res'][200]> {
return __request(OpenAPI, {
method: 'POST',
url: '/workspace/{workspaceId}/feed/{channelId}/update',
path: {
workspaceId: data.workspaceId,
channelId: data.channelId
},
body: data.requestBody,
mediaType: 'application/json'
});
}
/**
* @param data The data for the request.
* @param data.workspaceId
@ -1185,4 +1224,60 @@ export class FeedService {
});
}
/**
* @param data The data for the request.
* @param data.workspaceId
* @param data.requestBody
* @returns unknown Successful response
* @throws ApiError
*/
public static feedCreateChannel(data: $OpenApiTs['/workspace/{workspaceId}/feed/createChannel']['post']['req']): CancelablePromise<$OpenApiTs['/workspace/{workspaceId}/feed/createChannel']['post']['res'][200]> {
return __request(OpenAPI, {
method: 'POST',
url: '/workspace/{workspaceId}/feed/createChannel',
path: {
workspaceId: data.workspaceId
},
body: data.requestBody,
mediaType: 'application/json'
});
}
/**
* @param data The data for the request.
* @param data.workspaceId
* @param data.channelId
* @returns unknown Successful response
* @throws ApiError
*/
public static feedDeleteChannel(data: $OpenApiTs['/workspace/{workspaceId}/feed/{channelId}']['delete']['req']): CancelablePromise<$OpenApiTs['/workspace/{workspaceId}/feed/{channelId}']['delete']['res'][200]> {
return __request(OpenAPI, {
method: 'DELETE',
url: '/workspace/{workspaceId}/feed/{channelId}',
path: {
workspaceId: data.workspaceId,
channelId: data.channelId
}
});
}
/**
* @param data The data for the request.
* @param data.channelId
* @param data.requestBody
* @returns unknown Successful response
* @throws ApiError
*/
public static feedSendEvent(data: $OpenApiTs['/feed/{channelId}/send']['post']['req']): CancelablePromise<$OpenApiTs['/feed/{channelId}/send']['post']['res'][200]> {
return __request(OpenAPI, {
method: 'POST',
url: '/feed/{channelId}/send',
path: {
channelId: data.channelId
},
body: data.requestBody,
mediaType: 'application/json'
});
}
}

View File

@ -1397,6 +1397,49 @@ export type $OpenApiTs = {
};
};
};
'/workspace/{workspaceId}/feed/{channelId}/info': {
get: {
req: {
channelId: string;
workspaceId: string;
};
res: {
/**
* Successful response
*/
200: {
id: string;
workspaceId: string;
name: string;
createdAt: string;
updatedAt: string;
} | null;
};
};
};
'/workspace/{workspaceId}/feed/{channelId}/update': {
post: {
req: {
channelId: string;
requestBody: {
name: string;
};
workspaceId: string;
};
res: {
/**
* Successful response
*/
200: {
id: string;
workspaceId: string;
name: string;
createdAt: string;
updatedAt: string;
} | null;
};
};
};
'/workspace/{workspaceId}/feed/{channelId}/events': {
get: {
req: {
@ -1423,4 +1466,80 @@ export type $OpenApiTs = {
};
};
};
'/workspace/{workspaceId}/feed/createChannel': {
post: {
req: {
requestBody: {
name: string;
};
workspaceId: string;
};
res: {
/**
* Successful response
*/
200: {
id: string;
workspaceId: string;
name: string;
createdAt: string;
updatedAt: string;
};
};
};
};
'/workspace/{workspaceId}/feed/{channelId}': {
delete: {
req: {
channelId: string;
workspaceId: string;
};
res: {
/**
* Successful response
*/
200: {
id: string;
workspaceId: string;
name: string;
createdAt: string;
updatedAt: string;
};
};
};
};
'/feed/{channelId}/send': {
post: {
req: {
channelId: string;
requestBody: {
eventName: string;
eventContent: string;
tags: Array<(string)>;
source: string;
senderId?: string | null;
senderName?: string | null;
important: boolean;
};
};
res: {
/**
* Successful response
*/
200: {
id: string;
channelId: string;
createdAt: string;
updatedAt: string;
eventName: string;
eventContent: string;
tags: Array<(string)>;
source: string;
senderId?: string | null;
senderName?: string | null;
important: boolean;
};
};
};
};
};

View File

@ -4,6 +4,7 @@ import { Button } from './ui/button';
import { LuCopy, LuCopyCheck } from 'react-icons/lu';
import { toast } from 'sonner';
import { useTranslation } from '@i18next-toolkit/react';
import { ScrollBar } from './ui/scroll-area';
export const CodeBlock: React.FC<{
code: string;
@ -20,7 +21,7 @@ export const CodeBlock: React.FC<{
});
return (
<div className="group relative overflow-auto">
<div className="group relative w-full overflow-auto">
<pre className="rounded-sm border border-zinc-800 bg-zinc-900 p-3 pr-12 text-sm">
<code>{props.code}</code>
</pre>

View File

@ -0,0 +1,36 @@
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import React from 'react';
import { CodeBlock } from '../CodeBlock';
import { useTranslation } from '@i18next-toolkit/react';
export const FeedApiGuide: React.FC<{ channelId: string }> = React.memo(
(props) => {
const { t } = useTranslation();
const code = `fetch('${window.location.origin}/open/feed/${props.channelId}/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
eventName: 'test name',
eventContent: 'test content',
tags: ['test'],
source: 'custom',
important: false,
})
})`;
return (
<Card className="w-full overflow-hidden">
<CardHeader>
<div>{t('You can send any message into this channel with:')}</div>
</CardHeader>
<CardContent className="flex w-full overflow-hidden">
<CodeBlock code={code} />
</CardContent>
</Card>
);
}
);
FeedApiGuide.displayName = 'FeedApiGuide';

View File

@ -0,0 +1,81 @@
import { useTranslation } from '@i18next-toolkit/react';
import { Button } from '@/components/ui/button';
import { useEventWithLoading } from '@/hooks/useEvent';
import { Card, CardContent, CardFooter } from '@/components/ui/card';
import { z } from 'zod';
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import React from 'react';
const addFormSchema = z.object({
name: z.string(),
});
export type FeedChannelEditFormValues = z.infer<typeof addFormSchema>;
interface FeedChannelEditFormProps {
defaultValues?: FeedChannelEditFormValues;
onSubmit: (values: FeedChannelEditFormValues) => Promise<void>;
}
export const FeedChannelEditForm: React.FC<FeedChannelEditFormProps> =
React.memo((props) => {
const { t } = useTranslation();
const form = useForm<FeedChannelEditFormValues>({
resolver: zodResolver(addFormSchema),
defaultValues: props.defaultValues ?? {
name: 'New Channel',
},
});
const [handleSubmit, isLoading] = useEventWithLoading(
async (values: FeedChannelEditFormValues) => {
await props.onSubmit(values);
form.reset();
}
);
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-8">
<Card>
<CardContent className="pt-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t('Channel Name')}</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
{t('Channel Name to Display')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
<CardFooter>
<Button type="submit" loading={isLoading}>
{props.defaultValues ? t('Update') : t('Create')}
</Button>
</CardFooter>
</Card>
</form>
</Form>
);
});
FeedChannelEditForm.displayName = 'FeedChannelEditForm';

View File

@ -0,0 +1,23 @@
import { AppRouterOutput } from '@/api/trpc';
import React from 'react';
import { Badge } from '../ui/badge';
type FeedEventItemType = AppRouterOutput['feed']['events'][number];
export const FeedEventItem: React.FC<{ event: FeedEventItemType }> = React.memo(
({ event }) => {
return (
<div className="border-muted rounded-lg border px-4 py-2">
<div className="mb-2">{event.eventName}</div>
<div className="flex flex-wrap gap-2">
<Badge>{event.source}</Badge>
{event.tags.map((tag) => (
<Badge variant="secondary">{tag}</Badge>
))}
</div>
</div>
);
}
);
FeedEventItem.displayName = 'FeedEventItem';

View File

@ -1,5 +1,6 @@
import * as React from 'react';
import {
LuActivitySquare,
LuAreaChart,
LuFilePieChart,
LuMonitorDot,
@ -103,6 +104,12 @@ export const DesktopLayout: React.FC<LayoutProps> = React.memo((props) => {
icon: RiSurveyLine,
to: '/survey',
},
{
title: t('Feed'),
label: '',
icon: LuActivitySquare,
to: '/feed',
},
]}
/>
<Separator />

View File

@ -1,5 +1,6 @@
import React from 'react';
import {
LuActivitySquare,
LuAreaChart,
LuFilePieChart,
LuMonitorDot,
@ -79,6 +80,12 @@ export const MobileLayout: React.FC<LayoutProps> = React.memo((props) => {
to="/survey"
extraModal={true}
/>
<MobileNavItem
title={t('Feed')}
icon={LuActivitySquare}
to="/feed"
extraModal={true}
/>
</div>
</DrawerContent>
</Drawer>

View File

@ -20,6 +20,7 @@ import { Route as RegisterImport } from './routes/register'
import { Route as PageImport } from './routes/page'
import { Route as MonitorImport } from './routes/monitor'
import { Route as LoginImport } from './routes/login'
import { Route as FeedImport } from './routes/feed'
import { Route as DashboardImport } from './routes/dashboard'
import { Route as IndexImport } from './routes/index'
import { Route as WebsiteOverviewImport } from './routes/website/overview'
@ -35,12 +36,15 @@ import { Route as SettingsAuditLogImport } from './routes/settings/auditLog'
import { Route as PageAddImport } from './routes/page/add'
import { Route as PageSlugImport } from './routes/page/$slug'
import { Route as MonitorAddImport } from './routes/monitor/add'
import { Route as FeedAddImport } from './routes/feed/add'
import { Route as WebsiteWebsiteIdIndexImport } from './routes/website/$websiteId/index'
import { Route as SurveySurveyIdIndexImport } from './routes/survey/$surveyId/index'
import { Route as MonitorMonitorIdIndexImport } from './routes/monitor/$monitorId/index'
import { Route as FeedChannelIdIndexImport } from './routes/feed/$channelId/index'
import { Route as WebsiteWebsiteIdConfigImport } from './routes/website/$websiteId/config'
import { Route as SurveySurveyIdEditImport } from './routes/survey/$surveyId/edit'
import { Route as MonitorMonitorIdEditImport } from './routes/monitor/$monitorId/edit'
import { Route as FeedChannelIdEditImport } from './routes/feed/$channelId/edit'
// Create/Update Routes
@ -89,6 +93,11 @@ const LoginRoute = LoginImport.update({
getParentRoute: () => rootRoute,
} as any)
const FeedRoute = FeedImport.update({
path: '/feed',
getParentRoute: () => rootRoute,
} as any)
const DashboardRoute = DashboardImport.update({
path: '/dashboard',
getParentRoute: () => rootRoute,
@ -164,6 +173,11 @@ const MonitorAddRoute = MonitorAddImport.update({
getParentRoute: () => MonitorRoute,
} as any)
const FeedAddRoute = FeedAddImport.update({
path: '/add',
getParentRoute: () => FeedRoute,
} as any)
const WebsiteWebsiteIdIndexRoute = WebsiteWebsiteIdIndexImport.update({
path: '/$websiteId/',
getParentRoute: () => WebsiteRoute,
@ -179,6 +193,11 @@ const MonitorMonitorIdIndexRoute = MonitorMonitorIdIndexImport.update({
getParentRoute: () => MonitorRoute,
} as any)
const FeedChannelIdIndexRoute = FeedChannelIdIndexImport.update({
path: '/$channelId/',
getParentRoute: () => FeedRoute,
} as any)
const WebsiteWebsiteIdConfigRoute = WebsiteWebsiteIdConfigImport.update({
path: '/$websiteId/config',
getParentRoute: () => WebsiteRoute,
@ -194,6 +213,11 @@ const MonitorMonitorIdEditRoute = MonitorMonitorIdEditImport.update({
getParentRoute: () => MonitorRoute,
} as any)
const FeedChannelIdEditRoute = FeedChannelIdEditImport.update({
path: '/$channelId/edit',
getParentRoute: () => FeedRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@ -206,6 +230,10 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardImport
parentRoute: typeof rootRoute
}
'/feed': {
preLoaderRoute: typeof FeedImport
parentRoute: typeof rootRoute
}
'/login': {
preLoaderRoute: typeof LoginImport
parentRoute: typeof rootRoute
@ -242,6 +270,10 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WebsiteImport
parentRoute: typeof rootRoute
}
'/feed/add': {
preLoaderRoute: typeof FeedAddImport
parentRoute: typeof FeedImport
}
'/monitor/add': {
preLoaderRoute: typeof MonitorAddImport
parentRoute: typeof MonitorImport
@ -294,6 +326,10 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WebsiteOverviewImport
parentRoute: typeof WebsiteImport
}
'/feed/$channelId/edit': {
preLoaderRoute: typeof FeedChannelIdEditImport
parentRoute: typeof FeedImport
}
'/monitor/$monitorId/edit': {
preLoaderRoute: typeof MonitorMonitorIdEditImport
parentRoute: typeof MonitorImport
@ -306,6 +342,10 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WebsiteWebsiteIdConfigImport
parentRoute: typeof WebsiteImport
}
'/feed/$channelId/': {
preLoaderRoute: typeof FeedChannelIdIndexImport
parentRoute: typeof FeedImport
}
'/monitor/$monitorId/': {
preLoaderRoute: typeof MonitorMonitorIdIndexImport
parentRoute: typeof MonitorImport
@ -326,6 +366,11 @@ declare module '@tanstack/react-router' {
export const routeTree = rootRoute.addChildren([
IndexRoute,
DashboardRoute,
FeedRoute.addChildren([
FeedAddRoute,
FeedChannelIdEditRoute,
FeedChannelIdIndexRoute,
]),
LoginRoute,
MonitorRoute.addChildren([
MonitorAddRoute,

View File

@ -0,0 +1,88 @@
import { trpc } from '@/api/trpc';
import { CommonHeader } from '@/components/CommonHeader';
import { CommonList } from '@/components/CommonList';
import { CommonWrapper } from '@/components/CommonWrapper';
import { Button } from '@/components/ui/button';
import { useDataReady } from '@/hooks/useDataReady';
import { useEvent } from '@/hooks/useEvent';
import { Layout } from '@/components/layout';
import { useCurrentWorkspaceId } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route';
import { cn } from '@/utils/style';
import { useTranslation } from '@i18next-toolkit/react';
import {
createFileRoute,
useNavigate,
useRouterState,
} from '@tanstack/react-router';
import { LuPlus } from 'react-icons/lu';
export const Route = createFileRoute('/feed')({
beforeLoad: routeAuthBeforeLoad,
component: PageComponent,
});
function PageComponent() {
const workspaceId = useCurrentWorkspaceId();
const { t } = useTranslation();
const { data: channels = [], isLoading } = trpc.feed.channels.useQuery({
workspaceId,
});
const navigate = useNavigate();
const pathname = useRouterState({
select: (state) => state.location.pathname,
});
const items = channels.map((item) => ({
id: item.id,
title: item.name,
number: item._count.events ?? 0,
href: `/feed/${item.id}`,
}));
useDataReady(
() => channels.length > 0,
() => {
if (pathname === Route.fullPath) {
navigate({
to: '/feed/$channelId',
params: {
channelId: channels[0].id,
},
});
}
}
);
const handleClickAdd = useEvent(() => {
navigate({
to: '/feed/add',
});
});
return (
<Layout
list={
<CommonWrapper
header={
<CommonHeader
title={t('Feed')}
actions={
<Button
className={cn(pathname === '/feed/add' && '!bg-muted')}
variant="outline"
Icon={LuPlus}
onClick={handleClickAdd}
>
{t('Add')}
</Button>
}
/>
}
>
<CommonList hasSearch={true} items={items} isLoading={isLoading} />
</CommonWrapper>
}
/>
);
}

View File

@ -0,0 +1,110 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useTranslation } from '@i18next-toolkit/react';
import { useEvent } from '@/hooks/useEvent';
import { useCurrentWorkspaceId } from '@/store/user';
import { defaultErrorHandler, trpc } from '@/api/trpc';
import { Card, CardContent } from '@/components/ui/card';
import { CommonWrapper } from '@/components/CommonWrapper';
import { routeAuthBeforeLoad } from '@/utils/route';
import { Loading } from '@/components/Loading';
import { ErrorTip } from '@/components/ErrorTip';
import { CommonHeader } from '@/components/CommonHeader';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
FeedChannelEditForm,
FeedChannelEditFormValues,
} from '@/components/feed/FeedChannelEditForm';
export const Route = createFileRoute('/feed/$channelId/edit')({
beforeLoad: routeAuthBeforeLoad,
component: PageComponent,
});
function PageComponent() {
const { t } = useTranslation();
const { channelId } = Route.useParams<{ channelId: string }>();
const workspaceId = useCurrentWorkspaceId();
const navigate = useNavigate();
const mutation = trpc.feed.updateChannelInfo.useMutation({
onError: defaultErrorHandler,
});
const { data: channel, isLoading } = trpc.feed.channelInfo.useQuery({
workspaceId,
channelId,
});
const trpcUtils = trpc.useUtils();
const handleSubmit = useEvent(async (values: FeedChannelEditFormValues) => {
const res = await mutation.mutateAsync({
...values,
channelId,
workspaceId,
});
trpcUtils.feed.channelInfo.setData(
{
channelId,
workspaceId,
},
res
);
trpcUtils.feed.channels.setData(
{
workspaceId,
},
(prev) => {
if (prev) {
const index = prev.findIndex((item) => item.id === channelId);
if (index >= 0) {
prev[index] = {
...prev[index],
...res,
};
}
}
return prev;
}
);
if (res) {
navigate({
to: '/feed/$channelId',
params: {
channelId: res.id,
},
replace: true,
});
} else {
navigate({
to: '/feed',
replace: true,
});
}
});
if (isLoading) {
return <Loading />;
}
if (!channel) {
return <ErrorTip />;
}
return (
<CommonWrapper
header={<CommonHeader title={channel.name} desc={t('Edit')} />}
>
<ScrollArea className="h-full overflow-hidden p-4">
<Card>
<CardContent className="pt-4">
<FeedChannelEditForm
defaultValues={channel}
onSubmit={handleSubmit}
/>
</CardContent>
</Card>
</ScrollArea>
</CommonWrapper>
);
}

View File

@ -0,0 +1,103 @@
import {
AppRouterOutput,
defaultErrorHandler,
defaultSuccessHandler,
trpc,
} from '@/api/trpc';
import { CommonHeader } from '@/components/CommonHeader';
import { CommonWrapper } from '@/components/CommonWrapper';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useCurrentWorkspaceId } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route';
import { useTranslation } from '@i18next-toolkit/react';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useEvent } from '@/hooks/useEvent';
import { AlertConfirm } from '@/components/AlertConfirm';
import { LuPencil, LuTrash } from 'react-icons/lu';
import { Button } from '@/components/ui/button';
import { Scrollbar } from '@radix-ui/react-scroll-area';
import { Empty } from 'antd';
import { FeedApiGuide } from '@/components/feed/FeedApiGuide';
import { FeedEventItem } from '@/components/feed/FeedEventItem';
export const Route = createFileRoute('/feed/$channelId/')({
beforeLoad: routeAuthBeforeLoad,
component: PageComponent,
});
function PageComponent() {
const { channelId } = Route.useParams<{ channelId: string }>();
const workspaceId = useCurrentWorkspaceId();
const { t } = useTranslation();
const { data: info } = trpc.feed.channelInfo.useQuery({
workspaceId,
channelId,
});
const { data: events } = trpc.feed.events.useQuery({
workspaceId,
channelId,
});
const deleteMutation = trpc.feed.deleteChannel.useMutation({
onSuccess: defaultSuccessHandler,
onError: defaultErrorHandler,
});
const trpcUtils = trpc.useUtils();
const navigate = useNavigate();
const handleDelete = useEvent(async () => {
await deleteMutation.mutateAsync({ workspaceId, channelId });
trpcUtils.feed.channels.refetch();
navigate({
to: '/feed',
replace: true,
});
});
return (
<CommonWrapper
header={
<CommonHeader
title={info?.name ?? ''}
actions={
<div className="space-x-2">
<Button
variant="outline"
size="icon"
Icon={LuPencil}
onClick={() =>
navigate({
to: '/feed/$channelId/edit',
params: {
channelId,
},
})
}
/>
<AlertConfirm
title={t('Confirm to delete this channel?')}
description={t('All feed will be remove')}
content={t('It will permanently delete the relevant data')}
onConfirm={handleDelete}
>
<Button variant="outline" size="icon" Icon={LuTrash} />
</AlertConfirm>
</div>
}
/>
}
>
{events && events.length === 0 ? (
<div className="w-full overflow-hidden p-4">
<FeedApiGuide channelId={channelId} />
</div>
) : (
<ScrollArea className="h-full overflow-hidden p-4">
{(events ?? []).map((event) => (
<FeedEventItem key={event.id} event={event} />
))}
</ScrollArea>
)}
</CommonWrapper>
);
}

View File

@ -0,0 +1,52 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useTranslation } from '@i18next-toolkit/react';
import { useEvent } from '@/hooks/useEvent';
import { useCurrentWorkspaceId } from '@/store/user';
import { defaultErrorHandler, trpc } from '@/api/trpc';
import { CommonWrapper } from '@/components/CommonWrapper';
import { routeAuthBeforeLoad } from '@/utils/route';
import {
FeedChannelEditForm,
FeedChannelEditFormValues,
} from '@/components/feed/FeedChannelEditForm';
export const Route = createFileRoute('/feed/add')({
beforeLoad: routeAuthBeforeLoad,
component: PageComponent,
});
function PageComponent() {
const { t } = useTranslation();
const workspaceId = useCurrentWorkspaceId();
const createMutation = trpc.feed.createChannel.useMutation({
onError: defaultErrorHandler,
});
const utils = trpc.useUtils();
const navigate = useNavigate();
const onSubmit = useEvent(async (values: FeedChannelEditFormValues) => {
const res = await createMutation.mutateAsync({
workspaceId,
name: values.name,
});
utils.feed.channels.refetch();
navigate({
to: '/feed/$channelId',
params: {
channelId: res.id,
},
});
});
return (
<CommonWrapper
header={<h1 className="text-xl font-bold">{t('Add Channel')}</h1>}
>
<div className="p-4">
<FeedChannelEditForm onSubmit={onSubmit} />
</div>
</CommonWrapper>
);
}

View File

@ -9,7 +9,7 @@ import { Layout } from '@/components/layout';
import { useCurrentWorkspaceId } from '@/store/user';
import { routeAuthBeforeLoad } from '@/utils/route';
import { cn } from '@/utils/style';
import { Trans, useTranslation } from '@i18next-toolkit/react';
import { useTranslation } from '@i18next-toolkit/react';
import {
createFileRoute,
useNavigate,

View File

@ -1,5 +1,11 @@
import { z } from 'zod';
import { OpenApiMetaInfo, router, workspaceProcedure } from '../trpc';
import {
OpenApiMetaInfo,
publicProcedure,
router,
workspaceOwnerProcedure,
workspaceProcedure,
} from '../trpc';
import { OPENAPI_TAG } from '../../utils/const';
import { OpenApiMeta } from 'trpc-openapi';
import { FeedChannelModelSchema, FeedEventModelSchema } from '../../prisma/zod';
@ -43,6 +49,65 @@ export const feedRouter = router({
return channels;
}),
channelInfo: workspaceProcedure
.meta(
buildFeedOpenapi({
method: 'GET',
path: '/{channelId}/info',
})
)
.input(
z.object({
channelId: z.string(),
})
)
.output(FeedChannelModelSchema.nullable())
.query(async ({ input }) => {
const { channelId, workspaceId } = input;
const channel = prisma.feedChannel.findFirst({
where: {
workspaceId,
id: channelId,
},
});
return channel;
}),
updateChannelInfo: workspaceProcedure
.meta(
buildFeedOpenapi({
method: 'POST',
path: '/{channelId}/update',
})
)
.input(
z
.object({
channelId: z.string(),
})
.merge(
FeedChannelModelSchema.pick({
name: true,
})
)
)
.output(FeedChannelModelSchema.nullable())
.mutation(async ({ input }) => {
const { channelId, workspaceId, name } = input;
const channel = prisma.feedChannel.update({
where: {
workspaceId,
id: channelId,
},
data: {
name,
},
});
return channel;
}),
events: workspaceProcedure
.meta(
buildFeedOpenapi({
@ -67,6 +132,93 @@ export const feedRouter = router({
return events;
}),
createChannel: workspaceOwnerProcedure
.meta(
buildFeedOpenapi({
method: 'POST',
path: '/createChannel',
})
)
.input(
FeedChannelModelSchema.pick({
name: true,
})
)
.output(FeedChannelModelSchema)
.mutation(async ({ input }) => {
const { name, workspaceId } = input;
const channel = await prisma.feedChannel.create({
data: {
workspaceId,
name,
},
});
return channel;
}),
deleteChannel: workspaceOwnerProcedure
.meta(
buildFeedOpenapi({
method: 'DELETE',
path: '/{channelId}',
})
)
.input(
z.object({
channelId: z.string(),
})
)
.output(FeedChannelModelSchema)
.mutation(async ({ input }) => {
const { channelId, workspaceId } = input;
const channel = await prisma.feedChannel.delete({
where: {
workspaceId,
id: channelId,
},
});
return channel;
}),
sendEvent: publicProcedure
.meta({
openapi: {
tags: [OPENAPI_TAG.FEED],
protect: false,
method: 'POST',
path: '/feed/{channelId}/send',
},
})
.input(
FeedEventModelSchema.pick({
eventName: true,
eventContent: true,
tags: true,
source: true,
senderId: true,
senderName: true,
important: true,
}).merge(
z.object({
channelId: z.string(),
})
)
)
.output(FeedEventModelSchema)
.mutation(async ({ input }) => {
const { channelId, ...data } = input;
const event = await prisma.feedEvent.create({
data: {
...data,
channelId: channelId,
},
});
return event;
}),
});
function buildFeedOpenapi(meta: OpenApiMetaInfo): OpenApiMeta {

File diff suppressed because one or more lines are too long