feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
- Sidebar: dynamic brand-accent colors, brainstorm section restyled - AI chat general: popup panel with expand/collapse, hides when contextual AI open - AI chat contextual: tabs reordered (Actions first), X close button, height fix - Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.) - Global color cleanup: emerald/orange hardcoded → brand-accent dynamic - Brainstorm page: orange → brand-accent throughout - PageEntry animation component added to key pages - Floating AI button: bg-brand-accent instead of hardcoded black - i18n: all 15 locales updated with new AI/billing keys - Billing: freemium quota tracking, BYOK, stripe subscription scaffolding - Admin: integrated into new design - AGENTS.md + CLAUDE.md project rules added
This commit is contained in:
@@ -1,14 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { DemoModeToggle } from '@/components/demo-mode-toggle'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2, Sparkles, Brain, Languages, Tag, History, Wand2 } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { motion } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface AISettingsPanelProps {
|
||||
initialSettings: {
|
||||
@@ -60,6 +59,20 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleHistoryModeChange = async (value: 'manual' | 'auto') => {
|
||||
setSettings(prev => ({ ...prev, noteHistoryMode: value }))
|
||||
try {
|
||||
setIsPending(true)
|
||||
await updateAISettings({ noteHistoryMode: value })
|
||||
toast.success(t('settings.settingsSaved'))
|
||||
} catch {
|
||||
toast.error(t('aiSettings.error'))
|
||||
setSettings(initialSettings)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDemoModeToggle = async (enabled: boolean) => {
|
||||
setSettings(prev => ({ ...prev, demoMode: enabled }))
|
||||
try {
|
||||
@@ -78,7 +91,7 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
{
|
||||
key: 'titleSuggestions',
|
||||
icon: Wand2,
|
||||
name: t('titleSuggestions.available').replace('💡 ', ''),
|
||||
name: t('aiSettings.titleSuggestions'),
|
||||
description: t('aiSettings.titleSuggestionsDesc'),
|
||||
value: settings.titleSuggestions,
|
||||
},
|
||||
@@ -120,7 +133,11 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="space-y-16 pb-20"
|
||||
>
|
||||
{isPending && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
@@ -128,88 +145,118 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feature toggles as cards */}
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-foreground mb-4">{t('aiSettings.features')}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{features.map(({ key, icon: Icon, name, description, value }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="bg-card rounded-lg border border-border p-5 shadow-sm flex items-start justify-between gap-4"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0 mt-0.5">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={value}
|
||||
onClick={() => handleToggle(key, !value)}
|
||||
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary/30 mt-0.5 ${value ? 'bg-primary' : 'bg-muted-foreground/30'}`}
|
||||
<div className="space-y-10">
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-sm font-bold text-ink border-b border-border/40 pb-4">
|
||||
{t('aiSettings.features')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{features.map(({ key, icon: Icon, name, description, value }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-6 flex items-center justify-between group hover:shadow-xl hover:shadow-brand-accent/5 transition-all duration-300"
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${value ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory Echo frequency — shown when enabled */}
|
||||
{settings.memoryEcho && (
|
||||
<div className="bg-card rounded-lg border border-border p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-1">{t('aiSettings.frequency')}</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">{t('aiSettings.frequencyDesc')}</p>
|
||||
<RadioGroup value={settings.memoryEchoFrequency} onValueChange={handleFrequencyChange} className="space-y-2">
|
||||
{[
|
||||
{ value: 'daily', label: t('aiSettings.frequencyDaily') },
|
||||
{ value: 'weekly', label: t('aiSettings.frequencyWeekly') },
|
||||
].map((opt) => (
|
||||
<div key={opt.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={opt.value} id={`freq-${opt.value}`} />
|
||||
<Label htmlFor={`freq-${opt.value}`} className="font-normal text-sm cursor-pointer">{opt.label}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note History mode — shown when enabled */}
|
||||
{settings.noteHistory && (
|
||||
<div className="bg-card rounded-lg border border-border p-5 shadow-sm">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-1">{t('notes.historyMode')}</h3>
|
||||
<RadioGroup
|
||||
value={settings.noteHistoryMode ?? 'manual'}
|
||||
onValueChange={(value) => {
|
||||
const mode = value as 'manual' | 'auto'
|
||||
setSettings(s => ({ ...s, noteHistoryMode: mode }))
|
||||
updateAISettings({ noteHistoryMode: mode }).then(() => toast.success(t('settings.settingsSaved')))
|
||||
}}
|
||||
className="space-y-3 mt-3"
|
||||
>
|
||||
{[
|
||||
{ value: 'manual', label: t('notes.historyModeManual'), desc: t('notes.historyModeManualDesc') },
|
||||
{ value: 'auto', label: t('notes.historyModeAuto'), desc: t('notes.historyModeAutoDesc') },
|
||||
].map((opt) => (
|
||||
<div key={opt.value} className="flex items-start gap-2">
|
||||
<RadioGroupItem value={opt.value} id={`history-${opt.value}`} className="mt-0.5" />
|
||||
<div>
|
||||
<Label htmlFor={`history-${opt.value}`} className="text-sm font-medium cursor-pointer">{opt.label}</Label>
|
||||
<p className="text-xs text-muted-foreground">{opt.desc}</p>
|
||||
<div className="flex items-center gap-5">
|
||||
<div className={cn(
|
||||
'p-3 bg-paper dark:bg-brand-accent/10 rounded-2xl border border-brand-accent/20 transition-all duration-300',
|
||||
'group-hover:bg-brand-accent group-hover:text-white group-hover:scale-110',
|
||||
'text-brand-accent'
|
||||
)}>
|
||||
<Icon size={18} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[13px] font-bold text-ink">{name}</h4>
|
||||
<p className="text-[10px] text-concrete leading-relaxed pr-4 line-clamp-2">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer shrink-0 ml-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={value}
|
||||
onChange={() => handleToggle(key, !value)}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-brand-accent" />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Demo Mode */}
|
||||
<DemoModeToggle demoMode={settings.demoMode} onToggle={handleDemoModeToggle} />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6">
|
||||
{settings.memoryEcho && (
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-10">
|
||||
<div className="space-y-1.5 text-left text-brand-accent">
|
||||
<h4 className="text-sm font-bold">{t('aiSettings.frequency')}</h4>
|
||||
<p className="text-[10px] opacity-60 uppercase tracking-wider font-semibold">
|
||||
{t('aiSettings.frequencyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{(['daily', 'weekly'] as const).map((opt) => (
|
||||
<label key={opt} className="flex items-center gap-4 cursor-pointer group">
|
||||
<input
|
||||
type="radio"
|
||||
name="freq"
|
||||
className="sr-only peer"
|
||||
checked={settings.memoryEchoFrequency === opt}
|
||||
onChange={() => handleFrequencyChange(opt)}
|
||||
/>
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-brand-accent flex items-center justify-center p-0.5 transition-all">
|
||||
<div className={cn(
|
||||
'w-full h-full rounded-full transition-all',
|
||||
settings.memoryEchoFrequency === opt ? 'bg-brand-accent' : 'bg-transparent'
|
||||
)} />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-ink group-hover:opacity-70 transition-opacity">
|
||||
{opt === 'daily' ? t('aiSettings.frequencyDaily') : t('aiSettings.frequencyWeekly')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.noteHistory && (
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-10">
|
||||
<div className="space-y-1.5 text-left text-brand-accent">
|
||||
<h4 className="text-sm font-bold">{t('notes.historyMode')}</h4>
|
||||
<p className="text-[10px] opacity-60 uppercase tracking-wider font-semibold">
|
||||
{t('aiSettings.noteHistoryDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{([
|
||||
{ value: 'manual' as const, label: t('notes.historyModeManual'), desc: t('notes.historyModeManualDesc') },
|
||||
{ value: 'auto' as const, label: t('notes.historyModeAuto'), desc: t('notes.historyModeAutoDesc') },
|
||||
]).map((opt) => (
|
||||
<label key={opt.value} className="flex items-center gap-4 cursor-pointer group">
|
||||
<input
|
||||
type="radio"
|
||||
name="hist"
|
||||
className="sr-only peer"
|
||||
checked={(settings.noteHistoryMode ?? 'manual') === opt.value}
|
||||
onChange={() => handleHistoryModeChange(opt.value)}
|
||||
/>
|
||||
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-brand-accent flex items-center justify-center p-0.5 transition-all">
|
||||
<div className={cn(
|
||||
'w-full h-full rounded-full transition-all',
|
||||
(settings.noteHistoryMode ?? 'manual') === opt.value ? 'bg-brand-accent' : 'bg-transparent'
|
||||
)} />
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-sm font-bold text-ink">{opt.label}</p>
|
||||
<p className="text-[10px] text-concrete">{opt.desc}</p>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DemoModeToggle demoMode={settings.demoMode} onToggle={handleDemoModeToggle} />
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
277
memento-note/components/ai/byok-settings-panel.tsx
Normal file
277
memento-note/components/ai/byok-settings-panel.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { KeyRound, Loader2, Shield, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type PublicKey = {
|
||||
provider: string
|
||||
alias: string
|
||||
model: string | null
|
||||
isActive: boolean
|
||||
lastUsedAt: string | null
|
||||
}
|
||||
|
||||
async function fetchByokKeys(): Promise<{
|
||||
keys: PublicKey[]
|
||||
allowedProviders: string[]
|
||||
}> {
|
||||
const res = await fetch('/api/user/api-keys')
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.message || body.error || 'Failed to load keys')
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function providerLabel(t: (key: string) => string, provider: string): string {
|
||||
const key = `byokSettings.providers.${provider}`
|
||||
const translated = t(key)
|
||||
return translated === key ? provider : translated
|
||||
}
|
||||
|
||||
export function ByokSettingsPanel() {
|
||||
const { t } = useLanguage()
|
||||
const queryClient = useQueryClient()
|
||||
const [provider, setProvider] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [alias, setAlias] = useState('')
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['user', 'api-keys'],
|
||||
queryFn: fetchByokKeys,
|
||||
})
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user', 'api-keys'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] })
|
||||
}, [queryClient])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await fetch('/api/user/api-keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider, apiKey, alias: alias || undefined }),
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw new Error(body.message || body.error || 'Save failed')
|
||||
}
|
||||
return body
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('byokSettings.saved'))
|
||||
setApiKey('')
|
||||
setAlias('')
|
||||
setProvider('')
|
||||
invalidate()
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || t('byokSettings.error'))
|
||||
},
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
provider: p,
|
||||
isActive,
|
||||
}: {
|
||||
provider: string
|
||||
isActive: boolean
|
||||
}) => {
|
||||
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Update failed')
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
onError: () => toast.error(t('byokSettings.error')),
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (p: string) => {
|
||||
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) throw new Error('Delete failed')
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('byokSettings.deleted'))
|
||||
invalidate()
|
||||
},
|
||||
onError: () => toast.error(t('byokSettings.error')),
|
||||
})
|
||||
|
||||
const allowed = data?.allowedProviders ?? []
|
||||
const keys = data?.keys ?? []
|
||||
const hasActiveByok = keys.some((k) => k.isActive)
|
||||
const tierBlocked = !isLoading && allowed.length === 0
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground p-5">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('byokSettings.loading')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-sm text-destructive p-5">{t('byokSettings.loadError')}</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="byok"
|
||||
className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 scroll-mt-6 space-y-6"
|
||||
>
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="p-3 bg-violet-500/10 rounded-2xl text-violet-500 border border-violet-500/20">
|
||||
<KeyRound size={18} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-[13px] font-bold text-ink flex items-center gap-2 flex-wrap">
|
||||
{t('byokSettings.title')}
|
||||
{hasActiveByok && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 text-[10px] font-medium">
|
||||
<Shield size={10} />
|
||||
{t('byokSettings.badgeActive')}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">{t('byokSettings.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tierBlocked ? (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 bg-amber-500/10 rounded-xl px-4 py-3">
|
||||
{t('byokSettings.tierRequired')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 border border-border/60 rounded-2xl p-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="byok-provider" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
|
||||
<Select
|
||||
value={provider}
|
||||
onValueChange={setProvider}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
<SelectTrigger id="byok-provider">
|
||||
<SelectValue placeholder={t('byokSettings.providerPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowed.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{providerLabel(t, p)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="byok-alias" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.alias')}</Label>
|
||||
<Input
|
||||
id="byok-alias"
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
placeholder={t('byokSettings.aliasPlaceholder')}
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="byok-key" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.apiKey')}</Label>
|
||||
<Input
|
||||
id="byok-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder={t('byokSettings.apiKeyPlaceholder')}
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!provider || apiKey.length < 8 || saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
className={cn(
|
||||
'px-6 py-3 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300',
|
||||
'bg-ink text-paper shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||
'disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('byokSettings.save')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keys.length > 0 && (
|
||||
<ul className="space-y-3">
|
||||
{keys.map((key) => (
|
||||
<li
|
||||
key={key.provider}
|
||||
className="flex items-center justify-between gap-3 rounded-2xl border border-border/60 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-bold text-ink">{providerLabel(t, key.provider)}</div>
|
||||
{key.alias ? (
|
||||
<p className="text-[10px] text-concrete truncate">{key.alias}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={key.isActive}
|
||||
onChange={(e) => toggleMutation.mutate({ provider: key.provider, isActive: e.target.checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-brand-accent" />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 rounded-lg flex items-center justify-center text-destructive/60 hover:text-destructive hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(t('byokSettings.confirmDelete'))) {
|
||||
deleteMutation.mutate(key.provider)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!tierBlocked && keys.length === 0 && (
|
||||
<p className="text-[11px] text-concrete">{t('byokSettings.empty')}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user