Add rectangular logo upload to settings, support SVG content type

Settings page now shows a rectangular upload area for the store logo
instead of circular avatar. Uses authenticated image fetching with
blob URL cleanup. Accepts SVG in addition to JPEG/PNG/WebP. SVG
added to file serve content type map. Simplified to single logo
image (used on PDFs, sidebar, and login).
This commit is contained in:
Ryan Moon
2026-03-29 16:27:02 -05:00
parent 8d75586f8b
commit f9bf1c9bff
2 changed files with 86 additions and 14 deletions

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { queryOptions } from '@tanstack/react-query'
@@ -134,17 +134,8 @@ function SettingsPage() {
</CardHeader>
<CardContent className="space-y-6">
{/* Logo upload */}
<div className="flex items-start gap-8">
<div className="text-center space-y-2">
<Label className="text-xs text-muted-foreground">Store Logo</Label>
<AvatarUpload entityType="company" entityId={store.id} size="lg" category="logo" placeholderIcon={ImageIcon} />
<p className="text-[10px] text-muted-foreground">Used on PDFs, invoices, receipts</p>
</div>
<div className="text-center space-y-2">
<Label className="text-xs text-muted-foreground">App Icon</Label>
<AvatarUpload entityType="company" entityId={store.id} size="md" category="icon" placeholderIcon={Building} />
<p className="text-[10px] text-muted-foreground">Sidebar & login screen</p>
</div>
<div>
<LogoUpload entityId={store.id} category="logo" label="Store Logo" description="Used on PDFs, sidebar, and login screen" />
</div>
{editing ? (
@@ -388,3 +379,84 @@ function AddLocationDialog({ open, onOpenChange }: { open: boolean; onOpenChange
</Dialog>
)
}
function LogoUpload({ entityId, category, label, description }: { entityId: string; category: string; label: string; description: string }) {
const queryClient = useQueryClient()
const token = useAuthStore((s) => s.token)
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploading, setUploading] = useState(false)
const [imgSrc, setImgSrc] = useState<string | null>(null)
const { data: filesData } = useQuery(queryOptions({
queryKey: ['files', 'company', entityId],
queryFn: () => api.get<{ data: { id: string; path: string; filename: string }[] }>('/v1/files', { entityType: 'company', entityId }),
enabled: !!entityId,
}))
const logoFile = filesData?.data?.find((f) => f.path.includes(`/${category}-`))
// Load image via authenticated fetch
useEffect(() => {
if (!logoFile || !token) { setImgSrc(null); return }
let cancelled = false
let blobUrl: string | null = null
async function load() {
try {
const res = await fetch(`/v1/files/serve/${logoFile!.path}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok || cancelled) return
const blob = await res.blob()
if (!cancelled) { blobUrl = URL.createObjectURL(blob); setImgSrc(blobUrl) }
} catch { /* ignore */ }
}
load()
return () => { cancelled = true; if (blobUrl) URL.revokeObjectURL(blobUrl) }
}, [logoFile?.path, token])
async function handleUpload(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
try {
if (logoFile) await api.del(`/v1/files/${logoFile.id}`)
const formData = new FormData()
formData.append('file', file)
formData.append('entityType', 'company')
formData.append('entityId', entityId)
formData.append('category', category)
const res = await fetch('/v1/files', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: formData })
if (!res.ok) throw new Error('Upload failed')
queryClient.invalidateQueries({ queryKey: ['files', 'company', entityId] })
toast.success(`${label} updated`)
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Upload failed')
} finally {
setUploading(false)
e.target.value = ''
}
}
return (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">{label}</Label>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex items-center justify-center w-48 h-24 rounded-lg border-2 border-dashed border-muted-foreground/30 bg-muted/30 hover:border-primary hover:bg-muted/50 transition-colors overflow-hidden"
>
{imgSrc ? (
<img src={imgSrc} alt={label} className="max-w-full max-h-full object-contain p-2" />
) : (
<div className="flex flex-col items-center gap-1 text-muted-foreground">
<ImageIcon className="h-8 w-8" />
<span className="text-xs">Click to upload</span>
</div>
)}
</button>
<p className="text-[10px] text-muted-foreground">{description}</p>
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp,image/svg+xml" className="hidden" onChange={handleUpload} />
</div>
)
}