Skip to content

Commit 12c18e5

Browse files
committed
feat: ama creation form
1 parent 6a4a41f commit 12c18e5

6 files changed

Lines changed: 500 additions & 1 deletion

File tree

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
'use client';
2+
3+
import type { CreateAMABody } from '@chatsift/api';
4+
import { useParams, useRouter } from 'next/navigation';
5+
import { useState } from 'react';
6+
import { NormalPromptFields } from './NormalPromptFields';
7+
import { PromptModeToggle } from './PromptModeToggle';
8+
import { RawPromptField } from './RawPromptField';
9+
import { SnowflakeInput } from './SnowflakeInput';
10+
import { Button } from '@/components/common/Button';
11+
import { client } from '@/data/client';
12+
import { APIError } from '@/utils/fetcher';
13+
14+
type PromptMode = 'normal' | 'raw';
15+
16+
interface FormData {
17+
answersChannelId: string;
18+
description: string;
19+
flaggedQueueId: string;
20+
guestQueueId: string;
21+
imageURL: string;
22+
modQueueId: string;
23+
plainText: string;
24+
promptChannelId: string;
25+
promptRaw: string;
26+
thumbnailURL: string;
27+
title: string;
28+
}
29+
30+
type FormErrors = Partial<Record<keyof FormData, string>>;
31+
32+
const SNOWFLAKE_REGEX = /^\d{17,20}$/;
33+
34+
function validateSnowflake(value: string, required: boolean = true): string | undefined {
35+
if (!value) {
36+
return required ? 'This field is required' : undefined;
37+
}
38+
39+
if (!SNOWFLAKE_REGEX.test(value)) {
40+
return 'Must be a valid Discord ID (17-20 digits)';
41+
}
42+
43+
return undefined;
44+
}
45+
46+
function validateURL(value: string): string | undefined {
47+
if (!value) return undefined;
48+
49+
try {
50+
new URL(value);
51+
return undefined;
52+
} catch {
53+
return 'Must be a valid URL';
54+
}
55+
}
56+
57+
export function CreateAMAForm() {
58+
const router = useRouter();
59+
const params = useParams<{ id: string }>();
60+
const { id: guildId } = params;
61+
62+
const [promptMode, setPromptMode] = useState<PromptMode>('normal');
63+
const [formData, setFormData] = useState<FormData>({
64+
title: '',
65+
answersChannelId: '',
66+
promptChannelId: '',
67+
modQueueId: '',
68+
flaggedQueueId: '',
69+
guestQueueId: '',
70+
description: '',
71+
plainText: '',
72+
imageURL: '',
73+
thumbnailURL: '',
74+
promptRaw: '',
75+
});
76+
const [errors, setErrors] = useState<FormErrors>({});
77+
78+
const createAMA = client.guilds.ama.createAMA(guildId);
79+
80+
const validateForm = (): boolean => {
81+
const newErrors: FormErrors = {};
82+
83+
if (!formData.title.trim()) {
84+
newErrors.title = 'Title is required';
85+
} else if (formData.title.length > 255) {
86+
newErrors.title = 'Title must be at most 255 characters';
87+
}
88+
89+
const answersChannelError = validateSnowflake(formData.answersChannelId);
90+
if (answersChannelError) newErrors.answersChannelId = answersChannelError;
91+
92+
const promptChannelError = validateSnowflake(formData.promptChannelId);
93+
if (promptChannelError) newErrors.promptChannelId = promptChannelError;
94+
95+
// Optional snowflake fields
96+
const modQueueError = validateSnowflake(formData.modQueueId, false);
97+
if (modQueueError) newErrors.modQueueId = modQueueError;
98+
99+
const flaggedQueueError = validateSnowflake(formData.flaggedQueueId, false);
100+
if (flaggedQueueError) newErrors.flaggedQueueId = flaggedQueueError;
101+
102+
const guestQueueError = validateSnowflake(formData.guestQueueId, false);
103+
if (guestQueueError) newErrors.guestQueueId = guestQueueError;
104+
105+
// Normal mode validations
106+
if (promptMode === 'normal') {
107+
if (formData.description && formData.description.length > 4_000) {
108+
newErrors.description = 'Description must be at most 4000 characters';
109+
}
110+
111+
if (formData.plainText && formData.plainText.length > 100) {
112+
newErrors.plainText = 'Plain text must be at most 100 characters';
113+
}
114+
115+
const imageURLError = validateURL(formData.imageURL);
116+
if (imageURLError) newErrors.imageURL = imageURLError;
117+
118+
const thumbnailURLError = validateURL(formData.thumbnailURL);
119+
if (thumbnailURLError) newErrors.thumbnailURL = thumbnailURLError;
120+
}
121+
122+
setErrors(newErrors);
123+
return Object.keys(newErrors).length === 0;
124+
};
125+
126+
const handleSubmit = async (e: React.FormEvent) => {
127+
e.preventDefault();
128+
129+
if (!validateForm()) {
130+
return;
131+
}
132+
133+
try {
134+
const body = {
135+
title: formData.title,
136+
answersChannelId: formData.answersChannelId,
137+
promptChannelId: formData.promptChannelId,
138+
modQueueId: formData.modQueueId || null,
139+
flaggedQueueId: formData.flaggedQueueId || null,
140+
guestQueueId: formData.guestQueueId || null,
141+
} as CreateAMABody;
142+
143+
if (promptMode === 'raw') {
144+
(body as any).prompt_raw = JSON.parse(formData.promptRaw);
145+
} else {
146+
(body as any).prompt = {
147+
description: formData.description || undefined,
148+
plainText: formData.plainText || undefined,
149+
imageURL: formData.imageURL || undefined,
150+
thumbnailURL: formData.thumbnailURL || undefined,
151+
};
152+
}
153+
154+
await createAMA.mutateAsync(body);
155+
router.replace(`/dashboard/${guildId}/ama/amas`);
156+
} catch (error) {
157+
if (error instanceof APIError && error.payload.statusCode === 400) {
158+
console.error('Invalid prompt_raw data:', error);
159+
} else {
160+
console.error('Failed to create AMA:', error);
161+
}
162+
}
163+
};
164+
165+
const formatJSON = () => {
166+
try {
167+
const parsed = JSON.parse(formData.promptRaw);
168+
setFormData({ ...formData, promptRaw: JSON.stringify(parsed, null, 2) });
169+
} catch {
170+
// Invalid JSON, ignore
171+
}
172+
};
173+
174+
// TODO
175+
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
176+
setTimeout(() => formatJSON(), 50);
177+
};
178+
179+
return (
180+
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
181+
{/* Base Fields */}
182+
<div className="space-y-4">
183+
<h2 className="text-xl font-medium text-primary dark:text-primary-dark">Session Details</h2>
184+
185+
<div>
186+
<label className="block text-sm font-medium text-secondary dark:text-secondary-dark mb-2" htmlFor="title">
187+
Title *
188+
</label>
189+
<input
190+
className="w-full px-3 py-2 border border-on-secondary dark:border-on-secondary-dark rounded-md bg-card dark:bg-card-dark text-primary dark:text-primary-dark focus:outline-none focus:ring-2 focus:ring-misc-accent focus:border-misc-accent"
191+
id="title"
192+
maxLength={255}
193+
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
194+
placeholder="My AMA Session"
195+
type="text"
196+
value={formData.title}
197+
/>
198+
{errors.title && <p className="mt-1 text-sm text-red-500">{errors.title}</p>}
199+
</div>
200+
201+
<SnowflakeInput
202+
error={errors.answersChannelId}
203+
id="answersChannelId"
204+
label="Answers Channel ID"
205+
onChange={(value) => setFormData({ ...formData, answersChannelId: value })}
206+
required
207+
value={formData.answersChannelId}
208+
/>
209+
210+
<SnowflakeInput
211+
error={errors.promptChannelId}
212+
id="promptChannelId"
213+
label="Prompt Channel ID"
214+
onChange={(value) => setFormData({ ...formData, promptChannelId: value })}
215+
required
216+
value={formData.promptChannelId}
217+
/>
218+
219+
<SnowflakeInput
220+
error={errors.modQueueId}
221+
id="modQueueId"
222+
label="Mod Queue ID (optional)"
223+
onChange={(value) => setFormData({ ...formData, modQueueId: value })}
224+
value={formData.modQueueId}
225+
/>
226+
227+
<SnowflakeInput
228+
error={errors.flaggedQueueId}
229+
id="flaggedQueueId"
230+
label="Flagged Queue ID (optional)"
231+
onChange={(value) => setFormData({ ...formData, flaggedQueueId: value })}
232+
value={formData.flaggedQueueId}
233+
/>
234+
235+
<SnowflakeInput
236+
error={errors.guestQueueId}
237+
id="guestQueueId"
238+
label="Guest Queue ID (optional)"
239+
onChange={(value) => setFormData({ ...formData, guestQueueId: value })}
240+
value={formData.guestQueueId}
241+
/>
242+
</div>
243+
244+
{/* Prompt Mode Selection */}
245+
<div className="space-y-4">
246+
<h2 className="text-xl font-medium text-primary dark:text-primary-dark">Prompt Configuration</h2>
247+
248+
<PromptModeToggle mode={promptMode} onModeChange={setPromptMode} />
249+
250+
{promptMode === 'normal' && (
251+
<NormalPromptFields
252+
description={formData.description}
253+
errors={errors}
254+
imageURL={formData.imageURL}
255+
onDescriptionChange={(value) => setFormData({ ...formData, description: value })}
256+
onImageURLChange={(value) => setFormData({ ...formData, imageURL: value })}
257+
onPlainTextChange={(value) => setFormData({ ...formData, plainText: value })}
258+
onThumbnailURLChange={(value) => setFormData({ ...formData, thumbnailURL: value })}
259+
plainText={formData.plainText}
260+
thumbnailURL={formData.thumbnailURL}
261+
/>
262+
)}
263+
264+
{promptMode === 'raw' && (
265+
<RawPromptField
266+
onFormatClick={formatJSON}
267+
onPaste={handlePaste}
268+
onValueChange={(value) => setFormData({ ...formData, promptRaw: value })}
269+
value={formData.promptRaw}
270+
/>
271+
)}
272+
</div>
273+
274+
{/* Submit Button */}
275+
<div className="flex gap-4">
276+
<Button
277+
className="px-6 py-3 bg-misc-accent text-white rounded-md hover:opacity-90 transition-opacity"
278+
type="submit"
279+
>
280+
Create AMA Session
281+
</Button>
282+
<Button
283+
className="px-6 py-3 bg-on-tertiary dark:bg-on-tertiary-dark text-primary dark:text-primary-dark rounded-md hover:bg-on-secondary dark:hover:bg-on-secondary-dark transition-colors"
284+
onPress={() => router.back()}
285+
type="button"
286+
>
287+
Cancel
288+
</Button>
289+
</div>
290+
</form>
291+
);
292+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
interface NormalPromptFieldsProps {
2+
readonly description: string;
3+
readonly errors: {
4+
readonly description?: string;
5+
readonly imageURL?: string;
6+
readonly plainText?: string;
7+
readonly thumbnailURL?: string;
8+
};
9+
readonly imageURL: string;
10+
onDescriptionChange(value: string): void;
11+
onImageURLChange(value: string): void;
12+
onPlainTextChange(value: string): void;
13+
onThumbnailURLChange(value: string): void;
14+
readonly plainText: string;
15+
readonly thumbnailURL: string;
16+
}
17+
18+
export function NormalPromptFields({
19+
plainText,
20+
description,
21+
imageURL,
22+
thumbnailURL,
23+
errors,
24+
onPlainTextChange,
25+
onDescriptionChange,
26+
onImageURLChange,
27+
onThumbnailURLChange,
28+
}: NormalPromptFieldsProps) {
29+
return (
30+
<div className="space-y-4">
31+
<div>
32+
<label className="block text-sm font-medium text-secondary dark:text-secondary-dark mb-2" htmlFor="plainText">
33+
Plain Text (optional, max 100 characters)
34+
</label>
35+
<input
36+
className="w-full px-3 py-2 border border-on-secondary dark:border-on-secondary-dark rounded-md bg-card dark:bg-card-dark text-primary dark:text-primary-dark focus:outline-none focus:ring-2 focus:ring-misc-accent focus:border-misc-accent"
37+
id="plainText"
38+
maxLength={100}
39+
onChange={(e) => onPlainTextChange(e.target.value)}
40+
placeholder="Message content above the embed"
41+
type="text"
42+
value={plainText}
43+
/>
44+
{errors.plainText && <p className="mt-1 text-sm text-red-500">{errors.plainText}</p>}
45+
</div>
46+
47+
<div>
48+
<label className="block text-sm font-medium text-secondary dark:text-secondary-dark mb-2" htmlFor="description">
49+
Description (optional, max 4000 characters)
50+
</label>
51+
<textarea
52+
className="w-full px-3 py-2 border border-on-secondary dark:border-on-secondary-dark rounded-md bg-card dark:bg-card-dark text-primary dark:text-primary-dark focus:outline-none focus:ring-2 focus:ring-misc-accent focus:border-misc-accent"
53+
id="description"
54+
maxLength={4_000}
55+
onChange={(e) => onDescriptionChange(e.target.value)}
56+
placeholder="Embed description text"
57+
rows={4}
58+
value={description}
59+
/>
60+
{errors.description && <p className="mt-1 text-sm text-red-500">{errors.description}</p>}
61+
</div>
62+
63+
<div>
64+
<label className="block text-sm font-medium text-secondary dark:text-secondary-dark mb-2" htmlFor="imageURL">
65+
Image URL (optional)
66+
</label>
67+
<input
68+
className="w-full px-3 py-2 border border-on-secondary dark:border-on-secondary-dark rounded-md bg-card dark:bg-card-dark text-primary dark:text-primary-dark focus:outline-none focus:ring-2 focus:ring-misc-accent focus:border-misc-accent"
69+
id="imageURL"
70+
onChange={(e) => onImageURLChange(e.target.value)}
71+
placeholder="https://example.com/image.png"
72+
type="url"
73+
value={imageURL}
74+
/>
75+
{errors.imageURL && <p className="mt-1 text-sm text-red-500">{errors.imageURL}</p>}
76+
</div>
77+
78+
<div>
79+
<label
80+
className="block text-sm font-medium text-secondary dark:text-secondary-dark mb-2"
81+
htmlFor="thumbnailURL"
82+
>
83+
Thumbnail URL (optional)
84+
</label>
85+
<input
86+
className="w-full px-3 py-2 border border-on-secondary dark:border-on-secondary-dark rounded-md bg-card dark:bg-card-dark text-primary dark:text-primary-dark focus:outline-none focus:ring-2 focus:ring-misc-accent focus:border-misc-accent"
87+
id="thumbnailURL"
88+
onChange={(e) => onThumbnailURLChange(e.target.value)}
89+
placeholder="https://example.com/thumbnail.png"
90+
type="url"
91+
value={thumbnailURL}
92+
/>
93+
{errors.thumbnailURL && <p className="mt-1 text-sm text-red-500">{errors.thumbnailURL}</p>}
94+
</div>
95+
</div>
96+
);
97+
}

0 commit comments

Comments
 (0)