Skip to content

Commit a2de29a

Browse files
authored
feat: modmail qol (#314)
1 parent cf872fe commit a2de29a

14 files changed

Lines changed: 120 additions & 22 deletions

File tree

apps/website/src/app/dashboard/[id]/modmail/config/_components/ModmailConfigForm.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ export function ModmailConfigForm() {
267267
label="Anonymous Reply Label"
268268
maxLength={100}
269269
onChange={(value) => updateField('anonReplyLabel', value)}
270-
placeholder="{{ guildName }} Team"
270+
placeholder="{{guildName}} Team"
271271
value={form.anonReplyLabel}
272272
/>
273273

apps/website/src/app/dashboard/[id]/modmail/panels/[panelId]/_components/EditPanelForm.tsx

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ import { Skeleton } from '@/components/common/Skeleton';
1616
import { UserErrorHandler } from '@/components/user/UserErrorHandler';
1717

1818
interface FormData {
19+
attachmentUrl: string;
1920
buttonLabel: string;
2021
description: string;
2122
panelRaw: string;
2223
title: string;
2324
}
2425

25-
type FormErrors = Partial<Record<'buttonLabel' | 'categoryIds' | 'description' | 'panelRaw' | 'title', string>>;
26+
type FormErrors = Partial<
27+
Record<'attachmentUrl' | 'buttonLabel' | 'categoryIds' | 'description' | 'panelRaw' | 'title', string>
28+
>;
2629

2730
function mapIssuesToFormErrors(issues: readonly { message: string; path: PropertyKey[] }[]): FormErrors {
2831
const errors: FormErrors = {};
@@ -33,7 +36,7 @@ function mapIssuesToFormErrors(issues: readonly { message: string; path: Propert
3336
if (first === 'categoryIds') {
3437
errors.categoryIds ??= issue.message;
3538
} else if (first === 'panel' && typeof second === 'string') {
36-
if (second === 'title' || second === 'description' || second === 'buttonLabel') {
39+
if (second === 'title' || second === 'description' || second === 'buttonLabel' || second === 'attachmentUrl') {
3740
errors[second] ??= issue.message;
3841
}
3942
} else if (first === 'panel_raw') {
@@ -71,22 +74,29 @@ function prettyPrintOrRaw(value: string): string {
7174
// never fails, it just leaves fields blank for shapes it doesn't understand. Note there's no `buttonLabel` in
7275
// here at all: the button's current label lives only in the live Discord component, not in `panelJsonData`, so it
7376
// can't be recovered -- leaving it blank on submit resets it to the schema's 'Create Ticket' default.
74-
function bestEffortNormalFields(panelJsonData: string): { description: string; title: string } {
77+
function bestEffortNormalFields(panelJsonData: string): { attachmentUrl: string; description: string; title: string } {
7578
try {
7679
const parsed = JSON.parse(panelJsonData) as Record<string, unknown>;
7780
const embeds = parsed['embeds'];
7881
if (Array.isArray(embeds) && embeds.length > 0 && typeof embeds[0] === 'object' && embeds[0] !== null) {
7982
const embed = embeds[0] as Record<string, unknown>;
83+
const image = embed['image'];
84+
const attachmentUrl =
85+
typeof image === 'object' && image !== null && typeof (image as Record<string, unknown>)['url'] === 'string'
86+
? ((image as Record<string, unknown>)['url'] as string)
87+
: '';
88+
8089
return {
8190
title: typeof embed['title'] === 'string' ? embed['title'] : '',
8291
description: typeof embed['description'] === 'string' ? embed['description'] : '',
92+
attachmentUrl,
8393
};
8494
}
8595
} catch {
8696
// Not JSON, or not the expected shape -- fall through to blank fields.
8797
}
8898

89-
return { title: '', description: '' };
99+
return { title: '', description: '', attachmentUrl: '' };
90100
}
91101

92102
interface EditPanelFormProps {
@@ -129,6 +139,7 @@ export function EditPanelForm({ panel }: EditPanelFormProps) {
129139
title: formData.title,
130140
description: formData.description || undefined,
131141
buttonLabel: formData.buttonLabel || undefined,
142+
attachmentUrl: formData.attachmentUrl || undefined,
132143
},
133144
};
134145
};
@@ -180,6 +191,7 @@ export function EditPanelForm({ panel }: EditPanelFormProps) {
180191
['title', error.fieldError(panelField, 'title')],
181192
['description', error.fieldError(panelField, 'description')],
182193
['buttonLabel', error.fieldError(panelField, 'buttonLabel')],
194+
['attachmentUrl', error.fieldError(panelField, 'attachmentUrl')],
183195
];
184196

185197
const newErrors: FormErrors = Object.fromEntries(
@@ -247,9 +259,11 @@ export function EditPanelForm({ panel }: EditPanelFormProps) {
247259
<div>
248260
{mode === 'normal' ? (
249261
<PanelEmbedFields
262+
attachmentUrl={formData.attachmentUrl}
250263
buttonLabel={formData.buttonLabel}
251264
description={formData.description}
252265
errors={errors}
266+
onAttachmentUrlChange={(value) => updateFormData('attachmentUrl', value)}
253267
onButtonLabelChange={(value) => updateFormData('buttonLabel', value)}
254268
onDescriptionChange={(value) => updateFormData('description', value)}
255269
onTitleChange={(value) => updateFormData('title', value)}
@@ -277,6 +291,7 @@ export function EditPanelForm({ panel }: EditPanelFormProps) {
277291

278292
{mode === 'normal' ? (
279293
<PanelPreview
294+
attachmentUrl={formData.attachmentUrl}
280295
buttonLabel={formData.buttonLabel}
281296
description={formData.description}
282297
mode="normal"

apps/website/src/app/dashboard/[id]/modmail/panels/_components/PanelEmbedFields.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import { TextAreaField } from '@/components/common/TextAreaField';
22
import { TextField } from '@/components/common/TextField';
33

44
interface PanelEmbedFieldsProps {
5+
readonly attachmentUrl: string;
56
readonly buttonLabel: string;
67
readonly description: string;
78
readonly errors: {
9+
readonly attachmentUrl?: string;
810
readonly buttonLabel?: string;
911
readonly description?: string;
1012
readonly title?: string;
1113
};
14+
onAttachmentUrlChange(value: string): void;
1215
onButtonLabelChange(value: string): void;
1316
onDescriptionChange(value: string): void;
1417
onTitleChange(value: string): void;
@@ -19,10 +22,12 @@ export function PanelEmbedFields({
1922
title,
2023
description,
2124
buttonLabel,
25+
attachmentUrl,
2226
errors,
2327
onTitleChange,
2428
onDescriptionChange,
2529
onButtonLabelChange,
30+
onAttachmentUrlChange,
2631
}: PanelEmbedFieldsProps) {
2732
return (
2833
<div className="space-y-4">
@@ -56,6 +61,21 @@ export function PanelEmbedFields({
5661
placeholder="Create Ticket"
5762
value={buttonLabel}
5863
/>
64+
65+
<TextField
66+
error={errors.attachmentUrl}
67+
helper={
68+
<p className="mt-1 text-sm text-secondary dark:text-secondary-dark">
69+
Optional. Shown as an image on the panel embed -- must be a direct link to an image.
70+
</p>
71+
}
72+
id="panel-attachment-url"
73+
label="Image URL"
74+
onChange={onAttachmentUrlChange}
75+
placeholder="https://..."
76+
type="url"
77+
value={attachmentUrl}
78+
/>
5979
</div>
6080
);
6181
}

apps/website/src/app/dashboard/[id]/modmail/panels/_components/PanelPreview.tsx

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
'use client';
22

33
import dynamic from 'next/dynamic';
4+
import { useState } from 'react';
5+
import { Button } from '@/components/common/Button';
46
import { Skeleton } from '@/components/common/Skeleton';
57

68
// `ssr: false` is load-bearing -- see `DiscordMarkdown.tsx`'s own doc comment on why its wasm parser can't
@@ -19,6 +21,7 @@ const DiscordMarkdown = dynamic(
1921
interface PreviewEmbed {
2022
readonly color?: number | undefined;
2123
readonly description?: string | undefined;
24+
readonly imageUrl?: string | undefined;
2225
readonly title?: string | undefined;
2326
}
2427

@@ -29,6 +32,7 @@ interface PreviewResult {
2932
}
3033

3134
interface NormalPreviewProps {
35+
readonly attachmentUrl: string;
3236
readonly buttonLabel: string;
3337
readonly description: string;
3438
readonly mode: 'normal';
@@ -84,13 +88,19 @@ function parseRawPanel(raw: string): PreviewResult {
8488
}
8589

8690
const embedRecord = firstEmbed as Record<string, unknown>;
91+
const image = embedRecord['image'];
92+
const imageUrl =
93+
typeof image === 'object' && image !== null && typeof (image as Record<string, unknown>)['url'] === 'string'
94+
? ((image as Record<string, unknown>)['url'] as string)
95+
: undefined;
8796

8897
return {
8998
content,
9099
embed: {
91100
title: typeof embedRecord['title'] === 'string' ? embedRecord['title'] : undefined,
92101
description: typeof embedRecord['description'] === 'string' ? embedRecord['description'] : undefined,
93102
color: typeof embedRecord['color'] === 'number' ? embedRecord['color'] : undefined,
103+
imageUrl,
94104
},
95105
};
96106
}
@@ -104,16 +114,21 @@ function resolvePreview(props: PanelPreviewProps): PreviewResult {
104114
embed: {
105115
title: props.title || undefined,
106116
description: props.description || undefined,
117+
imageUrl: props.attachmentUrl || undefined,
107118
},
108119
};
109120
}
110121

111122
export function PanelPreview(props: PanelPreviewProps) {
112123
const { content, embed, error } = resolvePreview(props);
113-
const hasEmbedContent = Boolean(embed?.title) || Boolean(embed?.description);
124+
const hasEmbedContent = Boolean(embed?.title) || Boolean(embed?.description) || Boolean(embed?.imageUrl);
114125
// Raw-mode panels always get the fixed "Create Ticket" button server-side (see createPanel.ts) -- only
115126
// normal-mode panels have a user-configurable label.
116127
const buttonLabel = (props.mode === 'normal' && props.buttonLabel.trim()) || 'Create Ticket';
128+
// Rendering an `<img>` fetches it immediately -- gate behind an explicit click the same way
129+
// `SnippetCard` does, since this is a staff-pasted URL nobody here has vetted. Tracks *which* URL was
130+
// approved so editing to a different image always requires a fresh click.
131+
const [previewedUrl, setPreviewedUrl] = useState<string | null>(null);
117132

118133
return (
119134
<div className="rounded-md border border-on-secondary bg-[#313338] p-4 dark:border-on-secondary-dark">
@@ -143,6 +158,22 @@ export function PanelPreview(props: PanelPreviewProps) {
143158
<DiscordMarkdown content={embed.description} forBot="MODMAIL" />
144159
</div>
145160
)}
161+
{embed?.imageUrl &&
162+
(previewedUrl === embed.imageUrl ? (
163+
// eslint-disable-next-line @next/next/no-img-element -- arbitrary staff-pasted external URL, not one of the app's known image sources Next's optimizer can proxy
164+
<img
165+
alt="Panel embed"
166+
className="max-h-40 rounded-md border border-on-secondary dark:border-on-secondary-dark"
167+
src={embed.imageUrl}
168+
/>
169+
) : (
170+
<Button
171+
className="h-fit p-0 text-xs text-white/50 underline hover:bg-transparent"
172+
onPress={() => setPreviewedUrl(embed.imageUrl!)}
173+
>
174+
Show image preview
175+
</Button>
176+
))}
146177
</div>
147178
</div>
148179
)}

apps/website/src/app/dashboard/[id]/modmail/panels/new/_components/CreatePanelForm.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { Skeleton } from '@/components/common/Skeleton';
1919
import { UserErrorHandler } from '@/components/user/UserErrorHandler';
2020

2121
interface FormData {
22+
attachmentUrl: string;
2223
buttonLabel: string;
2324
channelId: string;
2425
description: string;
@@ -27,7 +28,7 @@ interface FormData {
2728
}
2829

2930
type FormErrors = Partial<
30-
Record<'buttonLabel' | 'categoryIds' | 'channelId' | 'description' | 'panelRaw' | 'title', string>
31+
Record<'attachmentUrl' | 'buttonLabel' | 'categoryIds' | 'channelId' | 'description' | 'panelRaw' | 'title', string>
3132
>;
3233

3334
const TOP_LEVEL_FIELDS = ['channelId', 'categoryIds'] as const;
@@ -41,7 +42,7 @@ function mapIssuesToFormErrors(issues: readonly { message: string; path: Propert
4142
if (typeof first === 'string' && (TOP_LEVEL_FIELDS as readonly string[]).includes(first)) {
4243
errors[first as 'categoryIds' | 'channelId'] ??= issue.message;
4344
} else if (first === 'panel' && typeof second === 'string') {
44-
if (second === 'title' || second === 'description' || second === 'buttonLabel') {
45+
if (second === 'title' || second === 'description' || second === 'buttonLabel' || second === 'attachmentUrl') {
4546
errors[second] ??= issue.message;
4647
}
4748
} else if (first === 'panel_raw') {
@@ -77,6 +78,7 @@ export function CreatePanelForm() {
7778
title: '',
7879
description: '',
7980
buttonLabel: '',
81+
attachmentUrl: '',
8082
panelRaw: '',
8183
});
8284
const [categoryIds, setCategoryIds] = useState<number[]>([]);
@@ -101,6 +103,7 @@ export function CreatePanelForm() {
101103
title: formData.title,
102104
description: formData.description || undefined,
103105
buttonLabel: formData.buttonLabel || undefined,
106+
attachmentUrl: formData.attachmentUrl || undefined,
104107
},
105108
};
106109
};
@@ -153,6 +156,7 @@ export function CreatePanelForm() {
153156
['title', error.fieldError(panelField, 'title')],
154157
['description', error.fieldError(panelField, 'description')],
155158
['buttonLabel', error.fieldError(panelField, 'buttonLabel')],
159+
['attachmentUrl', error.fieldError(panelField, 'attachmentUrl')],
156160
];
157161

158162
const newErrors: FormErrors = Object.fromEntries(
@@ -233,9 +237,11 @@ export function CreatePanelForm() {
233237
<div>
234238
{mode === 'normal' ? (
235239
<PanelEmbedFields
240+
attachmentUrl={formData.attachmentUrl}
236241
buttonLabel={formData.buttonLabel}
237242
description={formData.description}
238243
errors={errors}
244+
onAttachmentUrlChange={(value) => updateFormData('attachmentUrl', value)}
239245
onButtonLabelChange={(value) => updateFormData('buttonLabel', value)}
240246
onDescriptionChange={(value) => updateFormData('description', value)}
241247
onTitleChange={(value) => updateFormData('title', value)}
@@ -263,6 +269,7 @@ export function CreatePanelForm() {
263269

264270
{mode === 'normal' ? (
265271
<PanelPreview
272+
attachmentUrl={formData.attachmentUrl}
266273
buttonLabel={formData.buttonLabel}
267274
description={formData.description}
268275
mode="normal"

apps/website/src/components/common/TemplatePlaceholdersHint.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ interface TemplatePlaceholdersHintProps {
1111
}
1212

1313
/**
14-
* Documents the `{{ name }}` placeholder syntax `services/modmail-bot`'s `lib/templateString.ts`
14+
* Documents the `{{name}}` placeholder syntax `services/modmail-bot`'s `lib/templateString.ts`
1515
* substitutes before posting text — the dashboard has no other way to tell an admin these exist since
1616
* the fields are just plain inputs.
1717
*/
@@ -22,7 +22,7 @@ export function TemplatePlaceholdersHint({ placeholders = ALL_PLACEHOLDERS }: Te
2222
{placeholders.map((placeholder, index) => (
2323
<span key={placeholder}>
2424
<code className="rounded bg-on-secondary px-1 py-0.5 text-xs dark:bg-on-secondary-dark">
25-
{`{{ ${placeholder} }}`}
25+
{`{{${placeholder}}}`}
2626
</code>
2727
{index < placeholders.length - 1 ? ', ' : ''}
2828
</span>

0 commit comments

Comments
 (0)