diff --git a/.env.private.example b/.env.private.example index 676cd7d2..f7472033 100644 --- a/.env.private.example +++ b/.env.private.example @@ -3,6 +3,9 @@ NODE_ENV=dev # obviously, set to prod in prod. ENCRYPTION_KEY=boop # generate using node -e "console.log(require('crypto').randomBytes(32).toString('base64'));" OAUTH_DISCORD_CLIENT_SECRET=73tI-EaLVgtkrcgpBawzmOfdNpY_4ME7 +# don't touch. overwrites the one in .env.public, which prisma reads +DATABASE_URL=postgres://chatsift:admin@postgres:5432/chatsift + LOCAL_DATABASE_PORT=5432 LOCAL_DOZZLE_PORT=8080 diff --git a/.env.public b/.env.public index 6ab22ae9..0fac77ea 100644 --- a/.env.public +++ b/.env.public @@ -1,7 +1,7 @@ ROOT_DOMAIN=automoderator.app ADMINS=223703707118731264 -DATABASE_URL=postgres://chatsift:admin@postgres:5432/chatsift +DATABASE_URL=postgres://chatsift:admin@127.0.0.1:5432/chatsift REDIS_URL=redis://redis:6379 API_PORT=9876 diff --git a/apps/website/package.json b/apps/website/package.json index b139709c..e7da0f55 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@chatsift/core": "workspace:^", + "@chatsift/discord-utils": "workspace:^", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-navigation-menu": "^1.2.14", diff --git a/apps/website/src/app/dashboard/[id]/ama/amas/_components/AMASessionCard.tsx b/apps/website/src/app/dashboard/[id]/ama/amas/_components/AMASessionCard.tsx new file mode 100644 index 00000000..22ec1396 --- /dev/null +++ b/apps/website/src/app/dashboard/[id]/ama/amas/_components/AMASessionCard.tsx @@ -0,0 +1,31 @@ +import type { AMASessionWithCount } from '@chatsift/api'; +import Link from 'next/link'; + +interface AMASessionCardProps { + readonly data: AMASessionWithCount; +} + +export function AMASessionCard({ data }: AMASessionCardProps) { + return ( + +
+

+ {data.title} +

+

+ {data.questionCount} {data.questionCount === 1 ? 'question' : 'questions'} +

+
+
+ + {data.ended ? 'Ended' : 'Active'} + +
+ + ); +} diff --git a/apps/website/src/app/dashboard/[id]/ama/amas/_components/AMASessionsList.tsx b/apps/website/src/app/dashboard/[id]/ama/amas/_components/AMASessionsList.tsx new file mode 100644 index 00000000..eecdb687 --- /dev/null +++ b/apps/website/src/app/dashboard/[id]/ama/amas/_components/AMASessionsList.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useParams, useSearchParams } from 'next/navigation'; +import { useMemo } from 'react'; +import { AMASessionCard } from './AMASessionCard'; +import { CreateAMACard } from './CreateAMACard'; +import { client } from '@/data/client'; + +export function AMASessionsList() { + const params = useParams<{ id: string }>(); + const { data: sessions } = client.guilds.ama.useAMAs(params.id, { include_ended: 'false' }); + const searchParams = useSearchParams(); + + const searchQuery = searchParams.get('search') ?? ''; + + const filtered = useMemo(() => { + if (!sessions?.length) { + return []; + } + + const lower = searchQuery.toLowerCase(); + return sessions.filter((session) => session.title.toLowerCase().includes(lower)); + }, [sessions, searchQuery]); + + return ( + + ); +} diff --git a/apps/website/src/app/dashboard/[id]/ama/amas/_components/CreateAMACard.tsx b/apps/website/src/app/dashboard/[id]/ama/amas/_components/CreateAMACard.tsx new file mode 100644 index 00000000..696d178a --- /dev/null +++ b/apps/website/src/app/dashboard/[id]/ama/amas/_components/CreateAMACard.tsx @@ -0,0 +1,19 @@ +'use client'; + +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { SvgPlus } from '@/components/icons/SvgPlus'; + +export function CreateAMACard() { + const params = useParams<{ id: string }>(); + + return ( + + + Create AMA + + ); +} diff --git a/apps/website/src/app/dashboard/[id]/ama/amas/layout.tsx b/apps/website/src/app/dashboard/[id]/ama/amas/layout.tsx new file mode 100644 index 00000000..bd007449 --- /dev/null +++ b/apps/website/src/app/dashboard/[id]/ama/amas/layout.tsx @@ -0,0 +1,12 @@ +import { HydrationBoundary } from '@tanstack/react-query'; +import { server } from '@/data/server'; + +export default async function AMAsLayout({ children, params }: LayoutProps<'/dashboard/[id]/ama/amas'>) { + const { id } = await params; + + return ( + + {children} + + ); +} diff --git a/apps/website/src/app/dashboard/[id]/ama/amas/new/_components/CreateAMAForm.tsx b/apps/website/src/app/dashboard/[id]/ama/amas/new/_components/CreateAMAForm.tsx new file mode 100644 index 00000000..59df6114 --- /dev/null +++ b/apps/website/src/app/dashboard/[id]/ama/amas/new/_components/CreateAMAForm.tsx @@ -0,0 +1,330 @@ +'use client'; + +import type { CreateAMABody } from '@chatsift/api'; +import { ChannelType } from 'discord-api-types/v10'; +import { useParams, useRouter } from 'next/navigation'; +import { useState } from 'react'; +import { NormalPromptFields } from './NormalPromptFields'; +import { PromptModeToggle } from './PromptModeToggle'; +import { RawPromptField } from './RawPromptField'; +import { Button } from '@/components/common/Button'; +import { ChannelSelect, threadTypes } from '@/components/common/ChannelSelect'; +import { Skeleton } from '@/components/common/Skeleton'; +import { client } from '@/data/client'; +import { APIError } from '@/utils/fetcher'; + +interface FormData { + answersChannelId: string; + description: string; + flaggedQueueId: string; + guestQueueId: string; + imageURL: string; + modQueueId: string; + plainText: string; + promptChannelId: string; + promptRaw: string; + thumbnailURL: string; + title: string; +} + +type FormErrors = Partial>; + +function validateURL(value: string): string | undefined { + if (!value) return undefined; + + try { + new URL(value); + return undefined; + } catch { + return 'Must be a valid URL'; + } +} + +const allowedChannelTypes = [ChannelType.GuildText, ...threadTypes]; + +export function CreateAMAForm() { + const router = useRouter(); + const params = useParams<{ id: string }>(); + const { id: guildId } = params; + + const { data: guildInfo, isLoading } = client.guilds.useInfo(guildId, { for_bot: 'AMA', force_fresh: 'false' }); + const createAMA = client.guilds.ama.useCreateAMA(guildId); + + const [promptMode, setPromptMode] = useState<'normal' | 'raw'>('normal'); + const [formData, setFormData] = useState({ + title: '', + answersChannelId: '', + promptChannelId: '', + modQueueId: '', + flaggedQueueId: '', + guestQueueId: '', + description: '', + plainText: '', + imageURL: '', + thumbnailURL: '', + promptRaw: '', + }); + const [errors, setErrors] = useState({}); + const [generalError, setGeneralError] = useState(null); + + const updateFormData = (field: keyof FormData, value: string | undefined) => { + setFormData((prev) => ({ ...prev, [field]: value })); + setErrors((prev) => ({ ...prev, [field]: undefined })); + }; + + const validateForm = (): boolean => { + const newErrors: FormErrors = {}; + + if (!formData.title.trim()) { + newErrors.title = 'This field is required'; + } else if (formData.title.length > 255) { + newErrors.title = 'Title must be at most 255 characters'; + } + + if (!formData.answersChannelId) { + newErrors.answersChannelId = 'This field is required'; + } + + if (!formData.promptChannelId) { + newErrors.promptChannelId = 'This field is required'; + } + + // Normal mode validations + if (promptMode === 'normal') { + if (formData.description && formData.description.length > 4_000) { + newErrors.description = 'Description must be at most 4000 characters'; + } + + if (formData.plainText && formData.plainText.length > 100) { + newErrors.plainText = 'Plain text must be at most 100 characters'; + } + + const imageURLError = validateURL(formData.imageURL); + if (imageURLError) newErrors.imageURL = imageURLError; + + const thumbnailURLError = validateURL(formData.thumbnailURL); + if (thumbnailURLError) newErrors.thumbnailURL = thumbnailURLError; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setGeneralError(null); + + try { + const body = { + title: formData.title, + answersChannelId: formData.answersChannelId, + promptChannelId: formData.promptChannelId, + modQueueId: formData.modQueueId || null, + flaggedQueueId: formData.flaggedQueueId || null, + guestQueueId: formData.guestQueueId || null, + } as CreateAMABody; + + if (promptMode === 'raw') { + (body as any).prompt_raw = JSON.parse(formData.promptRaw); + } else { + (body as any).prompt = { + description: formData.description || undefined, + plainText: formData.plainText || undefined, + imageURL: formData.imageURL || undefined, + thumbnailURL: formData.thumbnailURL || undefined, + }; + } + + await createAMA.mutateAsync(body); + router.replace(`/dashboard/${guildId}/ama/amas`); + } catch (error) { + if (error instanceof APIError && error.payload.statusCode === 422) { + if (promptMode === 'raw') { + setGeneralError('Invalid prompt data. Please check your JSON data and try again.'); + } else { + setGeneralError( + 'An unknown validation error occured. Please contact support in regards to this error. For the tech savvy, the request response includes more information.', + ); + } + + return; + } + + setGeneralError('An unknown error occurred. Please try again later.'); + console.error('Failed to create AMA:', error); + } + }; + + const handlePaste = (e: React.ClipboardEvent) => { + const pastedText = e.clipboardData.getData('text'); + + try { + const parsed = JSON.parse(pastedText); + const formatted = JSON.stringify(parsed, null, 2); + + e.preventDefault(); + + updateFormData('promptRaw', formatted); + } catch { + // Not valid JSON, let default paste happen + } + }; + + if (isLoading) { + return ( +
+
+ + + + + + + +
+
+ + +
+
+ ); + } + + return ( +
+ {generalError &&

{generalError}

} + + {/* Base Fields */} +
+

Session Details

+
+ + updateFormData('title', e.target.value)} + placeholder="AMA with renowed JP VA John Doe" + type="text" + value={formData.title} + /> + {errors.title &&

{errors.title}

} +
+ updateFormData('answersChannelId', value)} + placeholder="Select the channel where answers will be posted" + required + selectedId="answersChannelId" + value={formData.answersChannelId} + />{' '} + updateFormData('promptChannelId', value)} + placeholder="Select the channel where the prompt will be posted" + required + selectedId="promptChannelId" + value={formData.promptChannelId} + />{' '} + updateFormData('modQueueId', value)} + placeholder="Select a channel for mod queue" + selectedId="modQueueId" + value={formData.modQueueId} + />{' '} + updateFormData('flaggedQueueId', value)} + placeholder="Select a channel for flagged questions" + selectedId="flaggedQueueId" + value={formData.flaggedQueueId} + />{' '} + updateFormData('guestQueueId', value)} + placeholder="Select a channel for guest queue" + selectedId="guestQueueId" + value={formData.guestQueueId} + /> +
+ + {/* Prompt Mode Selection */} +
+

Prompt Configuration

+ + + + {promptMode === 'normal' && ( + updateFormData('description', value)} + onImageURLChange={(value) => updateFormData('imageURL', value)} + onPlainTextChange={(value) => updateFormData('plainText', value)} + onThumbnailURLChange={(value) => updateFormData('thumbnailURL', value)} + plainText={formData.plainText} + thumbnailURL={formData.thumbnailURL} + /> + )} + + {promptMode === 'raw' && ( + { + try { + const parsed = JSON.parse(formData.promptRaw); + updateFormData('promptRaw', JSON.stringify(parsed, null, 2)); + } catch { + // Invalid JSON, ignore + } + }} + onPaste={handlePaste} + onValueChange={(value) => updateFormData('promptRaw', value)} + value={formData.promptRaw} + /> + )} +
+ + {/* Submit Button */} +
+ + +
+
+ ); +} diff --git a/apps/website/src/app/dashboard/[id]/ama/amas/new/_components/NormalPromptFields.tsx b/apps/website/src/app/dashboard/[id]/ama/amas/new/_components/NormalPromptFields.tsx new file mode 100644 index 00000000..d1608418 --- /dev/null +++ b/apps/website/src/app/dashboard/[id]/ama/amas/new/_components/NormalPromptFields.tsx @@ -0,0 +1,97 @@ +interface NormalPromptFieldsProps { + readonly description: string; + readonly errors: { + readonly description?: string; + readonly imageURL?: string; + readonly plainText?: string; + readonly thumbnailURL?: string; + }; + readonly imageURL: string; + onDescriptionChange(value: string): void; + onImageURLChange(value: string): void; + onPlainTextChange(value: string): void; + onThumbnailURLChange(value: string): void; + readonly plainText: string; + readonly thumbnailURL: string; +} + +export function NormalPromptFields({ + plainText, + description, + imageURL, + thumbnailURL, + errors, + onPlainTextChange, + onDescriptionChange, + onImageURLChange, + onThumbnailURLChange, +}: NormalPromptFieldsProps) { + return ( +
+
+ + onPlainTextChange(e.target.value)} + placeholder="Message content above the embed" + type="text" + value={plainText} + /> + {errors.plainText &&

{errors.plainText}

} +
+ +
+ +