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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
|
||||
const data = await app.storage.get(filePath)
|
||||
const ext = filePath.split('.').pop()?.toLowerCase()
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', pdf: 'application/pdf',
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf',
|
||||
}
|
||||
return reply
|
||||
.header('Content-Type', contentTypeMap[ext ?? ''] ?? 'application/octet-stream')
|
||||
@@ -154,7 +154,7 @@ export const fileRoutes: FastifyPluginAsync = async (app) => {
|
||||
const data = await app.storage.get(filePath)
|
||||
const ext = filePath.split('.').pop()?.toLowerCase()
|
||||
const contentTypeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', pdf: 'application/pdf',
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf',
|
||||
}
|
||||
return reply
|
||||
.header('Content-Type', contentTypeMap[ext ?? ''] ?? 'application/octet-stream')
|
||||
|
||||
Reference in New Issue
Block a user