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 { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { queryOptions } from '@tanstack/react-query'
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
@@ -134,17 +134,8 @@ function SettingsPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Logo upload */}
|
{/* Logo upload */}
|
||||||
<div className="flex items-start gap-8">
|
<div>
|
||||||
<div className="text-center space-y-2">
|
<LogoUpload entityId={store.id} category="logo" label="Store Logo" description="Used on PDFs, sidebar, and login screen" />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
@@ -388,3 +379,84 @@ function AddLocationDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
|||||||
</Dialog>
|
</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 data = await app.storage.get(filePath)
|
||||||
const ext = filePath.split('.').pop()?.toLowerCase()
|
const ext = filePath.split('.').pop()?.toLowerCase()
|
||||||
const contentTypeMap: Record<string, string> = {
|
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
|
return reply
|
||||||
.header('Content-Type', contentTypeMap[ext ?? ''] ?? 'application/octet-stream')
|
.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 data = await app.storage.get(filePath)
|
||||||
const ext = filePath.split('.').pop()?.toLowerCase()
|
const ext = filePath.split('.').pop()?.toLowerCase()
|
||||||
const contentTypeMap: Record<string, string> = {
|
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
|
return reply
|
||||||
.header('Content-Type', contentTypeMap[ext ?? ''] ?? 'application/octet-stream')
|
.header('Content-Type', contentTypeMap[ext ?? ''] ?? 'application/octet-stream')
|
||||||
|
|||||||
Reference in New Issue
Block a user