feat(bot): upload profile picture

This commit is contained in:
2026-03-28 15:20:24 +01:00
parent 9caeb9f97e
commit 462a51e376
7 changed files with 129 additions and 21 deletions

View File

@@ -1,18 +1,21 @@
'use client';
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { editBot } from '@/lib/form/actions';
import { BotAccount } from '@hctv/db';
import { useRouter } from 'next/navigation';
import { Button } from '@/components/ui/button';
import { UploadButton } from '@/lib/uploadthing';
import { toast } from 'sonner';
import React from 'react';
export function GeneralSettings(props: BotAccount) {
const router = useRouter();
const [isUploading, setIsUploading] = React.useState(false);
const [uploadError, setUploadError] = React.useState<string | null>(null);
const formRef = React.useRef<HTMLFormElement>(null);
return (
<Card>
<CardHeader>
@@ -21,6 +24,7 @@ export function GeneralSettings(props: BotAccount) {
</CardHeader>
<CardContent>
<UniversalForm
formRef={formRef}
fields={[
{
name: 'from',
@@ -42,7 +46,7 @@ export function GeneralSettings(props: BotAccount) {
label: 'Bot Slug',
placeholder: 'Enter bot slug',
required: true,
value: props.slug
value: props.slug,
},
{
name: 'description',
@@ -52,11 +56,96 @@ export function GeneralSettings(props: BotAccount) {
value: props.description,
textArea: true,
},
{
name: 'pfpUrl',
label: 'Profile Picture',
type: 'url',
value: props.pfpUrl,
component: ({ field }) => {
return (
<div className="space-y-4">
<input type="hidden" {...field} />
{field.value && (
<div className="flex items-center space-x-4">
<Avatar className="h-16 w-16">
<AvatarImage src={field.value} alt="Current profile picture" />
<AvatarFallback>{props.displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex-1">
<p className="text-sm font-medium">Current profile picture</p>
<p className="text-xs text-muted-foreground">
Click &quot;Upload new image&quot; to replace
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
field.onChange('');
setUploadError(null);
}}
>
Remove
</Button>
</div>
)}
<div>
<UploadButton
endpoint="pfpUpload"
className="mt-2 ut-button:bg-mantle ut-button:text-mantle-foreground ut-allowed-content:text-muted-foreground/70"
content={{
button: field.value ? 'Upload new image' : 'Upload profile picture',
allowedContent: 'Image (1MB max)',
}}
onUploadBegin={() => {
setIsUploading(true);
setUploadError(null);
}}
onClientUploadComplete={(res) => {
setIsUploading(false);
if (res && res[0]) {
field.onChange(res[0].ufsUrl);
toast.success('Profile picture uploaded successfully!');
setTimeout(() => {
formRef.current?.requestSubmit();
}, 0);
}
}}
onUploadError={(error) => {
setIsUploading(false);
setUploadError(error.message);
toast.error(`Upload failed: ${error.message}`);
}}
disabled={isUploading}
/>
{isUploading && <p className="mt-2 text-sm text-primary">Uploading...</p>}
{uploadError && <p className="mt-2 text-sm text-red-600">{uploadError}</p>}
{!field.value && !isUploading && !uploadError && (
<p className="mt-2 text-sm text-muted-foreground">
Upload a profile picture for your channel.
</p>
)}
</div>
</div>
);
},
},
]}
schemaName={'editBot'}
action={editBot}
onActionComplete={(result) => {
if (result?.success) {
router.refresh();
}
}}
/>
</CardContent>
</Card>
)
}
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback } from 'react';
import { useState, useCallback, useRef } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
@@ -123,6 +123,7 @@ export default function ChannelSettingsClient({
const [region, setRegion] = useState<MediaMTXRegion>('hq');
const channelList = useOwnedChannels();
const router = useRouter();
const channelSettingsFormRef = useRef<HTMLFormElement>(null);
const handleStreamInfoActionComplete = useCallback((result: any) => {
if (result?.success) {
@@ -291,6 +292,7 @@ export default function ChannelSettingsClient({
</CardHeader>
<CardContent className="space-y-6">
<UniversalForm
formRef={channelSettingsFormRef}
fields={[
{ name: 'channelId', type: 'hidden', value: channel.id, label: 'Channel ID' },
{
@@ -346,6 +348,9 @@ export default function ChannelSettingsClient({
if (res && res[0]) {
field.onChange(res[0].ufsUrl);
toast.success('Profile picture uploaded successfully!');
setTimeout(() => {
channelSettingsFormRef.current?.requestSubmit();
}, 0);
}
}}
onUploadError={(error) => {

View File

@@ -47,6 +47,7 @@ export function UniversalForm<T extends z.ZodType>({
action,
onActionComplete,
defaultValues,
formRef,
submitText = 'Submit',
submitClassname,
otherSubmitButton,
@@ -87,7 +88,7 @@ export function UniversalForm<T extends z.ZodType>({
return (
<Form {...form}>
<form action={formAction} className="space-y-2">
<form ref={formRef} action={formAction} className="space-y-2">
{fields.map((field) => (
<FormField
key={field.name}

View File

@@ -1,32 +1,37 @@
import { z } from 'zod';
import { HTMLInputTypeAttribute } from 'react';
import type { HTMLInputTypeAttribute, Ref } from 'react';
import { ControllerRenderProps } from 'react-hook-form';
import { z } from 'zod';
import { schemaDb } from './UniversalForm';
export type FormFieldConfig = {
export type FormFieldConfig<T extends z.ZodType> = {
name: string;
label?: string;
type?: HTMLInputTypeAttribute;
placeholder?: string;
description?: string;
value?: any;
value?: z.input<T>[keyof z.input<T>];
textArea?: boolean;
textAreaRows?: number;
maxChars?: number;
inputFilter?: RegExp;
component?: (props: { field: ControllerRenderProps<any, any> } & any) => React.ReactNode;
component?: (
props: {
field: ControllerRenderProps<z.infer<T>>;
} & Record<string, unknown>
) => React.ReactNode;
componentProps?: Record<string, any>;
required?: boolean;
};
export type UniversalFormProps<T extends z.ZodType> = {
fields: FormFieldConfig[];
fields: FormFieldConfig<T>[];
schemaName: (typeof schemaDb)[number]['name'];
action: (prev: any, formData: FormData) => void;
onActionComplete?: (result: any) => void;
defaultValues?: Partial<z.infer<T>>;
formRef?: Ref<HTMLFormElement>;
submitText?: string;
submitClassname?: string;
otherSubmitButton?: React.ReactNode;
submitButtonDivClassname?: string;
};
};

View File

@@ -614,7 +614,7 @@ export async function createBot(prev: any, formData: FormData) {
slug: zod.data.slug,
ownerId: user.id,
description: zod.data.description,
pfpUrl: await genIdenticonUpload(zod.data.slug, 'botpfp'),
pfpUrl: await genIdenticonUpload(zod.data.slug, 'botpfp'),
},
});
@@ -647,14 +647,21 @@ export async function editBot(prev: any, formData: FormData) {
if (botExists) {
return { success: false, error: 'Bot slug already exists' };
}
}
if (zod.data.pfpUrl === '') {
const identicon = await genIdenticonUpload(zod.data.name, 'pfp');
zod.data.pfpUrl = identicon;
}
// i feel like you could just append the data instead of manually changing each field but oh well
const updatedBot = await prisma.botAccount.update({
where: { id: zod.data.from },
data: {
displayName: zod.data.name,
slug: zod.data.slug,
description: zod.data.description,
pfpUrl: zod.data.pfpUrl,
},
});

View File

@@ -50,6 +50,7 @@ export const createBotSchema = z.object({
export const editBotSchema = createBotSchema.and(
z.object({
from: z.string().min(1),
pfpUrl: z.string(),
})
);

View File

@@ -20,7 +20,7 @@ export const ourFileRouter = {
*/
maxFileSize: "1MB",
maxFileCount: 1,
},
},
})
// Set permissions and file types for this FileRoute
.middleware(async () => {