- 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>
246 lines
8.1 KiB
TypeScript
246 lines
8.1 KiB
TypeScript
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
|