Spaces:
Runtime error
Runtime error
nyk
feat: full i18n — 1752 keys across 10 languages, all panels translated (#326)
b180108 unverified | 'use client' | |
| import { useState, useEffect, useCallback } from 'react' | |
| import { useTranslations } from 'next-intl' | |
| import { Button } from '@/components/ui/button' | |
| interface EnvVarInfo { | |
| redacted: string | |
| set: boolean | |
| } | |
| interface Integration { | |
| id: string | |
| name: string | |
| category: string | |
| categoryLabel: string | |
| envVars: Record<string, EnvVarInfo> | |
| status: 'connected' | 'partial' | 'not_configured' | |
| vaultItem: string | null | |
| testable: boolean | |
| recommendation?: string | null | |
| } | |
| interface Category { | |
| id: string | |
| label: string | |
| } | |
| export function IntegrationsPanel() { | |
| const t = useTranslations('integrations') | |
| const [integrations, setIntegrations] = useState<Integration[]>([]) | |
| const [categories, setCategories] = useState<Category[]>([]) | |
| const [opAvailable, setOpAvailable] = useState(false) | |
| const [envPath, setEnvPath] = useState<string | null>(null) | |
| const [loading, setLoading] = useState(true) | |
| const [error, setError] = useState<string | null>(null) | |
| const [activeCategory, setActiveCategory] = useState<string>('ai') | |
| // Edits: integration id -> env var key -> new value | |
| const [edits, setEdits] = useState<Record<string, string>>({}) | |
| const [revealed, setRevealed] = useState<Set<string>>(new Set()) | |
| const [saving, setSaving] = useState(false) | |
| const [feedback, setFeedback] = useState<{ ok: boolean; text: string } | null>(null) | |
| const [testing, setTesting] = useState<string | null>(null) // integration id being tested | |
| const [pulling, setPulling] = useState<string | null>(null) // integration id being pulled | |
| const [pullingAll, setPullingAll] = useState(false) | |
| const [confirmRemove, setConfirmRemove] = useState<{ integrationId: string; keys: string[] } | null>(null) | |
| const showFeedback = (ok: boolean, text: string) => { | |
| setFeedback({ ok, text }) | |
| setTimeout(() => setFeedback(null), 3000) | |
| } | |
| const fetchIntegrations = useCallback(async () => { | |
| try { | |
| const res = await fetch('/api/integrations') | |
| if (res.status === 401 || res.status === 403) { | |
| setError('Admin access required') | |
| return | |
| } | |
| if (!res.ok) { | |
| setError('Failed to load integrations') | |
| return | |
| } | |
| const data = await res.json() | |
| setIntegrations(data.integrations || []) | |
| setCategories(data.categories || []) | |
| setOpAvailable(data.opAvailable ?? false) | |
| setEnvPath(data.envPath ?? null) | |
| if (data.categories?.[0]) { | |
| setActiveCategory(prev => { | |
| // Keep current if valid, otherwise default to first | |
| const ids = (data.categories as Category[]).map((c: Category) => c.id) | |
| return ids.includes(prev) ? prev : ids[0] | |
| }) | |
| } | |
| } catch { | |
| setError('Failed to load integrations') | |
| } finally { | |
| setLoading(false) | |
| } | |
| }, []) | |
| useEffect(() => { fetchIntegrations() }, [fetchIntegrations]) | |
| const handleEdit = (envKey: string, value: string) => { | |
| setEdits(prev => ({ ...prev, [envKey]: value })) | |
| } | |
| const cancelEdit = (envKey: string) => { | |
| setEdits(prev => { | |
| const next = { ...prev } | |
| delete next[envKey] | |
| return next | |
| }) | |
| } | |
| const toggleReveal = (envKey: string) => { | |
| setRevealed(prev => { | |
| const next = new Set(prev) | |
| if (next.has(envKey)) next.delete(envKey) | |
| else next.add(envKey) | |
| return next | |
| }) | |
| } | |
| const hasChanges = Object.keys(edits).length > 0 | |
| const handleSave = async () => { | |
| if (!hasChanges) return | |
| setSaving(true) | |
| try { | |
| const res = await fetch('/api/integrations', { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ vars: edits }), | |
| }) | |
| const data = await res.json() | |
| if (res.ok) { | |
| showFeedback(true, `Saved ${data.count} variable${data.count === 1 ? '' : 's'}`) | |
| setEdits({}) | |
| setRevealed(new Set()) | |
| fetchIntegrations() | |
| } else { | |
| showFeedback(false, data.error || 'Failed to save') | |
| } | |
| } catch { | |
| showFeedback(false, 'Network error') | |
| } finally { | |
| setSaving(false) | |
| } | |
| } | |
| const handleDiscard = () => { | |
| setEdits({}) | |
| setRevealed(new Set()) | |
| } | |
| const handleRemove = async (envKeys: string[]) => { | |
| try { | |
| const res = await fetch(`/api/integrations?keys=${encodeURIComponent(envKeys.join(','))}`, { | |
| method: 'DELETE', | |
| }) | |
| const data = await res.json() | |
| if (res.ok) { | |
| showFeedback(true, `Removed ${data.count} variable${data.count === 1 ? '' : 's'}`) | |
| fetchIntegrations() | |
| } else { | |
| showFeedback(false, data.error || 'Failed to remove') | |
| } | |
| } catch { | |
| showFeedback(false, 'Network error') | |
| } | |
| } | |
| const handleTest = async (integrationId: string) => { | |
| setTesting(integrationId) | |
| try { | |
| const res = await fetch('/api/integrations', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ action: 'test', integrationId }), | |
| }) | |
| const data = await res.json() | |
| if (data.ok) { | |
| showFeedback(true, data.detail || 'Connection successful') | |
| } else { | |
| showFeedback(false, data.detail || data.error || 'Test failed') | |
| } | |
| } catch { | |
| showFeedback(false, 'Network error') | |
| } finally { | |
| setTesting(null) | |
| } | |
| } | |
| const handlePull = async (integrationId: string) => { | |
| setPulling(integrationId) | |
| try { | |
| const res = await fetch('/api/integrations', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ action: 'pull', integrationId }), | |
| }) | |
| const data = await res.json() | |
| if (data.ok) { | |
| showFeedback(true, data.detail || 'Pulled from 1Password') | |
| fetchIntegrations() | |
| } else { | |
| showFeedback(false, data.error || 'Pull failed') | |
| } | |
| } catch { | |
| showFeedback(false, 'Network error') | |
| } finally { | |
| setPulling(null) | |
| } | |
| } | |
| const handlePullAll = async () => { | |
| setPullingAll(true) | |
| try { | |
| const res = await fetch('/api/integrations', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ action: 'pull-all', category: activeCategory }), | |
| }) | |
| const data = await res.json() | |
| if (data.ok) { | |
| showFeedback(true, data.detail || 'Pulled from 1Password') | |
| fetchIntegrations() | |
| } else { | |
| showFeedback(false, data.error || 'Pull failed') | |
| } | |
| } catch { | |
| showFeedback(false, 'Network error') | |
| } finally { | |
| setPullingAll(false) | |
| } | |
| } | |
| const confirmAndRemove = (integrationId: string, keys: string[]) => { | |
| setConfirmRemove({ integrationId, keys }) | |
| } | |
| // Loading state | |
| if (loading) { | |
| return ( | |
| <div className="p-6 flex items-center gap-2"> | |
| <div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" /> | |
| <span className="text-sm text-muted-foreground">{t('loading')}</span> | |
| </div> | |
| ) | |
| } | |
| // Error state | |
| if (error) { | |
| return ( | |
| <div className="p-6"> | |
| <div className="bg-destructive/10 text-destructive rounded-lg p-4 text-sm">{error}</div> | |
| </div> | |
| ) | |
| } | |
| const filteredIntegrations = integrations.filter(i => i.category === activeCategory) | |
| const connectedCount = integrations.filter(i => i.status === 'connected').length | |
| return ( | |
| <div className="p-4 md:p-6 max-w-4xl mx-auto space-y-6"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <h2 className="text-lg font-semibold text-foreground">{t('title')}</h2> | |
| <p className="text-xs text-muted-foreground mt-0.5"> | |
| {t('connectedCount', { connected: connectedCount, total: integrations.length })} | |
| {envPath && <span className="ml-2 font-mono text-muted-foreground/50">{envPath}</span>} | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {opAvailable && ( | |
| <> | |
| <span className="text-2xs px-2 py-1 rounded bg-green-500/10 text-green-400 flex items-center gap-1"> | |
| <span className="w-1.5 h-1.5 rounded-full bg-green-500" /> | |
| 1P CLI | |
| </span> | |
| <Button | |
| onClick={handlePullAll} | |
| disabled={pullingAll} | |
| variant="outline" | |
| size="sm" | |
| className="flex items-center gap-1.5" | |
| title="Pull all vault-backed integrations in this category from 1Password" | |
| > | |
| {pullingAll ? ( | |
| <div className="w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin" /> | |
| ) : ( | |
| <svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M8 2v8M5 7l3 3 3-3" /> | |
| <path d="M3 12v2h10v-2" /> | |
| </svg> | |
| )} | |
| {t('pullAll')} | |
| </Button> | |
| </> | |
| )} | |
| {hasChanges && ( | |
| <Button | |
| onClick={handleDiscard} | |
| variant="outline" | |
| size="sm" | |
| > | |
| {t('discard')} | |
| </Button> | |
| )} | |
| <Button | |
| onClick={handleSave} | |
| disabled={!hasChanges || saving} | |
| variant={hasChanges ? 'default' : 'secondary'} | |
| size="sm" | |
| className={!hasChanges ? 'cursor-not-allowed' : ''} | |
| > | |
| {saving ? t('saving') : t('saveChanges')} | |
| </Button> | |
| </div> | |
| </div> | |
| {/* Feedback */} | |
| {feedback && ( | |
| <div className={`rounded-lg p-3 text-xs font-medium ${ | |
| feedback.ok ? 'bg-green-500/10 text-green-400' : 'bg-destructive/10 text-destructive' | |
| }`}> | |
| {feedback.text} | |
| </div> | |
| )} | |
| {/* Category tabs */} | |
| <div className="flex gap-1 border-b border-border pb-px overflow-x-auto"> | |
| {categories.map(cat => { | |
| const catIntegrations = integrations.filter(i => i.category === cat.id) | |
| const catConnected = catIntegrations.filter(i => i.status === 'connected').length | |
| return ( | |
| <Button | |
| key={cat.id} | |
| onClick={() => setActiveCategory(cat.id)} | |
| variant="ghost" | |
| size="sm" | |
| className={`rounded-t-md rounded-b-none relative whitespace-nowrap ${ | |
| activeCategory === cat.id | |
| ? 'bg-card text-foreground border border-border border-b-card -mb-px' | |
| : '' | |
| }`} | |
| > | |
| {cat.label} | |
| {catConnected > 0 && ( | |
| <span className="ml-1.5 inline-flex items-center justify-center min-w-[16px] h-4 text-2xs rounded-full bg-green-500/15 text-green-400 px-1"> | |
| {catConnected} | |
| </span> | |
| )} | |
| </Button> | |
| ) | |
| })} | |
| </div> | |
| {/* Integration cards */} | |
| <div className="space-y-3"> | |
| {filteredIntegrations.map(integration => ( | |
| <IntegrationCard | |
| key={integration.id} | |
| integration={integration} | |
| edits={edits} | |
| revealed={revealed} | |
| opAvailable={opAvailable} | |
| testing={testing === integration.id} | |
| pulling={pulling === integration.id} | |
| onEdit={handleEdit} | |
| onCancelEdit={cancelEdit} | |
| onToggleReveal={toggleReveal} | |
| onTest={() => handleTest(integration.id)} | |
| onPull={() => handlePull(integration.id)} | |
| onRemove={() => { | |
| const setKeys = Object.entries(integration.envVars) | |
| .filter(([, v]) => v.set) | |
| .map(([k]) => k) | |
| if (setKeys.length > 0) confirmAndRemove(integration.id, setKeys) | |
| }} | |
| /> | |
| ))} | |
| {filteredIntegrations.length === 0 && ( | |
| <div className="text-sm text-muted-foreground text-center py-8"> | |
| {t('noIntegrationsInCategory')} | |
| </div> | |
| )} | |
| </div> | |
| {/* Unsaved changes bar */} | |
| {hasChanges && ( | |
| <div className="fixed bottom-4 left-1/2 -translate-x-1/2 bg-card border border-border rounded-lg shadow-lg px-4 py-2.5 flex items-center gap-3 z-40"> | |
| <div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" /> | |
| <span className="text-xs text-foreground"> | |
| {Object.keys(edits).length} unsaved change{Object.keys(edits).length === 1 ? '' : 's'} | |
| </span> | |
| <Button | |
| onClick={handleDiscard} | |
| variant="ghost" | |
| size="xs" | |
| > | |
| {t('discard')} | |
| </Button> | |
| <Button | |
| onClick={handleSave} | |
| disabled={saving} | |
| size="xs" | |
| > | |
| {saving ? t('saving') : t('save')} | |
| </Button> | |
| </div> | |
| )} | |
| {/* Remove confirmation dialog */} | |
| {confirmRemove && ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"> | |
| <div className="bg-card border border-border rounded-lg shadow-xl p-5 max-w-sm mx-4 space-y-4"> | |
| <h3 className="text-sm font-semibold text-foreground">{t('removeTitle')}</h3> | |
| <p className="text-xs text-muted-foreground"> | |
| {t('removeDescription', { | |
| target: confirmRemove.keys.length === 1 ? confirmRemove.keys[0] : String(confirmRemove.keys.length) | |
| })} | |
| </p> | |
| <div className="flex justify-end gap-2"> | |
| <Button | |
| onClick={() => setConfirmRemove(null)} | |
| variant="outline" | |
| size="sm" | |
| > | |
| {t('cancel')} | |
| </Button> | |
| <Button | |
| onClick={() => { | |
| handleRemove(confirmRemove.keys) | |
| setConfirmRemove(null) | |
| }} | |
| variant="destructive" | |
| size="sm" | |
| > | |
| {t('remove')} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Integration card component | |
| // --------------------------------------------------------------------------- | |
| function IntegrationCard({ | |
| integration, | |
| edits, | |
| revealed, | |
| opAvailable, | |
| testing, | |
| pulling, | |
| onEdit, | |
| onCancelEdit, | |
| onToggleReveal, | |
| onTest, | |
| onPull, | |
| onRemove, | |
| }: { | |
| integration: Integration | |
| edits: Record<string, string> | |
| revealed: Set<string> | |
| opAvailable: boolean | |
| testing: boolean | |
| pulling: boolean | |
| onEdit: (key: string, value: string) => void | |
| onCancelEdit: (key: string) => void | |
| onToggleReveal: (key: string) => void | |
| onTest: () => void | |
| onPull: () => void | |
| onRemove: () => void | |
| }) { | |
| const t = useTranslations('integrations') | |
| const statusColors = { | |
| connected: 'bg-green-500', | |
| partial: 'bg-amber-500', | |
| not_configured: 'bg-muted-foreground/30', | |
| } | |
| const statusLabels = { | |
| connected: 'Connected', | |
| partial: 'Partial', | |
| not_configured: 'Not configured', | |
| } | |
| const hasEdits = Object.keys(integration.envVars).some(k => edits[k] !== undefined) | |
| const hasSetVars = Object.values(integration.envVars).some(v => v.set) | |
| return ( | |
| <div className={`bg-card border rounded-lg p-4 transition-colors ${ | |
| hasEdits ? 'border-primary/50' : 'border-border' | |
| }`}> | |
| {/* Card header */} | |
| <div className="flex items-center justify-between mb-3"> | |
| <div className="flex items-center gap-2.5"> | |
| <span className={`w-2 h-2 rounded-full shrink-0 ${statusColors[integration.status]}`} /> | |
| <span className="text-sm font-medium text-foreground">{integration.name}</span> | |
| <span className="text-2xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground"> | |
| {statusLabels[integration.status]} | |
| </span> | |
| </div> | |
| <div className="flex items-center gap-1.5"> | |
| {/* Pull from 1Password */} | |
| {integration.vaultItem && opAvailable && ( | |
| <Button | |
| onClick={onPull} | |
| disabled={pulling} | |
| title="Pull from 1Password" | |
| variant="outline" | |
| size="xs" | |
| className="text-2xs flex items-center gap-1" | |
| > | |
| {pulling ? ( | |
| <div className="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin" /> | |
| ) : ( | |
| <svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M8 2v8M5 7l3 3 3-3" /> | |
| <path d="M3 12v2h10v-2" /> | |
| </svg> | |
| )} | |
| 1P | |
| </Button> | |
| )} | |
| {/* Test connection */} | |
| {integration.testable && hasSetVars && ( | |
| <Button | |
| onClick={onTest} | |
| disabled={testing} | |
| title="Test connection" | |
| variant="outline" | |
| size="xs" | |
| className="text-2xs flex items-center gap-1" | |
| > | |
| {testing ? ( | |
| <div className="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin" /> | |
| ) : ( | |
| <svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M13 3L6 14" /> | |
| <polyline points="6,3 6,8 1,8" /> | |
| <polyline points="10,8 15,8 15,13" /> | |
| </svg> | |
| )} | |
| Test | |
| </Button> | |
| )} | |
| {/* Remove */} | |
| {hasSetVars && ( | |
| <Button | |
| onClick={onRemove} | |
| title="Remove from .env" | |
| variant="outline" | |
| size="xs" | |
| className="text-2xs hover:text-destructive hover:border-destructive/50" | |
| > | |
| {t('remove')} | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| {/* Env var rows */} | |
| <div className="space-y-2"> | |
| {Object.entries(integration.envVars).map(([envKey, info]) => { | |
| const isEditing = edits[envKey] !== undefined | |
| const isRevealed = revealed.has(envKey) | |
| return ( | |
| <div key={envKey} className="flex items-center gap-2"> | |
| <span className="text-2xs font-mono text-muted-foreground/70 w-48 truncate shrink-0" title={envKey}> | |
| {envKey} | |
| </span> | |
| <div className="flex-1 flex items-center gap-1.5"> | |
| {isEditing ? ( | |
| <input | |
| type={isRevealed ? 'text' : 'password'} | |
| value={edits[envKey]} | |
| onChange={e => onEdit(envKey, e.target.value)} | |
| placeholder="Enter value..." | |
| className="flex-1 px-2 py-1 text-xs bg-background border border-primary/50 rounded focus:border-primary focus:outline-none font-mono" | |
| autoComplete="off" | |
| data-1p-ignore | |
| /> | |
| ) : info.set ? ( | |
| <span className="text-xs font-mono text-muted-foreground">{info.redacted}</span> | |
| ) : ( | |
| <span className="text-xs text-muted-foreground/50 italic">{t('notSet')}</span> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-1 shrink-0"> | |
| {/* Reveal toggle (only when editing) */} | |
| {isEditing && ( | |
| <Button | |
| onClick={() => onToggleReveal(envKey)} | |
| title={isRevealed ? 'Hide value' : 'Show value'} | |
| variant="ghost" | |
| size="icon-xs" | |
| className="w-6 h-6" | |
| > | |
| {isRevealed ? <EyeOffIcon /> : <EyeIcon />} | |
| </Button> | |
| )} | |
| {/* Edit button */} | |
| {!isEditing && ( | |
| <Button | |
| onClick={() => onEdit(envKey, '')} | |
| title="Edit value" | |
| variant="ghost" | |
| size="icon-xs" | |
| className="w-6 h-6" | |
| > | |
| <EditIcon /> | |
| </Button> | |
| )} | |
| {/* Cancel edit */} | |
| {isEditing && ( | |
| <Button | |
| onClick={() => onCancelEdit(envKey)} | |
| title="Cancel edit" | |
| variant="ghost" | |
| size="icon-xs" | |
| className="w-6 h-6 hover:text-destructive" | |
| > | |
| <XIcon /> | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| {integration.recommendation && ( | |
| <div className="mt-3 rounded-md border border-border/60 bg-secondary/30 px-2.5 py-2"> | |
| <p className="text-2xs text-muted-foreground">{integration.recommendation}</p> | |
| {integration.id === 'x_twitter' && ( | |
| <div className="mt-1.5 flex flex-wrap items-center gap-2 text-2xs"> | |
| <a | |
| href="https://github.com/0xNyk/xint" | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="text-primary hover:underline" | |
| > | |
| github.com/0xNyk/xint | |
| </a> | |
| <a | |
| href="https://github.com/0xNyk/xint-rs" | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="text-primary hover:underline" | |
| > | |
| github.com/0xNyk/xint-rs | |
| </a> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Inline SVG icons (matching nav-rail pattern: 16x16, stroke-based) | |
| // --------------------------------------------------------------------------- | |
| function EyeIcon() { | |
| return ( | |
| <svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M1 8s2.5-5 7-5 7 5 7 5-2.5 5-7 5-7-5-7-5z" /> | |
| <circle cx="8" cy="8" r="2" /> | |
| </svg> | |
| ) | |
| } | |
| function EyeOffIcon() { | |
| return ( | |
| <svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M2 2l12 12" /> | |
| <path d="M6.5 6.5a2 2 0 002.8 2.8" /> | |
| <path d="M4.2 4.2C2.5 5.5 1 8 1 8s2.5 5 7 5c1.3 0 2.4-.4 3.4-1" /> | |
| <path d="M11.8 11.8C13.5 10.5 15 8 15 8s-2.5-5-7-5c-.7 0-1.4.1-2 .3" /> | |
| </svg> | |
| ) | |
| } | |
| function EditIcon() { | |
| return ( | |
| <svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M11.5 1.5l3 3L5 14H2v-3l9.5-9.5z" /> | |
| </svg> | |
| ) | |
| } | |
| function XIcon() { | |
| return ( | |
| <svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> | |
| <path d="M4 4l8 8M12 4l-8 8" /> | |
| </svg> | |
| ) | |
| } | |