added storage upload for logo

This commit is contained in:
Stephen Morgan committed 2026-04-24 18:16:45 +12:00
1 parent a17ac9e4ff
commit 63acb9db5e
1 file changed
+308 -170
@@ -1,9 +1,18 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useParams } from 'common'
import { useEffect, useMemo, useRef } from 'react'
import { Upload } from 'lucide-react'
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
Form_Shadcn_,
FormControl_Shadcn_,
@@ -23,6 +32,12 @@ import * as z from 'zod'
import { useAuthBrandingQuery } from '@/data/auth/auth-branding-query'
import { useAuthBrandingUpdateMutation } from '@/data/auth/auth-branding-update-mutation'
import { useProjectApiUrl } from '@/data/config/project-endpoint-query'
import { get } from '@/data/fetchers'
import { useBucketCreateMutation } from '@/data/storage/bucket-create-mutation'
import { createProjectSupabaseClient } from '@/lib/project-supabase-client'
const BUCKET_NAME = 'email_assets'
interface EmailBrandingSheetProps {
visible: boolean
@@ -67,7 +82,7 @@ function generatePreviewHtml(values: BrandingFormValues): string {
const footerText = values.brand_footer_text || `© ${new Date().getFullYear()} ${name}`
const headerContent = values.brand_logo_url
? `<img src="${values.brand_logo_url}" alt="${name}" style="max-height:40px;max-width:200px;" />`
? `<img src="${values.brand_logo_url}" alt="${name}" style="max-height:50px;max-width:200px;display:block;margin:0 auto;" />`
: `<span style="color:#fff;font-size:18px;font-weight:600;">${name}</span>`
return `<!DOCTYPE html>
@@ -95,9 +110,23 @@ function generatePreviewHtml(values: BrandingFormValues): string {
</html>`
}
async function bucketExists(projectRef: string): Promise<boolean> {
const { error } = await get('/platform/storage/{ref}/buckets/{id}', {
params: { path: { ref: projectRef, id: BUCKET_NAME } },
})
return !error
}
export function EmailBrandingSheet({ visible, onClose }: EmailBrandingSheetProps) {
const { ref: projectRef } = useParams()
const colorInputRef = useRef<HTMLInputElement>(null)
const logoUploadRef = useRef<HTMLInputElement>(null)
const [isUploadingLogo, setIsUploadingLogo] = useState(false)
const [pendingFile, setPendingFile] = useState<File | null>(null)
const [showBucketWarning, setShowBucketWarning] = useState(false)
const { data: clientEndpoint } = useProjectApiUrl({ projectRef })
const { data: branding, isPending: isLoading } = useAuthBrandingQuery(
{ projectRef },
@@ -114,6 +143,8 @@ export function EmailBrandingSheet({ visible, onClose }: EmailBrandingSheetProps
},
})
const { mutate: createBucket, isPending: isCreatingBucket } = useBucketCreateMutation()
const form = useForm<BrandingFormValues>({
resolver: zodResolver(BrandingFormSchema),
defaultValues: {
@@ -136,9 +167,66 @@ export function EmailBrandingSheet({ visible, onClose }: EmailBrandingSheetProps
}, [branding, form])
const watchedValues = form.watch()
const previewHtml = useMemo(() => generatePreviewHtml(watchedValues), [watchedValues])
const uploadFile = async (file: File) => {
if (!projectRef || !clientEndpoint) return
setIsUploadingLogo(true)
try {
const client = await createProjectSupabaseClient(projectRef, clientEndpoint)
const ext = file.name.split('.').pop() ?? 'png'
const path = `logo-${Date.now()}.${ext}`
const { error: uploadError } = await client.storage
.from(BUCKET_NAME)
.upload(path, file, { upsert: true })
if (uploadError) throw uploadError
const {
data: { publicUrl },
} = client.storage.from(BUCKET_NAME).getPublicUrl(path)
form.setValue('brand_logo_url', publicUrl, { shouldDirty: true })
} catch (err) {
toast.error(`Failed to upload logo: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
setIsUploadingLogo(false)
setPendingFile(null)
}
}
const handleFileSelect = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
// Reset so the same file can be re-selected after a cancel
e.target.value = ''
if (!file || !projectRef) return
const exists = await bucketExists(projectRef)
if (exists) {
uploadFile(file)
} else {
setPendingFile(file)
setShowBucketWarning(true)
}
}
const handleConfirmBucketCreate = () => {
if (!pendingFile || !projectRef) return
setShowBucketWarning(false)
createBucket(
{ projectRef, id: BUCKET_NAME, type: 'STANDARD', isPublic: true },
{
onSuccess: () => uploadFile(pendingFile),
onError: (err) => {
toast.error(`Failed to create bucket: ${err.message}`)
setPendingFile(null)
},
}
)
}
const handleCancelBucketCreate = () => {
setShowBucketWarning(false)
setPendingFile(null)
}
const onSubmit = (values: BrandingFormValues) => {
if (!projectRef) return
updateBranding({ projectRef, branding: values })
@@ -151,184 +239,234 @@ export function EmailBrandingSheet({ visible, onClose }: EmailBrandingSheetProps
}
}
const isWorking = isUploadingLogo || isCreatingBucket
return (
<Sheet open={visible} onOpenChange={handleOpenChange}>
<SheetContent
size="lg"
className="flex flex-col !min-w-screen lg:!min-w-[900px] xl:!min-w-[1080px]"
>
<SheetHeader className="py-3">
<SheetTitle>Customize email branding</SheetTitle>
</SheetHeader>
<>
<Sheet open={visible} onOpenChange={handleOpenChange}>
<SheetContent
size="lg"
className="flex flex-col !min-w-screen lg:!min-w-[900px] xl:!min-w-[1080px]"
>
<SheetHeader className="py-3">
<SheetTitle>Customize email branding</SheetTitle>
</SheetHeader>
<SheetSection className="flex-1 overflow-y-auto">
{isLoading ? (
<GenericSkeletonLoader />
) : (
<div className="flex gap-6 h-full min-h-0">
{/* Form panel */}
<div className="flex flex-col gap-6 w-80 shrink-0">
<p className="text-sm text-foreground-light">
These branding attributes are applied to all default email templates. Set up{' '}
<strong>custom SMTP</strong> to edit raw template HTML.
</p>
<SheetSection className="flex-1 overflow-y-auto">
{isLoading ? (
<GenericSkeletonLoader />
) : (
<div className="flex gap-6 h-full min-h-0">
{/* Form panel */}
<div className="flex flex-col gap-6 w-80 shrink-0">
<p className="text-sm text-foreground-light">
These branding attributes are applied to all default email templates. Set up{' '}
<strong>custom SMTP</strong> to edit raw template HTML.
</p>
<Form_Shadcn_ {...form}>
<form
id="branding-form"
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
<FormField_Shadcn_
control={form.control}
name="brand_name"
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Brand name"
description="Shown in the email header when no logo is set."
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
value={field.value ?? ''}
placeholder="Acme Corp"
maxLength={100}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="brand_logo_url"
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Logo URL"
description="Must be an https:// URL. Displayed in the email header."
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
value={field.value ?? ''}
placeholder="https://example.com/logo.png"
type="url"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="brand_color"
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Brand color"
description="Hex color used for buttons and accents (e.g. #3ecf8e)."
>
<FormControl_Shadcn_>
<div className="flex items-center gap-2">
{/* Native color picker swatch */}
<div className="relative shrink-0">
<div
className="w-9 h-9 rounded-md border border-control cursor-pointer"
style={{
background: /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(
field.value ?? ''
)
? (field.value ?? '#3ecf8e')
: '#3ecf8e',
}}
onClick={() => colorInputRef.current?.click()}
/>
<input
ref={colorInputRef}
type="color"
className="sr-only"
value={
/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(field.value ?? '')
? (field.value ?? '#3ecf8e')
: '#3ecf8e'
}
onChange={(e) => field.onChange(e.target.value)}
/>
</div>
<Form_Shadcn_ {...form}>
<form
id="branding-form"
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-6"
>
<FormField_Shadcn_
control={form.control}
name="brand_name"
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Brand name"
description="Shown in the email header when no logo is set."
>
<FormControl_Shadcn_>
<Input_Shadcn_
{...field}
value={field.value ?? ''}
placeholder="#3ecf8e"
className="font-mono"
maxLength={7}
placeholder="Acme Corp"
maxLength={100}
/>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="brand_footer_text"
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Footer text"
description="Appears at the bottom of every email. Max 500 characters."
>
<FormControl_Shadcn_>
<Textarea
{...field}
value={field.value ?? ''}
placeholder={`© ${new Date().getFullYear()} Acme Corp`}
rows={3}
maxLength={500}
className="resize-none"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</form>
</Form_Shadcn_>
</div>
<FormField_Shadcn_
control={form.control}
name="brand_logo_url"
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Logo"
description="Displayed in the email header. Max 50px tall."
>
<FormControl_Shadcn_>
<div className="flex flex-col gap-2">
<Input_Shadcn_
{...field}
value={field.value ?? ''}
placeholder="https://example.com/logo.png"
type="url"
/>
<div className="flex items-center gap-2 text-foreground-muted">
<div className="flex-1 border-t border-muted" />
<span className="text-xs">or</span>
<div className="flex-1 border-t border-muted" />
</div>
<Button
type="default"
size="tiny"
icon={<Upload size={12} />}
loading={isWorking}
disabled={isWorking}
onClick={() => logoUploadRef.current?.click()}
>
{isWorking ? 'Uploading…' : 'Upload image'}
</Button>
<input
ref={logoUploadRef}
type="file"
accept="image/*"
className="sr-only"
onChange={handleFileSelect}
/>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{/* Preview panel */}
<div className="flex-1 flex flex-col gap-2 min-w-0">
<p className="text-xs text-foreground-light font-medium uppercase tracking-wide">
Preview
</p>
<div className="flex-1 rounded-lg border border-default overflow-hidden bg-surface-200">
<iframe
title="Email branding preview"
srcDoc={previewHtml}
className="w-full h-full min-h-[500px]"
sandbox="allow-scripts"
/>
<FormField_Shadcn_
control={form.control}
name="brand_color"
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Brand color"
description="Hex color used for buttons and accents (e.g. #3ecf8e)."
>
<FormControl_Shadcn_>
<div className="flex items-center gap-2">
<div className="relative shrink-0">
<button
type="button"
className="w-9 h-9 rounded-md border border-control cursor-pointer"
style={{
background: /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(
field.value ?? ''
)
? (field.value ?? '#3ecf8e')
: '#3ecf8e',
}}
onClick={() => colorInputRef.current?.click()}
/>
<input
ref={colorInputRef}
type="color"
className="sr-only"
value={
/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(field.value ?? '')
? (field.value ?? '#3ecf8e')
: '#3ecf8e'
}
onChange={(e) => field.onChange(e.target.value)}
/>
</div>
<Input_Shadcn_
{...field}
value={field.value ?? ''}
placeholder="#3ecf8e"
className="font-mono"
maxLength={7}
/>
</div>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
<FormField_Shadcn_
control={form.control}
name="brand_footer_text"
render={({ field }) => (
<FormItemLayout
layout="vertical"
label="Footer text"
description="Appears at the bottom of every email. Max 500 characters."
>
<FormControl_Shadcn_>
<Textarea
{...field}
value={field.value ?? ''}
placeholder={`© ${new Date().getFullYear()} Acme Corp`}
rows={3}
maxLength={500}
className="resize-none"
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
</form>
</Form_Shadcn_>
</div>
{/* Preview panel */}
<div className="flex-1 flex flex-col gap-2 min-w-0">
<p className="text-xs text-foreground-light font-medium uppercase tracking-wide">
Preview
</p>
<div className="flex-1 rounded-lg border border-default overflow-hidden bg-surface-200">
<iframe
title="Email branding preview"
srcDoc={previewHtml}
className="w-full h-full min-h-[500px]"
sandbox="allow-scripts"
/>
</div>
</div>
</div>
</div>
)}
</SheetSection>
)}
</SheetSection>
<SheetFooter className="flex justify-end gap-2">
<Button type="default" onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
form="branding-form"
loading={isSaving}
disabled={isSaving || !form.formState.isDirty}
>
Save branding
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<SheetFooter className="flex justify-end gap-2">
<Button type="default" onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
form="branding-form"
loading={isSaving}
disabled={isSaving || !form.formState.isDirty}
>
Save branding
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<AlertDialog
open={showBucketWarning}
onOpenChange={(open) => !open && handleCancelBucketCreate()}
>
<AlertDialogContent size="small">
<AlertDialogHeader>
<AlertDialogTitle>Create storage bucket</AlertDialogTitle>
<AlertDialogDescription>
This will create a public storage bucket named{' '}
<span className="font-mono font-medium text-foreground">{BUCKET_NAME}</span> in your
project. Files in this bucket will be publicly accessible via URL.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelBucketCreate}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmBucketCreate}>
Create bucket and upload
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}