feat: add supporting data capture, PDF export, and branding settings UI

- API clients for supporting data CRUD and team branding
- AddSupportingDataModal with text snippet and screenshot tabs (paste + upload)
- SupportingDataPanel collapsible section integrated into both session runners
- ExportPreviewModal updated with PDF format and server-side download flow
- BrandingSettings component for company name and logo management
- Expose team_id in UserResponse schema for branding endpoint access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-03-17 02:03:40 -04:00
parent b72eb56b7f
commit 2339787499
13 changed files with 857 additions and 54 deletions

View File

@@ -0,0 +1,263 @@
import { useState, useRef, useCallback } from 'react'
import { Code2, ImageIcon, Upload } from 'lucide-react'
import { Modal } from '@/components/common/Modal'
import { Button } from '@/components/ui/Button'
import { cn } from '@/lib/utils'
import { createSupportingData } from '@/api/supportingData'
import { toast } from '@/lib/toast'
interface AddSupportingDataModalProps {
isOpen: boolean
onClose: () => void
sessionId: string
onAdded: () => void
}
type TabType = 'text_snippet' | 'screenshot'
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
export function AddSupportingDataModal({ isOpen, onClose, sessionId, onAdded }: AddSupportingDataModalProps) {
const [activeTab, setActiveTab] = useState<TabType>('text_snippet')
const [label, setLabel] = useState('')
const [textContent, setTextContent] = useState('')
const [imageBase64, setImageBase64] = useState<string | null>(null)
const [imageContentType, setImageContentType] = useState<string | null>(null)
const [imageFileName, setImageFileName] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const resetForm = () => {
setLabel('')
setTextContent('')
setImageBase64(null)
setImageContentType(null)
setImageFileName(null)
setError(null)
setActiveTab('text_snippet')
}
const handleClose = () => {
resetForm()
onClose()
}
const processFile = useCallback((file: File) => {
if (file.size > MAX_FILE_SIZE) {
setError('File must be under 2MB')
return
}
if (!['image/png', 'image/jpeg', 'image/svg+xml'].includes(file.type)) {
setError('Only PNG, JPEG, and SVG files are supported')
return
}
setError(null)
setImageFileName(file.name)
setImageContentType(file.type)
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
// Strip the data:... prefix to get raw base64
const base64 = result.includes(',') ? result.split(',')[1] : result
setImageBase64(base64)
}
reader.readAsDataURL(file)
}, [])
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) processFile(file)
}
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const items = e.clipboardData?.items
if (!items) return
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault()
const file = item.getAsFile()
if (file) processFile(file)
return
}
}
}, [processFile])
const handleSubmit = async () => {
if (!label.trim()) {
setError('Label is required')
return
}
if (activeTab === 'text_snippet' && !textContent.trim()) {
setError('Content is required')
return
}
if (activeTab === 'screenshot' && !imageBase64) {
setError('Please select or paste an image')
return
}
setIsSubmitting(true)
setError(null)
try {
await createSupportingData(sessionId, {
label: label.trim(),
data_type: activeTab,
content: activeTab === 'text_snippet' ? textContent : imageBase64!,
content_type: activeTab === 'screenshot' ? (imageContentType ?? undefined) : undefined,
})
toast.success('Supporting data added')
onAdded()
handleClose()
} catch (err) {
console.error('Failed to add supporting data:', err)
setError('Failed to save. Please try again.')
} finally {
setIsSubmitting(false)
}
}
return (
<Modal isOpen={isOpen} onClose={handleClose} title="Add Supporting Data">
{/* Tabs */}
<div className="mb-4 flex gap-1 rounded-lg bg-accent p-1">
<button
type="button"
onClick={() => setActiveTab('text_snippet')}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
activeTab === 'text_snippet'
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<Code2 className="h-4 w-4" />
Text Snippet
</button>
<button
type="button"
onClick={() => setActiveTab('screenshot')}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
activeTab === 'screenshot'
? 'bg-card text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
)}
>
<ImageIcon className="h-4 w-4" />
Screenshot
</button>
</div>
{/* Label */}
<div className="mb-4">
<label htmlFor="sd-label" className="mb-1 block text-sm font-medium text-foreground">
Label
</label>
<input
id="sd-label"
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder={activeTab === 'text_snippet' ? 'e.g. Error log output' : 'e.g. Blue screen photo'}
className={cn(
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm',
'text-foreground placeholder:text-muted-foreground',
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
/>
</div>
{/* Text Snippet Tab Content */}
{activeTab === 'text_snippet' && (
<div className="mb-4">
<label htmlFor="sd-content" className="mb-1 block text-sm font-medium text-foreground">
Content
</label>
<textarea
id="sd-content"
value={textContent}
onChange={(e) => setTextContent(e.target.value)}
placeholder="Paste log output, error messages, config snippets..."
rows={8}
className={cn(
'w-full resize-y rounded-md border border-border bg-card px-3 py-2 text-sm',
'font-mono text-foreground placeholder:text-muted-foreground',
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
/>
</div>
)}
{/* Screenshot Tab Content */}
{activeTab === 'screenshot' && (
<div className="mb-4" onPaste={handlePaste}>
<label className="mb-1 block text-sm font-medium text-foreground">
Image
</label>
{imageBase64 ? (
<div className="relative rounded-md border border-border bg-card p-2">
<img
src={`data:${imageContentType};base64,${imageBase64}`}
alt="Preview"
className="mx-auto max-h-48 rounded object-contain"
/>
<p className="mt-2 text-center text-xs text-muted-foreground">{imageFileName}</p>
<button
type="button"
onClick={() => {
setImageBase64(null)
setImageContentType(null)
setImageFileName(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}}
className="mt-2 w-full text-center text-xs text-muted-foreground hover:text-foreground"
>
Remove and choose another
</button>
</div>
) : (
<div
onClick={() => fileInputRef.current?.click()}
className={cn(
'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed border-border',
'bg-card/50 py-10 transition-colors hover:border-muted-foreground'
)}
>
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Click to upload or paste from clipboard</p>
<p className="text-xs text-muted-foreground">PNG, JPEG, or SVG - max 2MB</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml"
onChange={handleFileChange}
className="hidden"
/>
</div>
)}
{/* Error */}
{error && (
<p className="mb-4 text-sm text-rose-500">{error}</p>
)}
{/* Actions */}
<div className="flex items-center justify-end gap-2">
<Button variant="secondary" onClick={handleClose}>
Cancel
</Button>
<Button onClick={handleSubmit} loading={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Add'}
</Button>
</div>
</Modal>
)
}
export default AddSupportingDataModal

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Copy, Download, Check, RotateCcw } from 'lucide-react'
import { Copy, Download, Check, RotateCcw, FileDown } from 'lucide-react'
import { Modal } from '@/components/common/Modal'
import { cn } from '@/lib/utils'
import type { RedactionSummary } from '@/types'
@@ -9,8 +9,10 @@ interface ExportPreviewModalProps {
onClose: () => void
content: string
filename: string
format: 'markdown' | 'text' | 'html' | 'psa'
format: 'markdown' | 'text' | 'html' | 'psa' | 'pdf'
onDownload: (content: string) => void
onDownloadPdf?: () => void
pdfLoading?: boolean
includeSummary?: boolean
onToggleSummary?: (include: boolean) => void
redactionEnabled?: boolean
@@ -25,6 +27,8 @@ export function ExportPreviewModal({
filename,
format,
onDownload,
onDownloadPdf,
pdfLoading = false,
includeSummary = false,
onToggleSummary,
redactionEnabled = false,
@@ -72,7 +76,7 @@ export function ExportPreviewModal({
<p className="text-sm text-muted-foreground">
Filename: <span className="font-mono text-foreground">{filename}</span>
<span className="ml-3 rounded bg-accent px-2 py-0.5 text-xs text-muted-foreground">
{format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : 'Plain Text'}
{format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : format === 'pdf' ? 'PDF' : 'Plain Text'}
</span>
{isModified && (
<span className="ml-2 text-xs text-yellow-400">(edited)</span>
@@ -130,56 +134,79 @@ export function ExportPreviewModal({
</div>
</div>
{/* Editable Content */}
<label htmlFor="export-content" className="sr-only">
Export content
</label>
<textarea
id="export-content"
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
className={cn(
'h-96 w-full resize-y rounded-md border border-border bg-card p-4',
'font-mono text-sm text-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
/>
{format === 'pdf' ? (
/* PDF download-only UI */
<div className="flex flex-col items-center justify-center py-12">
<FileDown className="mb-4 h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground mb-4">PDF exports are generated server-side with your team&apos;s branding.</p>
<button
type="button"
onClick={onDownloadPdf}
disabled={pdfLoading}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-6 py-3 text-sm font-semibold text-[#101114]',
'hover:opacity-90 active:scale-[0.97] transition-all',
'disabled:opacity-60 disabled:cursor-not-allowed'
)}
>
<Download className="h-4 w-4" />
{pdfLoading ? 'Generating PDF...' : 'Download PDF'}
</button>
</div>
) : (
<>
{/* Editable Content */}
<label htmlFor="export-content" className="sr-only">
Export content
</label>
<textarea
id="export-content"
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
className={cn(
'h-96 w-full resize-y rounded-md border border-border bg-card p-4',
'font-mono text-sm text-foreground',
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
/>
{/* Actions */}
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
onClick={handleCopy}
className={cn(
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium',
'text-muted-foreground hover:bg-accent hover:text-foreground',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20'
)}
>
{copied ? (
<>
<Check className="h-4 w-4 text-emerald-400" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy to Clipboard
</>
)}
</button>
<button
type="button"
onClick={handleDownload}
className={cn(
'flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium',
'hover:opacity-90 focus:outline-hidden focus:ring-2 focus:ring-primary/20'
)}
>
<Download className="h-4 w-4" />
Download
</button>
</div>
{/* Actions */}
<div className="mt-4 flex items-center justify-end gap-2">
<button
type="button"
onClick={handleCopy}
className={cn(
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium',
'text-muted-foreground hover:bg-accent hover:text-foreground',
'focus:outline-hidden focus:ring-2 focus:ring-primary/20'
)}
>
{copied ? (
<>
<Check className="h-4 w-4 text-emerald-400" />
Copied!
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy to Clipboard
</>
)}
</button>
<button
type="button"
onClick={handleDownload}
className={cn(
'flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium',
'hover:opacity-90 focus:outline-hidden focus:ring-2 focus:ring-primary/20'
)}
>
<Download className="h-4 w-4" />
Download
</button>
</div>
</>
)}
</Modal>
)
}

View File

@@ -0,0 +1,152 @@
import { useState, useEffect, useCallback } from 'react'
import { Code2, ImageIcon, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
import { getSupportingData, deleteSupportingData } from '@/api/supportingData'
import type { SupportingDataItem } from '@/api/supportingData'
import { AddSupportingDataModal } from './AddSupportingDataModal'
import { toast } from '@/lib/toast'
interface SupportingDataPanelProps {
sessionId: string
}
export function SupportingDataPanel({ sessionId }: SupportingDataPanelProps) {
const [items, setItems] = useState<SupportingDataItem[]>([])
const [isExpanded, setIsExpanded] = useState(false)
const [showAddModal, setShowAddModal] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const loadItems = useCallback(async () => {
setIsLoading(true)
try {
const data = await getSupportingData(sessionId)
setItems(data)
} catch (err) {
console.error('Failed to load supporting data:', err)
} finally {
setIsLoading(false)
}
}, [sessionId])
useEffect(() => {
loadItems()
}, [loadItems])
const handleDelete = async (itemId: string) => {
try {
await deleteSupportingData(sessionId, itemId)
setItems((prev) => prev.filter((item) => item.id !== itemId))
toast.success('Supporting data removed')
} catch (err) {
console.error('Failed to delete supporting data:', err)
toast.error('Failed to remove item')
}
}
const handleAdded = () => {
loadItems()
setIsExpanded(true)
}
return (
<div className="rounded-xl border border-border bg-card/50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3">
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className="flex items-center gap-2 text-sm font-medium text-foreground hover:text-foreground/80"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
Supporting Data
{items.length > 0 && (
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-muted-foreground">
{items.length}
</span>
)}
</button>
<button
type="button"
onClick={() => setShowAddModal(true)}
className={cn(
'flex items-center gap-1.5 rounded-[10px] px-3 py-1.5 text-xs font-medium',
'bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97] transition-all'
)}
>
<Plus className="h-3.5 w-3.5" />
Add
</button>
</div>
{/* Items list */}
{isExpanded && (
<div className="border-t border-border px-4 py-2">
{isLoading && items.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">Loading...</p>
) : items.length === 0 ? (
<p className="py-4 text-center text-xs text-muted-foreground">
No supporting data yet. Add text snippets or screenshots to include in exports.
</p>
) : (
<div className="space-y-2 py-1">
{items.map((item) => (
<div
key={item.id}
className={cn(
'flex items-start gap-3 rounded-lg border border-border bg-card/30 px-3 py-2.5',
'group transition-colors hover:border-[rgba(255,255,255,0.12)]'
)}
>
<span className="mt-0.5 shrink-0 text-muted-foreground">
{item.data_type === 'text_snippet' ? (
<Code2 className="h-4 w-4" />
) : (
<ImageIcon className="h-4 w-4" />
)}
</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{item.label}</p>
{item.data_type === 'text_snippet' && item.content && (
<p className="mt-0.5 truncate text-xs font-mono text-muted-foreground">
{item.content.length > 120 ? item.content.slice(0, 120) + '...' : item.content}
</p>
)}
{item.data_type === 'screenshot' && item.content && (
<img
src={`data:${item.content_type || 'image/png'};base64,${item.content}`}
alt={item.label}
className="mt-1 max-h-16 rounded border border-border object-contain"
/>
)}
</div>
<button
type="button"
onClick={() => handleDelete(item.id)}
className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-rose-500 group-hover:opacity-100"
title="Remove"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Add Modal */}
<AddSupportingDataModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
sessionId={sessionId}
onAdded={handleAdded}
/>
</div>
)
}
export default SupportingDataPanel

View File

@@ -0,0 +1,245 @@
import { useState, useEffect, useRef } from 'react'
import { Palette, Upload, Trash2, Loader2 } from 'lucide-react'
import { getBranding, updateBranding, deleteLogo } from '@/api/branding'
import type { BrandingInfo } from '@/api/branding'
import { Button } from '@/components/ui/Button'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
interface BrandingSettingsProps {
teamId: string
}
const MAX_LOGO_SIZE = 2 * 1024 * 1024 // 2MB
export function BrandingSettings({ teamId }: BrandingSettingsProps) {
const [branding, setBranding] = useState<BrandingInfo | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [companyName, setCompanyName] = useState('')
const [logoFile, setLogoFile] = useState<File | null>(null)
const [logoPreview, setLogoPreview] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
loadBranding()
}, [teamId])
const loadBranding = async () => {
setIsLoading(true)
try {
const data = await getBranding(teamId)
setBranding(data)
setCompanyName(data.company_display_name || '')
if (data.has_logo) {
// Construct logo URL from the API
const token = localStorage.getItem('access_token')
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
setLogoPreview(`${baseUrl}/api/v1/teams/${teamId}/branding/logo?token=${token}`)
}
} catch (err) {
console.error('Failed to load branding:', err)
} finally {
setIsLoading(false)
}
}
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (file.size > MAX_LOGO_SIZE) {
setError('Logo must be under 2MB')
return
}
if (!['image/png', 'image/jpeg', 'image/svg+xml'].includes(file.type)) {
setError('Only PNG, JPEG, and SVG files are supported')
return
}
setError(null)
setLogoFile(file)
const reader = new FileReader()
reader.onload = () => setLogoPreview(reader.result as string)
reader.readAsDataURL(file)
}
const handleSave = async () => {
setIsSaving(true)
setError(null)
try {
const formData = new FormData()
if (companyName.trim()) {
formData.append('company_display_name', companyName.trim())
} else {
formData.append('company_display_name', '')
}
if (logoFile) {
formData.append('logo', logoFile)
}
const updated = await updateBranding(teamId, formData)
setBranding(updated)
setLogoFile(null)
toast.success('Branding settings saved')
} catch (err) {
console.error('Failed to save branding:', err)
setError('Failed to save. Please try again.')
} finally {
setIsSaving(false)
}
}
const handleDeleteLogo = async () => {
setIsDeleting(true)
try {
await deleteLogo(teamId)
setBranding((prev) => prev ? { ...prev, has_logo: false, logo_content_type: null } : prev)
setLogoPreview(null)
setLogoFile(null)
if (fileInputRef.current) fileInputRef.current.value = ''
toast.success('Logo removed')
} catch (err) {
console.error('Failed to delete logo:', err)
toast.error('Failed to remove logo')
} finally {
setIsDeleting(false)
}
}
if (isLoading) {
return (
<div className="glass-card-static p-4 sm:p-6">
<div className="flex items-center gap-2">
<Palette className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Branding</h2>
</div>
<div className="mt-4 flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
</div>
)
}
const hasChanges = companyName !== (branding?.company_display_name || '') || logoFile !== null
return (
<div className="glass-card-static p-4 sm:p-6">
<div className="flex items-center gap-2">
<Palette className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Branding</h2>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Customize your company branding for PDF exports
</p>
<div className="mt-4 space-y-5">
{/* Company Display Name */}
<div>
<label htmlFor="company-name" className="block text-sm font-medium text-foreground">
Company Display Name
</label>
<p className="text-xs text-muted-foreground">
Shown in the header of exported PDF reports
</p>
<input
id="company-name"
type="text"
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder="Your Company Name"
className={cn(
'mt-2 w-full max-w-md rounded-md border border-border bg-card px-3 py-2 text-sm',
'text-foreground placeholder:text-muted-foreground',
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
/>
</div>
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-foreground">
Company Logo
</label>
<p className="text-xs text-muted-foreground">
PNG, JPEG, or SVG - max 2MB. Displayed in PDF export headers.
</p>
{logoPreview ? (
<div className="mt-2 flex items-start gap-4">
<div className="rounded-lg border border-border bg-card/50 p-3">
<img
src={logoPreview}
alt="Logo preview"
className="max-h-16 max-w-[200px] object-contain"
/>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="text-sm text-muted-foreground hover:text-foreground"
>
Change logo
</button>
{branding?.has_logo && (
<button
type="button"
onClick={handleDeleteLogo}
disabled={isDeleting}
className="flex items-center gap-1 text-sm text-rose-500 hover:text-rose-400 disabled:opacity-50"
>
{isDeleting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Trash2 className="h-3.5 w-3.5" />
)}
Remove logo
</button>
)}
</div>
</div>
) : (
<div
onClick={() => fileInputRef.current?.click()}
className={cn(
'mt-2 flex max-w-md cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border',
'bg-card/30 py-8 transition-colors hover:border-muted-foreground'
)}
>
<Upload className="h-6 w-6 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Click to upload logo</p>
</div>
)}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/svg+xml"
onChange={handleFileChange}
className="hidden"
/>
</div>
{/* Error */}
{error && (
<p className="text-sm text-rose-500">{error}</p>
)}
{/* Save */}
{hasChanges && (
<div className="pt-2">
<Button onClick={handleSave} loading={isSaving}>
{isSaving ? 'Saving...' : 'Save Branding'}
</Button>
</div>
)}
</div>
</div>
)
}
export default BrandingSettings