fix(ui): thème, textes et lisibilité restants de la revue
Les boutons suivent la couleur d’apparence, les libellés trop petits ou trop techniques sont clarifiés, et le catalogue des fournisseurs se met à jour tout seul. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -55,6 +55,16 @@ const typeConfig: Record<string, { icon: typeof Globe }> = {
|
||||
'task-extractor': { icon: ListChecks },
|
||||
}
|
||||
|
||||
function kebabToCamel(value: string) {
|
||||
return value.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
function resolveStoredLabel(t: (key: string) => string, value: string) {
|
||||
if (!value.startsWith('agents.')) return value
|
||||
const translated = t(value)
|
||||
return translated !== value ? translated : value
|
||||
}
|
||||
|
||||
const frequencyKeys: Record<string, string> = {
|
||||
manual: 'agents.frequencies.manual',
|
||||
hourly: 'agents.frequencies.hourly',
|
||||
@@ -209,9 +219,9 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[13px] font-bold text-foreground">{agent.name}</h4>
|
||||
<h4 className="text-[13px] font-bold text-foreground">{resolveStoredLabel(t, agent.name)}</h4>
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground opacity-60">
|
||||
{t(`agents.types.${agent.type || 'custom'}`)}
|
||||
{t(`agents.types.${kebabToCamel(agent.type || 'custom')}`)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,7 +247,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
|
||||
|
||||
{agent.description && (
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
|
||||
{agent.description}
|
||||
{resolveStoredLabel(t, agent.description)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
|
||||
@@ -63,14 +63,26 @@ const templateConfig = [
|
||||
type TemplateId = typeof templateConfig[number]['id']
|
||||
type CategoryId = 'all' | 'veille' | 'digest' | 'tools' | 'generate'
|
||||
|
||||
const CATEGORIES: { id: CategoryId; label: string }[] = [
|
||||
{ id: 'all', label: 'Tous' },
|
||||
{ id: 'veille', label: '📡 Veille' },
|
||||
{ id: 'digest', label: '📰 Digest' },
|
||||
{ id: 'tools', label: '🔧 Outils' },
|
||||
{ id: 'generate', label: '✨ Génération' },
|
||||
const CATEGORIES: { id: CategoryId; labelKey: string }[] = [
|
||||
{ id: 'all', labelKey: 'agents.templates.categoryAll' },
|
||||
{ id: 'veille', labelKey: 'agents.templates.categoryWatch' },
|
||||
{ id: 'digest', labelKey: 'agents.templates.categoryDigest' },
|
||||
{ id: 'tools', labelKey: 'agents.templates.categoryTools' },
|
||||
{ id: 'generate', labelKey: 'agents.templates.categoryGenerate' },
|
||||
]
|
||||
|
||||
const PREVIEW_COUNT = 3
|
||||
|
||||
function templateNameKey(id: string) {
|
||||
return `agents.templates.${id}.name`
|
||||
}
|
||||
|
||||
function hasTranslatedName(t: (key: string) => string, id: string) {
|
||||
const key = templateNameKey(id)
|
||||
const name = t(key)
|
||||
return Boolean(name.trim()) && name !== key
|
||||
}
|
||||
|
||||
const typeIcons: Record<string, typeof Globe> = {
|
||||
scraper: Globe,
|
||||
researcher: Search,
|
||||
@@ -98,6 +110,7 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
const { t } = useLanguage()
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryId>('all')
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
|
||||
const handleInstall = async (tpl: typeof templateConfig[number]) => {
|
||||
setInstallingId(tpl.id)
|
||||
@@ -142,55 +155,62 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = activeCategory === 'all'
|
||||
const filtered = (activeCategory === 'all'
|
||||
? templateConfig
|
||||
: templateConfig.filter((tpl) => tpl.category === activeCategory)
|
||||
).filter((tpl) => hasTranslatedName(t, tpl.id))
|
||||
|
||||
const visible = showAll ? filtered : filtered.slice(0, PREVIEW_COUNT)
|
||||
const hiddenCount = filtered.length - visible.length
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Category filter */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setActiveCategory(cat.id)
|
||||
setShowAll(false)
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-semibold transition-all border ${
|
||||
activeCategory === cat.id
|
||||
? 'bg-ink text-paper border-ink'
|
||||
: 'bg-paper text-muted-ink border-border/40 hover:border-ink/30'
|
||||
}`}
|
||||
>
|
||||
{cat.label}
|
||||
{t(cat.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Template grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filtered.map(tpl => {
|
||||
{visible.map(tpl => {
|
||||
const Icon = templateIcons[tpl.id as TemplateId] ?? typeIcons[tpl.type] ?? Settings
|
||||
const isInstalling = installingId === tpl.id
|
||||
const nameKey = `agents.templates.${tpl.id}.name`
|
||||
const nameKey = templateNameKey(tpl.id)
|
||||
const descKey = `agents.templates.${tpl.id}.description`
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tpl.id}
|
||||
className="bg-card/40 border border-dashed border-border rounded-2xl p-6 group cursor-pointer hover:bg-card hover:border-foreground/20 transition-all"
|
||||
className="bg-card/40 border border-dashed border-border rounded-2xl p-6 hover:bg-card hover:border-foreground/20 transition-all"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center text-muted-foreground group-hover:bg-foreground group-hover:text-background mb-4 transition-all">
|
||||
<div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center text-muted-foreground mb-4">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h4 className="text-[13px] font-bold text-foreground">{t(nameKey)}</h4>
|
||||
{tpl.frequency !== 'manual' && (
|
||||
<span className="text-[10px] font-semibold text-concrete bg-border/20 rounded-full px-2 py-0.5 shrink-0">
|
||||
{tpl.frequency === 'daily' ? '📅 Quotidien' : '📆 Hebdo'}
|
||||
{t(`agents.frequencies.${tpl.frequency}`)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed mb-4">{t(descKey)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleInstall(tpl)}
|
||||
disabled={isInstalling}
|
||||
className="text-[11px] font-bold uppercase tracking-widest text-foreground hover:opacity-60 transition-opacity flex items-center gap-2 disabled:opacity-50"
|
||||
@@ -211,6 +231,25 @@ export function AgentTemplates({ onInstalled, existingAgentNames }: AgentTemplat
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{hiddenCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll(true)}
|
||||
className="text-[12px] font-semibold text-foreground hover:opacity-70 transition-opacity"
|
||||
>
|
||||
{t('agents.templates.seeAll')}
|
||||
</button>
|
||||
)}
|
||||
{showAll && filtered.length > PREVIEW_COUNT && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll(false)}
|
||||
className="text-[12px] font-semibold text-foreground hover:opacity-70 transition-opacity"
|
||||
>
|
||||
{t('agents.templates.showLess')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,26 +16,16 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ─── Provider display info ────────────────────────────────────────────────────
|
||||
const PROVIDER_INFO: Record<string, { name: string; hint: string }> = {
|
||||
openai: { name: 'OpenAI', hint: 'GPT-4o, GPT-4' },
|
||||
anthropic: { name: 'Anthropic', hint: 'Claude 3.5 Sonnet, Haiku' },
|
||||
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
|
||||
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
|
||||
deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' },
|
||||
openrouter: { name: 'OpenRouter', hint: 'Multi-provider access' },
|
||||
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
|
||||
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
|
||||
zai: { name: 'Zuki Journey', hint: 'OpenAI/Anthropic proxy' },
|
||||
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Anthropic-compatible proxy' },
|
||||
custom_openai: { name: 'Compatible OpenAI', hint: 'Any OpenAI-compatible proxy' },
|
||||
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Any Anthropic-compatible proxy' },
|
||||
custom: { name: 'Custom API', hint: 'Your own endpoint' },
|
||||
}
|
||||
import { providerDisplayName } from '@/lib/ai/provider-labels'
|
||||
import { PROVIDER_MODEL_SUGGESTIONS } from '@/lib/ai/models-list'
|
||||
|
||||
function displayName(provider: string): string {
|
||||
return PROVIDER_INFO[provider]?.name ?? provider
|
||||
return providerDisplayName(provider)
|
||||
}
|
||||
|
||||
function providerHint(provider: string): string {
|
||||
const models = PROVIDER_MODEL_SUGGESTIONS[provider] ?? []
|
||||
return models.slice(0, 2).join(', ')
|
||||
}
|
||||
|
||||
const MANUAL_MODEL_PROVIDERS = new Set(['custom'])
|
||||
@@ -155,20 +145,20 @@ function EditKeyForm({
|
||||
const showModelInput = manualModel || (!loadingModels && models.length === 0)
|
||||
|
||||
return (
|
||||
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-violet-600 dark:text-violet-400">
|
||||
<div className="mt-2 border border-brand-accent/20 rounded-xl bg-brand-accent/5 p-4 space-y-3">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wider text-brand-accent">
|
||||
{t('byok.editLabel', { name: displayName(savedKey.provider) })}
|
||||
</p>
|
||||
|
||||
{/* Alias */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')}</Label>
|
||||
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.aliasLabel')}</Label>
|
||||
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} />
|
||||
</div>
|
||||
{needsUrl && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
|
||||
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.apiUrl')}</Label>
|
||||
<Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
|
||||
</div>
|
||||
)}
|
||||
@@ -176,7 +166,7 @@ function EditKeyForm({
|
||||
|
||||
{/* Model */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
|
||||
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.model')}</Label>
|
||||
{showModelDropdown ? (
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder={t('byok.choose')} /></SelectTrigger>
|
||||
@@ -189,7 +179,7 @@ function EditKeyForm({
|
||||
|
||||
{/* Key rotation (optional) */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">
|
||||
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[13px] font-medium uppercase tracking-wider text-concrete">
|
||||
{t('byok.newKey')} <span className="normal-case font-normal text-concrete">{t('byok.newKeyHint')}</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -205,7 +195,7 @@ function EditKeyForm({
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div className={cn(
|
||||
'flex items-start gap-2 rounded-lg px-3 py-2 text-[10px] border',
|
||||
'flex items-start gap-2 rounded-lg px-3 py-2 text-[13px] border',
|
||||
testResult.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-400' : 'bg-rose-500/10 border-rose-500/20 text-rose-700 dark:text-rose-400'
|
||||
)}>
|
||||
{testResult.ok ? <CheckCircle2 size={12} className="shrink-0 mt-0.5" /> : <XCircle size={12} className="shrink-0 mt-0.5" />}
|
||||
@@ -224,7 +214,7 @@ function EditKeyForm({
|
||||
type="button"
|
||||
disabled={!newKey || newKey.length < 8 || testing}
|
||||
onClick={testModel}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-violet-400 transition-colors disabled:opacity-40 disabled:pointer-events-none"
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[13px] font-semibold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-brand-accent/50 transition-colors disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
|
||||
{t('byok.test')}
|
||||
@@ -233,7 +223,7 @@ function EditKeyForm({
|
||||
type="button"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
className="flex-1 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.12em] bg-ink text-paper shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
|
||||
className="flex-1 py-2 rounded-lg text-[13px] font-semibold uppercase tracking-[0.12em] bg-brand-accent text-white shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('byok.saveChanges')}
|
||||
</Button>
|
||||
@@ -286,13 +276,6 @@ export function ByokSettingsPanel() {
|
||||
setTestResult(null)
|
||||
setModel('')
|
||||
setModels([])
|
||||
if (MANUAL_MODEL_PROVIDERS.has(p)) return
|
||||
setLoadingModels(true)
|
||||
try {
|
||||
const list = await fetchModelsFromServer(p)
|
||||
setModels(list)
|
||||
if (list.length > 0) setModel(list[0])
|
||||
} finally { setLoadingModels(false) }
|
||||
}
|
||||
|
||||
async function refreshModels(p: string, key: string, _baseUrl?: string) {
|
||||
@@ -403,10 +386,10 @@ export function ByokSettingsPanel() {
|
||||
|
||||
{/* Header */}
|
||||
<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="p-3 bg-brand-accent/10 rounded-2xl text-brand-accent border border-brand-accent/20"><KeyRound size={18} /></div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<h3 className="text-[13px] font-bold text-ink">{t('byokSettings.title')}</h3>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">{t('byokSettings.description')}</p>
|
||||
<p className="text-sm text-concrete leading-relaxed">{t('byokSettings.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -419,7 +402,7 @@ export function ByokSettingsPanel() {
|
||||
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
|
||||
)}>
|
||||
{activeKey ? (
|
||||
<><Zap size={14} className="shrink-0" /><span>{t('byok.byokActive')} · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
|
||||
<><Zap size={14} className="shrink-0" /><span>{t('byok.byokActive')} · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[13px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
|
||||
) : (
|
||||
<><Shield size={14} className="shrink-0" /><span>{t('byok.noActiveKey')}</span></>
|
||||
)}
|
||||
@@ -433,7 +416,7 @@ export function ByokSettingsPanel() {
|
||||
{/* Saved keys */}
|
||||
{keys.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byok.savedKeys')}</p>
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wider text-concrete">{t('byok.savedKeys')}</p>
|
||||
<ul className="space-y-1">
|
||||
{keys.map((key) => (
|
||||
<li key={key.provider}>
|
||||
@@ -447,9 +430,9 @@ export function ByokSettingsPanel() {
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-ink leading-tight">{displayName(key.provider)}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{key.model && <code className="text-[9px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
|
||||
{key.alias && <span className="text-[9px] text-concrete">{key.alias}</span>}
|
||||
<span className={cn('text-[9px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
|
||||
{key.model && <code className="text-[13px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
|
||||
{key.alias && <span className="text-[13px] text-concrete">{key.alias}</span>}
|
||||
<span className={cn('text-[13px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
|
||||
{key.isActive ? t('byok.activeStatus') : t('byok.inactiveStatus')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -462,7 +445,7 @@ export function ByokSettingsPanel() {
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-lg flex items-center justify-center transition-colors',
|
||||
editingProvider === key.provider
|
||||
? 'text-violet-600 bg-violet-500/10 border border-violet-500/30'
|
||||
? 'text-brand-accent bg-brand-accent/10 border border-brand-accent/30'
|
||||
: 'text-concrete hover:text-ink hover:bg-muted border border-transparent'
|
||||
)}
|
||||
onClick={() => setEditingProvider(editingProvider === key.provider ? null : key.provider)}
|
||||
@@ -492,7 +475,7 @@ export function ByokSettingsPanel() {
|
||||
|
||||
{/* Inline edit form */}
|
||||
{editingProvider === key.provider && (
|
||||
<div className="border border-violet-500/20 border-t-0 rounded-b-xl overflow-hidden">
|
||||
<div className="border border-brand-accent/20 border-t-0 rounded-b-xl overflow-hidden">
|
||||
<EditKeyForm
|
||||
savedKey={key}
|
||||
onDone={() => setEditingProvider(null)}
|
||||
@@ -508,40 +491,40 @@ export function ByokSettingsPanel() {
|
||||
|
||||
{/* Add key form */}
|
||||
<div className="space-y-4 border border-border/60 rounded-2xl p-5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wider text-concrete">
|
||||
{keys.length > 0 ? t('byok.addOrReplace') : t('byok.connectProvider')}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
|
||||
<Label htmlFor="byok-provider" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byokSettings.provider')}</Label>
|
||||
<Select value={provider} onValueChange={onProviderChange} disabled={saveMutation.isPending}>
|
||||
<SelectTrigger id="byok-provider"><SelectValue placeholder={t('byok.chooseProvider')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowed.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
<span className="font-medium">{displayName(p)}</span>
|
||||
{PROVIDER_INFO[p]?.hint && <span className="ml-2 text-[10px] text-concrete">{PROVIDER_INFO[p].hint}</span>}
|
||||
{providerHint(p) && <span className="ml-2 text-[13px] text-concrete">{providerHint(p)}</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')} <span className="normal-case font-normal">{t('byok.optional')}</span></Label>
|
||||
<Label htmlFor="byok-alias" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.aliasLabel')} <span className="normal-case font-normal">{t('byok.optional')}</span></Label>
|
||||
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} disabled={saveMutation.isPending} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{NEEDS_BASE_URL.has(provider) && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
|
||||
<Label htmlFor="byok-baseurl" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.apiUrl')}</Label>
|
||||
<Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiKey')}</Label>
|
||||
<Label htmlFor="byok-key" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.apiKey')}</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
@@ -554,8 +537,8 @@ export function ByokSettingsPanel() {
|
||||
</div>
|
||||
<button
|
||||
type="button" disabled={!provider || apiKey.length < 8 || verifying} onClick={verifyKey}
|
||||
className={cn('shrink-0 px-4 py-2 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
|
||||
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-violet-400')}
|
||||
className={cn('shrink-0 px-4 py-2 rounded-xl text-[13px] font-semibold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
|
||||
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-brand-accent/50')}
|
||||
>
|
||||
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : t('byok.verify')}
|
||||
</button>
|
||||
@@ -564,7 +547,7 @@ export function ByokSettingsPanel() {
|
||||
|
||||
{provider && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
|
||||
<Label htmlFor="byok-model" className="text-[13px] font-medium uppercase tracking-wider text-concrete">{t('byok.model')}</Label>
|
||||
{loadingModels ? (
|
||||
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />{t('byok.fetchingModels')}</div>
|
||||
) : showModelDropdown ? (
|
||||
@@ -593,13 +576,13 @@ export function ByokSettingsPanel() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-violet-400 disabled:opacity-40 disabled:pointer-events-none"
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[13px] font-semibold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-brand-accent/50 disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}{t('byok.test')}
|
||||
</button>
|
||||
<Button
|
||||
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
|
||||
className="flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.15em] transition-all duration-200 bg-ink text-paper shadow-lg hover:scale-[1.01] active:scale-[0.99] disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none"
|
||||
className="flex-1 py-2.5 rounded-xl text-[13px] font-semibold uppercase tracking-[0.15em] transition-all duration-200 bg-brand-accent text-white shadow-lg hover:scale-[1.01] active:scale-[0.99] 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" />}
|
||||
|
||||
@@ -9,6 +9,8 @@ import { getNoteById, deleteNote, toggleArchive } from '@/app/actions/notes'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
|
||||
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
|
||||
|
||||
const NoteEditor = dynamic(
|
||||
() => import('@/components/note-editor').then(m => ({ default: m.NoteEditor })),
|
||||
@@ -23,6 +25,7 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
|
||||
const { t } = useLanguage()
|
||||
const [notes, setNotes] = useState<Note[]>(initialNotes)
|
||||
const [editingNote, setEditingNote] = useState<{ note: Note; readOnly: boolean } | null>(null)
|
||||
const [notePendingDelete, setNotePendingDelete] = useState<Note | null>(null)
|
||||
|
||||
const handleOpen = useCallback(async (note: Note, readOnly = false) => {
|
||||
const fresh = await getNoteById(note.id)
|
||||
@@ -50,17 +53,24 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const handleDeleteNote = useCallback(async (note: Note) => {
|
||||
const handleDeleteNote = useCallback((note: Note) => {
|
||||
setNotePendingDelete(note)
|
||||
}, [])
|
||||
|
||||
const confirmDeleteNote = useCallback(async () => {
|
||||
const note = notePendingDelete
|
||||
if (!note) return
|
||||
setNotePendingDelete(null)
|
||||
setNotes((prev) => prev.filter((n) => n.id !== note.id))
|
||||
try {
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
toast.success(t('notes.deleted') || 'Note supprimée')
|
||||
showNoteTrashedToast(note, t, () => setNotes((prev) => [note, ...prev]))
|
||||
} catch {
|
||||
setNotes((prev) => [note, ...prev])
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
}, [t])
|
||||
}, [notePendingDelete, t])
|
||||
|
||||
if (editingNote) {
|
||||
return (
|
||||
@@ -78,11 +88,20 @@ export function ArchiveClient({ notes: initialNotes }: ArchiveClientProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<NotesEditorialView
|
||||
notes={notes}
|
||||
onOpen={handleOpen}
|
||||
onArchiveNote={handleArchiveNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
/>
|
||||
<>
|
||||
<NotesEditorialView
|
||||
notes={notes}
|
||||
onOpen={handleOpen}
|
||||
onArchiveNote={handleArchiveNote}
|
||||
onDeleteNote={handleDeleteNote}
|
||||
/>
|
||||
<ConfirmDeleteNoteDialog
|
||||
open={notePendingDelete != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setNotePendingDelete(null)
|
||||
}}
|
||||
onConfirm={confirmDeleteNote}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
42
memento-note/components/confirm-delete-note-dialog.tsx
Normal file
42
memento-note/components/confirm-delete-note-dialog.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
export function ConfirmDeleteNoteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onConfirm: () => void | Promise<void>
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('notes.confirmDeleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t('notes.confirmDelete')}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={() => { void onConfirm() }}>
|
||||
{t('notes.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -86,18 +86,17 @@ export function DashboardActionStrip({
|
||||
const Wrapper = item.pulse && !prefersReducedMotion ? motion.button : 'button'
|
||||
const motionProps = item.pulse && !prefersReducedMotion
|
||||
? {
|
||||
key: `inbox-pulse-${inboxPulse}`,
|
||||
animate: { scale: [1, 1.03, 1] },
|
||||
transition: { duration: 0.45 },
|
||||
}
|
||||
: {}
|
||||
return (
|
||||
<Wrapper
|
||||
key={item.key}
|
||||
key={item.pulse && !prefersReducedMotion ? `inbox-pulse-${inboxPulse}` : item.key}
|
||||
type="button"
|
||||
onClick={item.onClick}
|
||||
{...motionProps}
|
||||
className={`shrink-0 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border transition-all text-start min-w-[108px] ${
|
||||
className={`shrink-0 flex items-center gap-2.5 px-3.5 py-2.5 rounded-xl border transition-all text-start min-w-[128px] ${
|
||||
item.accent
|
||||
? 'border-brand-accent/30 bg-brand-accent/[0.06] hover:border-brand-accent/50 hover:bg-brand-accent/10 shadow-sm'
|
||||
: 'border-border/25 bg-white/60 dark:bg-zinc-900/40 hover:border-border/50'
|
||||
@@ -114,7 +113,7 @@ export function DashboardActionStrip({
|
||||
}`}>
|
||||
{item.value}
|
||||
</p>
|
||||
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete truncate mt-0.5">
|
||||
<p className="text-[13px] font-medium text-concrete truncate mt-0.5">
|
||||
{item.label}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'motion/react'
|
||||
import { Search, Bot, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import { Search, Bot, ChevronLeft, ChevronRight, Loader2, Check } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||
|
||||
@@ -14,6 +14,11 @@ export interface AgentSuggestion {
|
||||
suggestedFrequency: string
|
||||
}
|
||||
|
||||
export interface CreatedAgentNotice {
|
||||
id: string | null
|
||||
topic: string
|
||||
}
|
||||
|
||||
export interface DashboardAgentCarouselProps {
|
||||
suggestions: AgentSuggestion[]
|
||||
loading?: boolean
|
||||
@@ -21,6 +26,9 @@ export interface DashboardAgentCarouselProps {
|
||||
formatFrequency: (f: string) => string
|
||||
onAccept: (id: string) => void
|
||||
onDismiss: (id: string) => void
|
||||
createdAgent?: CreatedAgentNotice | null
|
||||
onOpenCreated?: () => void
|
||||
onClearCreated?: () => void
|
||||
prefersReducedMotion?: boolean
|
||||
}
|
||||
|
||||
@@ -31,11 +39,15 @@ export function DashboardAgentCarousel({
|
||||
formatFrequency,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
createdAgent,
|
||||
onOpenCreated,
|
||||
onClearCreated,
|
||||
prefersReducedMotion,
|
||||
}: DashboardAgentCarouselProps) {
|
||||
const { t } = useLanguage()
|
||||
const [idx, setIdx] = useState(0)
|
||||
const directionRef = useRef(1)
|
||||
const safeIdx = suggestions.length === 0 ? 0 : Math.min(idx, suggestions.length - 1)
|
||||
|
||||
const goPrev = () => {
|
||||
directionRef.current = -1
|
||||
@@ -46,24 +58,27 @@ export function DashboardAgentCarousel({
|
||||
setIdx(i => Math.min(suggestions.length - 1, i + 1))
|
||||
}
|
||||
|
||||
const navActions = suggestions.length > 1 ? (
|
||||
const showCreated = !!createdAgent
|
||||
const showNav = !showCreated && suggestions.length > 1
|
||||
|
||||
const navActions = showNav ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
disabled={idx === 0}
|
||||
disabled={safeIdx === 0}
|
||||
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
||||
aria-label={t('homeDashboard.intelPrev')}
|
||||
>
|
||||
<ChevronLeft size={12} />
|
||||
</button>
|
||||
<span className="text-[8px] font-mono text-concrete px-1">
|
||||
{idx + 1}/{suggestions.length}
|
||||
{safeIdx + 1}/{suggestions.length}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
disabled={idx >= suggestions.length - 1}
|
||||
disabled={safeIdx >= suggestions.length - 1}
|
||||
className="p-1 rounded border border-border/30 disabled:opacity-25"
|
||||
aria-label={t('homeDashboard.intelNext')}
|
||||
>
|
||||
@@ -83,9 +98,18 @@ export function DashboardAgentCarousel({
|
||||
icon={<Bot size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.suggestedResearch')}
|
||||
actions={navActions}
|
||||
wrapTitle
|
||||
/>
|
||||
|
||||
{suggestions.length === 0 ? (
|
||||
{showCreated && createdAgent ? (
|
||||
<CreatedAgentPanel
|
||||
topic={createdAgent.topic}
|
||||
hasMore={suggestions.length > 0}
|
||||
onOpen={onOpenCreated}
|
||||
onSeeNext={onClearCreated}
|
||||
t={t}
|
||||
/>
|
||||
) : suggestions.length === 0 ? (
|
||||
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3">
|
||||
<p className="text-[11px] text-concrete leading-relaxed">
|
||||
{t('homeDashboard.agentsEmpty')}
|
||||
@@ -93,7 +117,7 @@ export function DashboardAgentCarousel({
|
||||
</div>
|
||||
) : (
|
||||
<AgentSlide
|
||||
current={suggestions[idx]}
|
||||
current={suggestions[safeIdx]}
|
||||
direction={directionRef.current}
|
||||
actingId={actingId}
|
||||
formatFrequency={formatFrequency}
|
||||
@@ -107,6 +131,54 @@ export function DashboardAgentCarousel({
|
||||
)
|
||||
}
|
||||
|
||||
function CreatedAgentPanel({
|
||||
topic,
|
||||
hasMore,
|
||||
onOpen,
|
||||
onSeeNext,
|
||||
t,
|
||||
}: {
|
||||
topic: string
|
||||
hasMore: boolean
|
||||
onOpen?: () => void
|
||||
onSeeNext?: () => void
|
||||
t: (key: string) => string
|
||||
}) {
|
||||
return (
|
||||
<div className="p-3.5 rounded-xl border border-border/25 bg-stone-50/60 dark:bg-zinc-950/40">
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<Check size={14} className="text-brand-accent shrink-0 mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-ink dark:text-dark-ink leading-snug">
|
||||
{t('homeDashboard.agentCreatedInCard')}
|
||||
</p>
|
||||
{topic ? (
|
||||
<p className="text-[11px] text-concrete leading-relaxed mt-1">{topic}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
{hasMore && onSeeNext ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSeeNext}
|
||||
className="text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg border border-border/40 text-concrete hover:text-ink transition-colors"
|
||||
>
|
||||
{t('homeDashboard.agentSeeNextSuggestion')}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-brand-accent text-white font-bold hover:bg-brand-accent/90"
|
||||
>
|
||||
{t('homeDashboard.agentOpenCreated')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentSlide({
|
||||
current,
|
||||
direction,
|
||||
@@ -165,7 +237,7 @@ function AgentSlide({
|
||||
type="button"
|
||||
disabled={actingId === current.id}
|
||||
onClick={() => onAccept(current.id)}
|
||||
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black font-bold hover:opacity-90 disabled:opacity-40"
|
||||
className="flex-1 inline-flex items-center justify-center gap-1 text-[9px] font-mono uppercase px-2.5 py-1.5 rounded-lg bg-brand-accent text-white font-bold hover:bg-brand-accent/90 disabled:opacity-40"
|
||||
>
|
||||
{actingId === current.id ? <Loader2 size={10} className="animate-spin" /> : null}
|
||||
{t('homeDashboard.createAgent')}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import { RevisionHeatmap } from '@/components/flashcards/revision-heatmap'
|
||||
import { UsageMeter } from '@/components/usage-meter'
|
||||
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||
import { pathNoteTitle } from '@/lib/dashboard/path-title'
|
||||
import type { DashboardWidgetId } from '@/lib/dashboard/layout'
|
||||
|
||||
interface DashboardWidgetShellProps {
|
||||
@@ -41,21 +42,40 @@ export function DashboardWidgetShell({
|
||||
)
|
||||
}
|
||||
|
||||
function inboxDisplayTitle(
|
||||
note: { title: string | null; excerpt?: string },
|
||||
fallback: string,
|
||||
): string {
|
||||
const titled = note.title?.trim()
|
||||
if (titled) return titled
|
||||
const excerpt = note.excerpt?.trim()
|
||||
if (!excerpt) return fallback
|
||||
return excerpt.length > 80 ? `${excerpt.slice(0, 80).trim()}…` : excerpt
|
||||
}
|
||||
|
||||
export function DashboardInboxWidget({
|
||||
count,
|
||||
notes,
|
||||
loading,
|
||||
onOpen,
|
||||
onSelect,
|
||||
formatRelativeTime,
|
||||
}: {
|
||||
count: number
|
||||
notes: Array<{ id: string; title: string | null; notebookId: string | null }>
|
||||
notes: Array<{
|
||||
id: string
|
||||
title: string | null
|
||||
excerpt?: string
|
||||
notebookId: string | null
|
||||
updatedAt?: string
|
||||
}>
|
||||
loading: boolean
|
||||
onOpen: () => void
|
||||
onSelect: (id: string, notebookId: string | null) => void
|
||||
formatRelativeTime?: (date: string) => string
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const reduced = !!useReducedMotion()
|
||||
const untitled = t('homeDashboard.untitled')
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="inbox"
|
||||
@@ -83,21 +103,22 @@ export function DashboardInboxWidget({
|
||||
className="w-full text-start p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all"
|
||||
>
|
||||
<p className="text-[11px] text-ink dark:text-dark-ink truncate">
|
||||
{note.title || t('homeDashboard.untitled')}
|
||||
{inboxDisplayTitle(note, untitled)}
|
||||
</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||
{t('homeDashboard.inbox')}
|
||||
{note.updatedAt && formatRelativeTime
|
||||
? ` · ${formatRelativeTime(note.updatedAt)}`
|
||||
: ''}
|
||||
</p>
|
||||
</motion.button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className="w-full flex items-center justify-between gap-2 px-1 pt-1 text-start"
|
||||
className="w-full text-start px-1 pt-1 text-[9px] font-mono uppercase font-bold text-brand-accent hover:underline"
|
||||
>
|
||||
<span className="text-[9px] font-mono uppercase font-bold text-concrete">
|
||||
{t('homeDashboard.inboxSeeAll', { count })}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono uppercase font-bold text-brand-accent">
|
||||
{t('homeDashboard.widgetOpen')} →
|
||||
</span>
|
||||
{t('homeDashboard.inboxOpenList')} →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -278,12 +299,22 @@ export function DashboardPinnedWidget({
|
||||
notes,
|
||||
loading,
|
||||
onSelect,
|
||||
formatRelativeTime,
|
||||
}: {
|
||||
notes: { id: string; title: string | null; notebookId: string | null }[]
|
||||
notes: {
|
||||
id: string
|
||||
title: string | null
|
||||
excerpt?: string
|
||||
notebookId: string | null
|
||||
updatedAt?: string
|
||||
notebook?: { name: string; color: string | null } | null
|
||||
}[]
|
||||
loading: boolean
|
||||
onSelect: (id: string, notebookId: string | null) => void
|
||||
formatRelativeTime?: (date: string) => string
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
const untitled = t('homeDashboard.untitled')
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="pinned"
|
||||
@@ -298,17 +329,37 @@ export function DashboardPinnedWidget({
|
||||
) : notes.length === 0 ? (
|
||||
<p className="text-[10px] text-concrete italic py-2">{t('homeDashboard.widgetPinnedEmpty')}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{notes.map(n => (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(n.id, n.notebookId)}
|
||||
className="w-full text-[10px] text-ink dark:text-dark-ink truncate text-start p-2 rounded-lg hover:bg-brand-accent/[0.04] transition-colors"
|
||||
>
|
||||
{n.title || t('homeDashboard.untitled')}
|
||||
</button>
|
||||
))}
|
||||
<div className="space-y-1.5">
|
||||
{notes.map(n => {
|
||||
const displayTitle = pathNoteTitle(n.title, n.excerpt) || untitled
|
||||
const notebookName = n.notebook?.name || t('homeDashboard.pinnedNoNotebook')
|
||||
const notebookColor = n.notebook?.color || '#C4A574'
|
||||
return (
|
||||
<button
|
||||
key={n.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(n.id, n.notebookId)}
|
||||
className="w-full flex items-center gap-2.5 p-2 rounded-xl border border-border/20 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start group"
|
||||
>
|
||||
<span
|
||||
className="w-1 h-7 rounded-full shrink-0"
|
||||
style={{ backgroundColor: notebookColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
|
||||
{displayTitle}
|
||||
</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||
{notebookName}
|
||||
{n.updatedAt && formatRelativeTime
|
||||
? ` · ${formatRelativeTime(n.updatedAt)}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
@@ -338,8 +389,7 @@ export function DashboardActivityWidget({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<RevisionHeatmap data={data} />
|
||||
<p className="text-[9px] text-concrete mt-2">{t('homeDashboard.widgetActivityHint')}</p>
|
||||
<RevisionHeatmap data={data} kind="edits" />
|
||||
</>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
@@ -426,15 +476,12 @@ export function DashboardFlashcardsProgressWidget({
|
||||
style={{ width: `${retention}%` }}
|
||||
/>
|
||||
</div>
|
||||
{dueCount > 0 ? (
|
||||
<p className="text-[10px] font-medium text-brand-accent group-hover:underline">
|
||||
{t('homeDashboard.flashDueCta', { count: dueCount })} →
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[9px] text-concrete group-hover:text-brand-accent transition-colors">
|
||||
{t('homeDashboard.flashOpenCta')} →
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[9px] text-concrete leading-relaxed mb-2">
|
||||
{t('homeDashboard.flashRetentionHint')}
|
||||
</p>
|
||||
<p className="text-[10px] font-medium text-brand-accent group-hover:underline">
|
||||
{dueCount > 0 ? t('homeDashboard.flashDueCta') : t('homeDashboard.flashOpenCta')} →
|
||||
</p>
|
||||
</button>
|
||||
)}
|
||||
</DashboardWidgetShell>
|
||||
|
||||
@@ -93,12 +93,7 @@ export function DashboardMindOrbit({
|
||||
)}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
className="relative h-[176px]"
|
||||
initial={reduced ? false : { clipPath: 'circle(0% at 50% 44%)' }}
|
||||
animate={{ clipPath: 'circle(120% at 50% 44%)' }}
|
||||
transition={{ duration: reduced ? 0 : 0.62, ease: EASE }}
|
||||
>
|
||||
<motion.div className="relative h-[200px] overflow-visible">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
preserveAspectRatio="none"
|
||||
@@ -133,7 +128,7 @@ export function DashboardMindOrbit({
|
||||
const color = CLUSTER_COLORS[cluster.clusterId % CLUSTER_COLORS.length]
|
||||
const scale = 0.65 + (cluster.noteIds.length / maxCount) * 0.55
|
||||
const size = Math.round(52 * scale)
|
||||
const label = cluster.name || `${t('homeDashboard.theme')} ${cluster.clusterId + 1}`
|
||||
const label = cluster.name?.trim() || t('homeDashboard.theme')
|
||||
const point = points[idx]
|
||||
return (
|
||||
<motion.button
|
||||
@@ -142,11 +137,12 @@ export function DashboardMindOrbit({
|
||||
whileHover={reduced ? undefined : { scale: 1.06 }}
|
||||
whileTap={reduced ? undefined : { scale: 0.97 }}
|
||||
onClick={() => (onOpenCluster ? onOpenCluster(cluster.clusterId) : onOpenInsights())}
|
||||
className="absolute flex flex-col items-center gap-1 group"
|
||||
className="absolute flex flex-col items-center gap-1 group z-[1] hover:z-10"
|
||||
aria-label={label}
|
||||
style={{
|
||||
left: `${point.x}%`,
|
||||
top: `${point.y}%`,
|
||||
width: size + 16,
|
||||
width: Math.max(size + 24, 128),
|
||||
}}
|
||||
initial={reduced ? { x: '-50%', y: '-50%' } : { x: '-50%', y: '-50%', scale: 0.82, opacity: 0.35 }}
|
||||
animate={{ x: '-50%', y: '-50%', scale: 1, opacity: 1 }}
|
||||
@@ -164,7 +160,10 @@ export function DashboardMindOrbit({
|
||||
>
|
||||
{cluster.noteIds.length}
|
||||
</div>
|
||||
<span className="text-[8px] font-medium text-ink/80 dark:text-dark-ink/80 text-center line-clamp-2 leading-tight max-w-[72px] group-hover:text-brand-accent transition-colors">
|
||||
<span className="text-[9px] font-medium text-ink/80 dark:text-dark-ink/80 text-center line-clamp-2 leading-snug max-w-[128px] group-hover:text-brand-accent transition-colors">
|
||||
{label}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute top-full mt-1 max-w-[200px] px-2 py-1 rounded-md bg-ink text-white text-[10px] leading-snug text-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg z-20">
|
||||
{label}
|
||||
</span>
|
||||
</motion.button>
|
||||
|
||||
@@ -12,16 +12,16 @@ import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row
|
||||
import type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
|
||||
|
||||
const TYPE_META: Record<DashboardPathType, { Icon: LucideIcon; accent: string }> = {
|
||||
continue: { Icon: PenLine, accent: 'text-ink' },
|
||||
continue: { Icon: PenLine, accent: 'text-brand-accent' },
|
||||
connect: { Icon: Link2, accent: 'text-brand-accent' },
|
||||
'add-link': { Icon: Plus, accent: 'text-emerald-600' },
|
||||
bridge: { Icon: GitBranch, accent: 'text-violet-600' },
|
||||
research: { Icon: Sparkles, accent: 'text-sky-600' },
|
||||
explore: { Icon: Compass, accent: 'text-amber-600' },
|
||||
'add-link': { Icon: Plus, accent: 'text-brand-accent' },
|
||||
bridge: { Icon: GitBranch, accent: 'text-brand-accent' },
|
||||
research: { Icon: Sparkles, accent: 'text-brand-accent' },
|
||||
explore: { Icon: Compass, accent: 'text-brand-accent' },
|
||||
organize: { Icon: Inbox, accent: 'text-brand-accent' },
|
||||
review: { Icon: GraduationCap, accent: 'text-brand-accent' },
|
||||
resurface: { Icon: Lightbulb, accent: 'text-brand-accent' },
|
||||
daily: { Icon: BookOpen, accent: 'text-concrete' },
|
||||
daily: { Icon: BookOpen, accent: 'text-brand-accent' },
|
||||
}
|
||||
|
||||
export interface DashboardNextPathsProps {
|
||||
|
||||
@@ -153,7 +153,7 @@ export function DashboardLinkSuggestions({
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="link-suggestions"
|
||||
icon={<Circle size={12} className="text-emerald-600" />}
|
||||
icon={<Circle size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.widgets.link-suggestions')}
|
||||
>
|
||||
{loading ? (
|
||||
@@ -169,16 +169,16 @@ export function DashboardLinkSuggestions({
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => onAction(p.id)}
|
||||
className="w-full p-3 rounded-xl border border-border/20 hover:border-emerald-500/30 text-start transition-all"
|
||||
className="w-full p-3 rounded-xl border border-border/20 hover:border-brand-accent/30 text-start transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<p className="text-[11px] font-mono font-bold text-ink dark:text-dark-ink truncate">{p.title}</p>
|
||||
{p.score ? (
|
||||
<span className="text-[8px] font-mono text-emerald-600 shrink-0">{p.score}%</span>
|
||||
<span className="text-[8px] font-mono text-brand-accent shrink-0">{p.score}%</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-[10px] text-concrete line-clamp-2">{p.description}</p>
|
||||
<p className="text-[8px] font-mono uppercase text-emerald-600 mt-2">{t('homeDashboard.pathActions.addLink')} →</p>
|
||||
<p className="text-[8px] font-mono uppercase text-brand-accent mt-2">{t('homeDashboard.pathActions.addLink')} →</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -212,7 +212,7 @@ export function DashboardBridgesWidget({
|
||||
return (
|
||||
<DashboardWidgetShell
|
||||
widgetId="bridges"
|
||||
icon={<Circle size={12} className="text-violet-600" />}
|
||||
icon={<Circle size={12} className="text-brand-accent" />}
|
||||
title={t('homeDashboard.widgets.bridges')}
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { Clock, ChevronRight, Play, PenLine } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||
@@ -24,27 +23,33 @@ export interface DashboardResumeHeroProps {
|
||||
prefersReducedMotion?: boolean
|
||||
}
|
||||
|
||||
function resumeDisplayTitle(note: ResumeNote, fallback: string): string {
|
||||
const titled = note.title?.trim()
|
||||
if (titled) return titled
|
||||
const excerpt = note.excerpt?.trim()
|
||||
if (!excerpt) return fallback
|
||||
return excerpt.length > 80 ? `${excerpt.slice(0, 80).trim()}…` : excerpt
|
||||
}
|
||||
|
||||
export function DashboardResumeHero({
|
||||
notes,
|
||||
loading,
|
||||
onSelect,
|
||||
onCaptureFocus,
|
||||
formatRelativeTime,
|
||||
prefersReducedMotion,
|
||||
}: DashboardResumeHeroProps) {
|
||||
const { t } = useLanguage()
|
||||
const hero = notes[0]
|
||||
const rest = notes.slice(1, 6)
|
||||
const untitled = t('homeDashboard.untitled')
|
||||
const visible = notes.slice(0, 6)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-5 min-h-[220px] animate-pulse">
|
||||
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-5 min-h-[180px] animate-pulse">
|
||||
<div className="h-4 w-32 bg-stone-100 dark:bg-zinc-800 rounded mb-4" />
|
||||
<div className="h-6 w-3/4 bg-stone-100 dark:bg-zinc-800 rounded mb-3" />
|
||||
<div className="h-16 bg-stone-50 dark:bg-zinc-950 rounded-xl mb-3" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-10 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
<div className="h-10 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
<div className="h-12 bg-stone-50 dark:bg-zinc-950 rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -61,7 +66,7 @@ export function DashboardResumeHero({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!hero ? (
|
||||
{visible.length === 0 ? (
|
||||
<div className="px-5 pb-5">
|
||||
<div className="rounded-xl border border-dashed border-border/40 bg-stone-50/60 dark:bg-zinc-950/40 p-5 text-center">
|
||||
<Clock size={22} className="mx-auto text-concrete/35 mb-2" strokeWidth={1.25} />
|
||||
@@ -81,73 +86,33 @@ export function DashboardResumeHero({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<motion.button
|
||||
type="button"
|
||||
whileHover={prefersReducedMotion ? undefined : { y: -1 }}
|
||||
onClick={() => onSelect(hero.id, hero.notebookId)}
|
||||
className="w-full text-start px-5 pb-3 group cursor-pointer"
|
||||
>
|
||||
<div className="p-4 rounded-xl border border-border/25 bg-gradient-to-br from-stone-50/80 to-white dark:from-zinc-950/50 dark:to-zinc-900/80 group-hover:border-brand-accent/35 group-hover:shadow-md transition-all">
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<span
|
||||
className="text-[8px] font-mono font-bold uppercase px-2 py-0.5 rounded text-white shrink-0"
|
||||
style={{ backgroundColor: hero.notebookColor }}
|
||||
>
|
||||
{hero.notebookName}
|
||||
</span>
|
||||
<span className="text-[9px] font-mono text-concrete/70 shrink-0">
|
||||
{formatRelativeTime(hero.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-base sm:text-lg font-serif font-semibold text-ink dark:text-dark-ink group-hover:text-brand-accent transition-colors leading-snug mb-2 line-clamp-2">
|
||||
{hero.title || t('homeDashboard.untitled')}
|
||||
</h3>
|
||||
{hero.excerpt ? (
|
||||
<p className="text-[11px] text-concrete leading-relaxed line-clamp-3">
|
||||
{hero.excerpt}
|
||||
<div className="px-5 pb-4 space-y-1.5">
|
||||
{visible.map(note => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(note.id, note.notebookId)}
|
||||
className="w-full flex items-center gap-3 p-2.5 rounded-xl border border-border/20 bg-stone-50/40 dark:bg-zinc-950/30 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start group"
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-8 rounded-full shrink-0"
|
||||
style={{ backgroundColor: note.notebookColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
|
||||
{resumeDisplayTitle(note, untitled)}
|
||||
</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||
{note.notebookName}
|
||||
{' · '}
|
||||
{formatRelativeTime(note.updatedAt)}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex items-center gap-1 mt-3 text-[9px] font-mono font-bold uppercase text-brand-accent opacity-80 group-hover:opacity-100 transition-opacity">
|
||||
{t('homeDashboard.resumeOpen')}
|
||||
<ChevronRight size={12} />
|
||||
</div>
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
{rest.length > 0 && (
|
||||
<div className="px-5 pb-4 space-y-1.5">
|
||||
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete/70 mb-1">
|
||||
{t('homeDashboard.resumeAlso')}
|
||||
</p>
|
||||
{rest.map(note => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => onSelect(note.id, note.notebookId)}
|
||||
className="w-full flex items-center gap-3 p-2.5 rounded-xl border border-border/20 bg-stone-50/40 dark:bg-zinc-950/30 hover:border-brand-accent/30 hover:bg-brand-accent/[0.03] transition-all text-start group"
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-8 rounded-full shrink-0"
|
||||
style={{ backgroundColor: note.notebookColor }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-semibold text-ink dark:text-dark-ink truncate group-hover:text-brand-accent transition-colors">
|
||||
{note.title || t('homeDashboard.untitled')}
|
||||
</p>
|
||||
<p className="text-[9px] text-concrete truncate mt-0.5">
|
||||
{note.notebookName}
|
||||
{' · '}
|
||||
{formatRelativeTime(note.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight size={12} className="text-concrete/40 group-hover:text-brand-accent shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<ChevronRight size={12} className="text-concrete/40 group-hover:text-brand-accent shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,12 @@ interface EmotionMeta {
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface SentimentRelatedNote {
|
||||
id: string
|
||||
title: string | null
|
||||
notebookId: string | null
|
||||
}
|
||||
|
||||
export interface DashboardSentimentChipProps {
|
||||
available: boolean
|
||||
loading?: boolean
|
||||
@@ -17,6 +23,8 @@ export interface DashboardSentimentChipProps {
|
||||
summary?: string
|
||||
emotions?: Record<string, number>
|
||||
emotionMeta: Record<string, EmotionMeta>
|
||||
relatedNotes?: SentimentRelatedNote[]
|
||||
onSelectNote?: (id: string, notebookId: string | null) => void
|
||||
}
|
||||
|
||||
export function DashboardSentimentChip({
|
||||
@@ -26,6 +34,8 @@ export function DashboardSentimentChip({
|
||||
summary,
|
||||
emotions,
|
||||
emotionMeta,
|
||||
relatedNotes = [],
|
||||
onSelectNote,
|
||||
}: DashboardSentimentChipProps) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
@@ -80,6 +90,24 @@ export function DashboardSentimentChip({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{relatedNotes.length > 0 && onSelectNote && (
|
||||
<div className="space-y-1 pt-1">
|
||||
<p className="text-[8px] font-mono font-bold uppercase tracking-wider text-concrete/70">
|
||||
{t('homeDashboard.sentimentFromNotes')}
|
||||
</p>
|
||||
{relatedNotes.map(note => (
|
||||
<button
|
||||
key={note.id}
|
||||
type="button"
|
||||
onClick={() => onSelectNote(note.id, note.notebookId)}
|
||||
className="w-full text-start text-[11px] text-ink dark:text-dark-ink truncate px-2 py-1.5 rounded-lg hover:bg-brand-accent/[0.04] hover:text-brand-accent transition-colors"
|
||||
>
|
||||
{note.title?.trim() || t('homeDashboard.untitled')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{emotions && (
|
||||
<div className="space-y-2 pt-1">
|
||||
{Object.entries(emotions)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useReducedMotion } from 'motion/react'
|
||||
import { Inbox, Send, Bell, Mail, Loader2 } from 'lucide-react'
|
||||
import { Send, Bell, Loader2, PenLine } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { useAiConsent } from '@/components/legal/ai-consent-provider'
|
||||
import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect'
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from '@/components/dashboard-path-widgets'
|
||||
import type { DashboardPath } from '@/lib/dashboard/path-types'
|
||||
import { buildFastPathsFromBriefing } from '@/lib/dashboard/paths-fast'
|
||||
import { pathNoteTitle, pickFocusNote } from '@/lib/dashboard/path-title'
|
||||
import { emitAiUsageChanged } from '@/lib/ai-usage-sync'
|
||||
import {
|
||||
DashboardInboxWidget,
|
||||
@@ -50,8 +51,10 @@ import type { LucideIcon } from 'lucide-react'
|
||||
interface BriefingPinnedNote {
|
||||
id: string
|
||||
title: string | null
|
||||
excerpt?: string
|
||||
notebookId: string | null
|
||||
updatedAt: string
|
||||
notebook?: { id: string; name: string; color: string | null; icon: string | null } | null
|
||||
}
|
||||
|
||||
interface ActivityDay {
|
||||
@@ -137,6 +140,7 @@ interface SentimentData {
|
||||
emotions?: Record<string, number>
|
||||
summary?: string
|
||||
topTopic?: string
|
||||
relatedNotes?: Array<{ id: string; title: string | null; notebookId: string | null }>
|
||||
}
|
||||
|
||||
interface MindMapData {
|
||||
@@ -217,7 +221,13 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
const [data, setData] = useState<{
|
||||
recentNotes: BriefingNote[]
|
||||
inboxCount: number
|
||||
inboxPreview?: Array<{ id: string; title: string | null; notebookId: string | null }>
|
||||
inboxPreview?: Array<{
|
||||
id: string
|
||||
title: string | null
|
||||
excerpt?: string
|
||||
notebookId: string | null
|
||||
updatedAt?: string
|
||||
}>
|
||||
dueFlashcards: number
|
||||
upcomingReminders: BriefingReminder[]
|
||||
insights: BriefingInsight[]
|
||||
@@ -245,6 +255,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
const [capturing, setCapturing] = useState(false)
|
||||
const [inboxPulse, setInboxPulse] = useState(0)
|
||||
const [actingSuggestionId, setActingSuggestionId] = useState<string | null>(null)
|
||||
const [createdAgent, setCreatedAgent] = useState<{ id: string | null; topic: string } | null>(null)
|
||||
const [echoRefreshing, setEchoRefreshing] = useState(false)
|
||||
const [dismissingInsightId, setDismissingInsightId] = useState<string | null>(null)
|
||||
const [actingBridgeSuggestionKey, setActingBridgeSuggestionKey] = useState<string | null>(null)
|
||||
@@ -267,7 +278,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
const loadPaths = useCallback(async (briefing: NonNullable<typeof data>) => {
|
||||
setPathsEnriching(true)
|
||||
try {
|
||||
const focus = briefing.recentNotes[0]
|
||||
const focus = pickFocusNote(briefing.recentNotes)
|
||||
const res = await fetch('/api/briefing/paths', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -418,12 +429,22 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
const gmail = data?.gmail
|
||||
const pinnedNotes = data?.pinnedNotes ?? []
|
||||
const writingActivity = data?.writingActivity ?? []
|
||||
const pathsList = paths
|
||||
const recentNotes = data?.recentNotes ?? []
|
||||
const pathsList = useMemo(
|
||||
() => paths.filter(p => p.type !== 'organize' && p.type !== 'review'),
|
||||
[paths],
|
||||
)
|
||||
const pathBridgeKeys = useMemo(
|
||||
() => pathsList
|
||||
.filter(p => p.type === 'bridge' && p.clusterAId != null && p.clusterBId != null)
|
||||
.map(p => `${p.clusterAId}-${p.clusterBId}`),
|
||||
[pathsList],
|
||||
)
|
||||
const focusNote = useMemo(() => pickFocusNote(recentNotes), [recentNotes])
|
||||
const openLoopsList = openLoops
|
||||
const briefingLoading = data === null
|
||||
const pathsLoading = data === null
|
||||
const pathsDetailLoading = pathsEnriching
|
||||
const recentNotes = data?.recentNotes ?? []
|
||||
const topBridgeNotes = useMemo(() => (mindMap?.bridgeNotes ?? []).slice(0, 3), [mindMap?.bridgeNotes])
|
||||
const dateLocale = localeForLanguage(language)
|
||||
|
||||
@@ -434,25 +455,19 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
|
||||
const themeCount = mindMap?.clusters.length ?? 0
|
||||
|
||||
const resumeNotes = useMemo(() => recentNotes.map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
excerpt: stripHtml(n.content).slice(0, 180),
|
||||
notebookName: n.notebook?.name || t('homeDashboard.inbox'),
|
||||
notebookColor: n.notebook?.color || '#8B5CF6',
|
||||
updatedAt: n.updatedAt,
|
||||
notebookId: n.notebookId,
|
||||
})), [recentNotes, t])
|
||||
|
||||
const briefingSubtitle = useMemo(() => {
|
||||
if (briefingLoading) return ''
|
||||
const parts: string[] = []
|
||||
if (inboxCount > 0) parts.push(t('homeDashboard.pulseInbox', { count: inboxCount }))
|
||||
if (dueFlashcards > 0) parts.push(t('homeDashboard.pulseReview', { count: dueFlashcards }))
|
||||
if (discoveryCount > 0) parts.push(t('homeDashboard.pulseDiscoveries', { count: discoveryCount }))
|
||||
if (parts.length === 0) return t('homeDashboard.pulseClear')
|
||||
return parts.join(' · ')
|
||||
}, [briefingLoading, inboxCount, dueFlashcards, discoveryCount, t])
|
||||
const resumeNotes = useMemo(() => recentNotes.flatMap(n => {
|
||||
const displayTitle = pathNoteTitle(n.title, n.content)
|
||||
if (!displayTitle) return []
|
||||
return [{
|
||||
id: n.id,
|
||||
title: displayTitle,
|
||||
excerpt: stripHtml(n.content).slice(0, 180),
|
||||
notebookName: n.notebook?.name || t('homeDashboard.inbox'),
|
||||
notebookColor: n.notebook?.color || '#8B5CF6',
|
||||
updatedAt: n.updatedAt,
|
||||
notebookId: n.notebookId,
|
||||
}]
|
||||
}), [recentNotes, t])
|
||||
|
||||
const handleCapture = useCallback(async () => {
|
||||
const text = captureText.trim()
|
||||
@@ -609,19 +624,20 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
|
||||
const handleAcceptSuggestion = useCallback(async (id: string) => {
|
||||
setActingSuggestionId(id)
|
||||
const topic = data?.agentSuggestions?.find(s => s.id === id)?.topic ?? ''
|
||||
try {
|
||||
const res = await fetch(`/api/agents/suggestions/${id}/accept`, { method: 'POST' })
|
||||
const json = await res.json()
|
||||
if (!res.ok) throw new Error(json.error)
|
||||
setData(prev => prev ? { ...prev, agentSuggestions: prev.agentSuggestions?.filter(s => s.id !== id) ?? [] } : prev)
|
||||
setCreatedAgent({ id: json.agentId ?? null, topic })
|
||||
toast.success(t('homeDashboard.agentCreated'))
|
||||
if (json.agentId) router.push(`/agents?id=${json.agentId}`)
|
||||
} catch {
|
||||
toast.error(t('homeDashboard.agentFailed'))
|
||||
} finally {
|
||||
setActingSuggestionId(null)
|
||||
}
|
||||
}, [t, router])
|
||||
}, [t, data?.agentSuggestions])
|
||||
|
||||
const handleDismissSuggestion = useCallback(async (id: string) => {
|
||||
setActingSuggestionId(id)
|
||||
@@ -752,12 +768,17 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
case 'capture':
|
||||
return wrap(
|
||||
<div className="relative rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm shadow-sm h-full">
|
||||
<div className="flex items-center justify-between gap-2 px-3.5 pt-2.5">
|
||||
<div className="flex items-center justify-between gap-2 px-3 pt-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Inbox size={11} className="text-brand-accent shrink-0" />
|
||||
<span className="text-[8px] font-mono font-bold uppercase tracking-widest text-concrete">
|
||||
{t('homeDashboard.quickCapture')}
|
||||
</span>
|
||||
<PenLine size={11} className="text-brand-accent shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<span className="text-[8px] font-mono font-bold uppercase tracking-widest text-concrete block">
|
||||
{t('homeDashboard.quickCapture')}
|
||||
</span>
|
||||
<span className="text-[9px] text-concrete leading-tight">
|
||||
{t('homeDashboard.captureGoesToFile')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DashboardWidgetHelp widgetId="capture" />
|
||||
</div>
|
||||
@@ -767,14 +788,15 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
onKeyDown={handleCaptureKeyDown}
|
||||
placeholder={t('homeDashboard.quickCapturePlaceholder')}
|
||||
rows={2}
|
||||
className="w-full text-sm px-3.5 pb-3 pt-1.5 pe-12 bg-transparent outline-none text-ink dark:text-dark-ink resize-none leading-relaxed placeholder:text-concrete/45"
|
||||
className="w-full text-sm px-3 pb-2 pt-1 pe-12 bg-transparent outline-none text-ink dark:text-dark-ink resize-none leading-snug h-[2.75rem] placeholder:text-concrete/45"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCapture}
|
||||
disabled={!captureText.trim() || capturing}
|
||||
className="absolute bottom-2.5 end-2.5 p-2 bg-ink text-white dark:bg-white dark:text-black rounded-lg disabled:opacity-25 hover:scale-105 active:scale-95 transition-all shadow-sm"
|
||||
className="absolute bottom-1.5 end-2 p-1.5 bg-brand-accent text-white rounded-lg disabled:opacity-25 hover:bg-brand-accent/90 hover:scale-105 active:scale-95 transition-all shadow-sm"
|
||||
aria-busy={capturing}
|
||||
aria-label={t('homeDashboard.captureSend')}
|
||||
>
|
||||
{capturing
|
||||
? <Loader2 size={12} className="animate-spin" />
|
||||
@@ -788,7 +810,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
paths={pathsList}
|
||||
loading={pathsLoading}
|
||||
enriching={pathsEnriching}
|
||||
focusNoteTitle={recentNotes[0]?.title}
|
||||
focusNoteTitle={focusNote ? pathNoteTitle(focusNote.title, focusNote.content) : null}
|
||||
onAction={handlePathAction}
|
||||
prefersReducedMotion={!!prefersReducedMotion}
|
||||
/>,
|
||||
@@ -887,6 +909,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
dismissingInsightId={dismissingInsightId}
|
||||
actingBridgeSuggestionKey={actingBridgeSuggestionKey}
|
||||
prefersReducedMotion={!!prefersReducedMotion}
|
||||
excludeBridgeSuggestionKeys={pathBridgeKeys}
|
||||
/>,
|
||||
)
|
||||
case 'reminders':
|
||||
@@ -903,7 +926,18 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
|
||||
</div>
|
||||
) : reminders.length === 0 ? (
|
||||
<p className="text-[11px] text-concrete italic py-1">{t('homeDashboard.allCaughtUp')}</p>
|
||||
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3">
|
||||
<p className="text-[11px] text-concrete leading-relaxed mb-2">
|
||||
{t('homeDashboard.remindersEmpty')}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/home?reminders=1&forceList=1')}
|
||||
className="text-[9px] font-mono font-bold uppercase text-brand-accent hover:underline"
|
||||
>
|
||||
{t('homeDashboard.remindersOpenAll')} →
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{reminders.slice(0, 4).map(r => (
|
||||
@@ -921,33 +955,15 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/home?reminders=1&forceList=1')}
|
||||
className="w-full text-start px-1 pt-1 text-[9px] font-mono uppercase font-bold text-brand-accent hover:underline"
|
||||
>
|
||||
{t('homeDashboard.remindersOpenAll')} →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{!briefingLoading && (
|
||||
gmail?.connected ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/settings/integrations')}
|
||||
className="w-full flex items-center justify-between gap-2 p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/25 transition-all text-start mt-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Mail size={11} className="text-concrete" />
|
||||
<span className="text-[10px] text-ink dark:text-dark-ink truncate">{t('homeDashboard.gmailCaptures')}</span>
|
||||
</div>
|
||||
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">
|
||||
{t('homeDashboard.gmailRecent', { count: gmail.recentCaptures })}
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/settings/integrations')}
|
||||
className="w-full text-[9px] font-mono uppercase tracking-wider text-concrete hover:text-brand-accent transition-colors text-start py-2 mt-2"
|
||||
>
|
||||
{t('homeDashboard.gmailConnect')} →
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>,
|
||||
)
|
||||
case 'mind-map':
|
||||
@@ -971,6 +987,9 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
formatFrequency={formatAgentFrequency}
|
||||
onAccept={handleAcceptSuggestion}
|
||||
onDismiss={handleDismissSuggestion}
|
||||
createdAgent={createdAgent}
|
||||
onOpenCreated={() => router.push(createdAgent?.id ? `/agents?id=${createdAgent.id}` : '/agents')}
|
||||
onClearCreated={() => setCreatedAgent(null)}
|
||||
prefersReducedMotion={!!prefersReducedMotion}
|
||||
/>,
|
||||
)
|
||||
@@ -983,6 +1002,8 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
summary={sentiment?.summary}
|
||||
emotions={sentiment?.emotions}
|
||||
emotionMeta={EMOTION_META}
|
||||
relatedNotes={sentiment?.relatedNotes}
|
||||
onSelectNote={onNoteSelect}
|
||||
/>,
|
||||
)
|
||||
case 'inbox':
|
||||
@@ -993,6 +1014,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
loading={briefingLoading}
|
||||
onOpen={() => router.push('/home?forceList=1')}
|
||||
onSelect={onNoteSelect}
|
||||
formatRelativeTime={relTime}
|
||||
/>,
|
||||
)
|
||||
case 'revision':
|
||||
@@ -1043,6 +1065,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
notes={pinnedNotes}
|
||||
loading={briefingLoading}
|
||||
onSelect={onNoteSelect}
|
||||
formatRelativeTime={relTime}
|
||||
/>,
|
||||
)
|
||||
case 'usage':
|
||||
@@ -1058,11 +1081,11 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
aiStatus, insights, topBridgeNotes, bridgeSuggestions, agentActions, handleRefreshEcho,
|
||||
handleEnableAi, echoRefreshing, handleDismissInsight, handleDismissBridgeSuggestion,
|
||||
handleCreateBridgeSuggestion, handleOpenFromInsight, dismissingInsightId,
|
||||
actingBridgeSuggestionKey, reminders, gmail, dateLocale, router, mindMap,
|
||||
agentSuggestions, actingSuggestionId, formatAgentFrequency, handleAcceptSuggestion,
|
||||
actingBridgeSuggestionKey, pathBridgeKeys, reminders, gmail, dateLocale, router, mindMap,
|
||||
agentSuggestions, actingSuggestionId, createdAgent, formatAgentFrequency, handleAcceptSuggestion,
|
||||
handleDismissSuggestion, sentiment, pinnedNotes, writingActivity, themeCount,
|
||||
pathsList, openLoopsList, handlePathAction, dailyReviewItems, linkSuggestionPaths,
|
||||
handleOpenDailyNote, flashcardStats,
|
||||
handleOpenDailyNote, flashcardStats, focusNote,
|
||||
])
|
||||
|
||||
return (
|
||||
@@ -1078,7 +1101,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { void reloadBriefingAndPaths() }}
|
||||
className="shrink-0 text-[10px] font-mono font-bold uppercase tracking-wider px-3 py-2 rounded-lg bg-ink text-white dark:bg-white dark:text-black"
|
||||
className="shrink-0 text-[10px] font-mono font-bold uppercase tracking-wider px-3 py-2 rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90"
|
||||
>
|
||||
{t('homeDashboard.briefingRetry')}
|
||||
</button>
|
||||
@@ -1086,20 +1109,13 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
)}
|
||||
{/* ── En-tête : orientation en 2 secondes ── */}
|
||||
<header className="mb-5">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end sm:justify-between gap-2 pb-4 border-b border-border/20">
|
||||
<div>
|
||||
<h1 className="font-serif text-2xl sm:text-3xl font-medium text-ink dark:text-dark-ink tracking-tight">
|
||||
{t('homeDashboard.title')}
|
||||
</h1>
|
||||
<p className="text-[10px] font-mono uppercase tracking-[0.2em] text-concrete font-bold mt-1">
|
||||
{new Date().toLocaleDateString(dateLocale, { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</p>
|
||||
</div>
|
||||
{!briefingLoading && briefingSubtitle && (
|
||||
<p className="text-[11px] text-concrete leading-relaxed max-w-md sm:text-end">
|
||||
{briefingSubtitle}
|
||||
</p>
|
||||
)}
|
||||
<div className="pb-4 border-b border-border/20">
|
||||
<h1 className="font-serif text-2xl sm:text-3xl font-medium text-ink dark:text-dark-ink tracking-tight">
|
||||
{t('homeDashboard.title')}
|
||||
</h1>
|
||||
<p className="text-[10px] font-mono uppercase tracking-[0.2em] text-concrete font-bold mt-1">
|
||||
{new Date().toLocaleDateString(dateLocale, { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Pulse : file d'attente cognitive en un coup d'œil */}
|
||||
@@ -1121,15 +1137,12 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="pb-24">
|
||||
<div className="pb-8">
|
||||
<DashboardWidgetGrid
|
||||
renderWidget={renderWidget}
|
||||
isWidgetEmpty={(id) => {
|
||||
if (briefingLoading) return false
|
||||
if (id === 'sentiment') return !sentimentLoading && (!sentiment?.available || !sentiment?.dominantEmotion)
|
||||
if (id === 'reminders') return reminders.length === 0
|
||||
if (id === 'revision') return dueFlashcards === 0
|
||||
if (id === 'inbox') return inboxCount === 0
|
||||
return false
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -240,7 +240,19 @@ export function DashboardWidgetGrid({ renderWidget, isWidgetEmpty }: DashboardWi
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div className={editMode ? 'space-y-4 pb-24' : 'space-y-4'}>
|
||||
{!editMode && loaded && hasVisibleWidgets && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditMode(true)}
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-3 py-1.5 rounded-xl text-brand-accent hover:bg-brand-accent/10 transition-colors"
|
||||
>
|
||||
<LayoutGrid size={12} />
|
||||
{t('homeDashboard.widgetCustomize')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{loaded ? (
|
||||
hasVisibleWidgets ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
@@ -311,9 +323,8 @@ export function DashboardWidgetGrid({ renderWidget, isWidgetEmpty }: DashboardWi
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editMode && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-2 px-3 py-2 rounded-2xl bg-ink/92 dark:bg-zinc-900/95 text-white shadow-xl border border-white/10 backdrop-blur-md">
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCatalogOpen(v => !v)}
|
||||
@@ -341,18 +352,8 @@ export function DashboardWidgetGrid({ renderWidget, isWidgetEmpty }: DashboardWi
|
||||
<Check size={12} />
|
||||
{t('homeDashboard.widgetDone')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditMode(true)}
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase font-bold px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 transition-colors"
|
||||
>
|
||||
<LayoutGrid size={12} />
|
||||
{t('homeDashboard.widgetCustomize')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editMode && catalogOpen && (
|
||||
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-40 w-[min(520px,calc(100vw-2rem))] max-h-[min(60vh,480px)] overflow-y-auto custom-scrollbar p-4 rounded-2xl bg-white dark:bg-zinc-900 border border-border/40 shadow-2xl">
|
||||
|
||||
@@ -10,6 +10,7 @@ interface DashboardWidgetTitleRowProps {
|
||||
title: string
|
||||
actions?: ReactNode
|
||||
className?: string
|
||||
wrapTitle?: boolean
|
||||
}
|
||||
|
||||
/** Titre widget : actions d’abord, aide « ? » en dernier — ne masque jamais la navigation. */
|
||||
@@ -19,12 +20,15 @@ export function DashboardWidgetTitleRow({
|
||||
title,
|
||||
actions,
|
||||
className = 'mb-3',
|
||||
wrapTitle,
|
||||
}: DashboardWidgetTitleRowProps) {
|
||||
return (
|
||||
<div className={`flex items-center justify-between gap-2 ${className}`}>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{icon}
|
||||
<h2 className="text-[10px] font-mono font-bold uppercase tracking-widest text-ink dark:text-dark-ink truncate">
|
||||
<div className={`flex items-start justify-between gap-2 ${className}`}>
|
||||
<div className="flex items-start gap-2 min-w-0 flex-1">
|
||||
{icon ? <span className="shrink-0 mt-0.5">{icon}</span> : null}
|
||||
<h2 className={`text-[13px] font-semibold uppercase tracking-wider text-ink dark:text-dark-ink ${
|
||||
wrapTitle ? 'leading-tight whitespace-normal' : 'truncate'
|
||||
}`}>
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,8 @@ interface HeatmapDay {
|
||||
interface RevisionHeatmapProps {
|
||||
data: HeatmapDay[]
|
||||
className?: string
|
||||
/** Révisions de flashcards, ou notes modifiées sur le tableau de bord. */
|
||||
kind?: 'reviews' | 'edits'
|
||||
}
|
||||
|
||||
function intensityClass(count: number, max: number): string {
|
||||
@@ -28,19 +30,25 @@ function resolveDateLocale(langCode: string): string {
|
||||
return langCode
|
||||
}
|
||||
|
||||
export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
|
||||
export function RevisionHeatmap({ data, className, kind = 'reviews' }: RevisionHeatmapProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const [hovered, setHovered] = useState<{ label: string; count: number; date: string } | null>(null)
|
||||
const [selected, setSelected] = useState<{ label: string; count: number; date: string } | null>(null)
|
||||
|
||||
const dateLocale = resolveDateLocale(language ?? 'en')
|
||||
const prefix = kind === 'edits' ? 'homeDashboard.activityHeatmap' : 'flashcards.heatmap'
|
||||
|
||||
const dayLabel = (count: number) => {
|
||||
if (count <= 0) return t(`${prefix}DayNone`)
|
||||
if (count === 1) return t(`${prefix}DayOne`)
|
||||
return t(`${prefix}Day`, { count })
|
||||
}
|
||||
|
||||
const { cells, maxCount, totalReviews, monthLabels } = useMemo(() => {
|
||||
const map = new Map(data.map((d) => [d.date, d.count]))
|
||||
const now = new Date()
|
||||
// todayUTC est le début de la journée courante à minuit UTC
|
||||
const todayUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
|
||||
|
||||
|
||||
const cells: { date: string; count: number; label: string }[] = []
|
||||
const monthLabels: { index: number; label: string }[] = []
|
||||
let lastMonth = -1
|
||||
@@ -78,22 +86,22 @@ export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{/* En-tête */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
||||
{t('flashcards.heatmapTitle')}
|
||||
<p className="text-[13px] font-semibold uppercase tracking-wider text-concrete">
|
||||
{t(`${prefix}Title`)}
|
||||
</p>
|
||||
<span className="text-[10px] text-concrete/60">
|
||||
{totalReviews > 0 ? `${totalReviews} révisions · 90 jours` : t('flashcards.heatmapLast90')}
|
||||
<span className="text-[13px] text-concrete/70">
|
||||
{totalReviews > 0
|
||||
? t(`${prefix}Total`, { count: totalReviews })
|
||||
: t(kind === 'edits' ? 'homeDashboard.activityHeatmapLast90' : 'flashcards.heatmapLast90')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Labels de mois au-dessus de la grille */}
|
||||
<div className="relative h-4">
|
||||
<div className="relative h-6">
|
||||
{monthLabels.map((m) => (
|
||||
<span
|
||||
key={m.label + m.index}
|
||||
className="absolute text-[9px] text-concrete/60 font-medium translate-y-0.5"
|
||||
className="absolute text-[13px] text-concrete/70 font-medium leading-tight"
|
||||
style={{ left: pct(m.index) }}
|
||||
>
|
||||
{m.label}
|
||||
@@ -101,14 +109,11 @@ export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Grille pleine largeur */}
|
||||
<div className="grid grid-cols-[repeat(15,minmax(0,1fr))] gap-1 sm:grid-cols-[repeat(18,minmax(0,1fr))]">
|
||||
{cells.map((cell) => {
|
||||
const isHovered = hovered?.date === cell.date
|
||||
const isSelected = selected?.date === cell.date
|
||||
const reviewText = cell.count > 0
|
||||
? `${cell.count} révision${cell.count > 1 ? 's' : ''}`
|
||||
: 'Aucune révision'
|
||||
const reviewText = dayLabel(cell.count)
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -134,47 +139,29 @@ export function RevisionHeatmap({ data, className }: RevisionHeatmapProps) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Info au survol / clic */}
|
||||
<div className="h-6 flex items-center justify-between text-[11px] border-b border-border/20 pb-1">
|
||||
<div className="min-h-7 flex items-center text-[13px] border-b border-border/20 pb-1">
|
||||
{activeInfo ? (
|
||||
<p className="flex items-center gap-1.5 animate-fadeIn">
|
||||
<span className="font-semibold text-foreground">
|
||||
{activeInfo.count > 0
|
||||
? `${activeInfo.count} révision${activeInfo.count > 1 ? 's' : ''}`
|
||||
: 'Aucune révision'}
|
||||
{dayLabel(activeInfo.count)}
|
||||
</span>
|
||||
<span className="text-concrete">· {activeInfo.label}</span>
|
||||
{selected?.date === activeInfo.date && !hovered && (
|
||||
<span className="text-[9px] bg-brand-accent/10 text-brand-accent px-1.5 py-0.2 rounded-full font-medium">
|
||||
sélectionné
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[10px] text-concrete/40 italic">
|
||||
Survolez ou cliquez sur un carré pour voir le détail
|
||||
<p className="text-[13px] text-concrete/50 italic">
|
||||
{t(`${prefix}Hint`)}
|
||||
</p>
|
||||
)}
|
||||
{selected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
className="text-[10px] text-brand-accent hover:text-brand-accent/80 hover:underline transition-colors"
|
||||
>
|
||||
Effacer la sélection
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Légende */}
|
||||
<div className="flex items-center gap-2 pt-0.5">
|
||||
<span className="text-[9px] text-concrete/50">Moins</span>
|
||||
<span className="text-[13px] text-concrete/60">{t('flashcards.heatmapLess')}</span>
|
||||
<div className="flex gap-0.5">
|
||||
{['bg-black/[0.06] dark:bg-white/[0.08]', 'bg-brand-accent/20', 'bg-brand-accent/40', 'bg-brand-accent/70', 'bg-brand-accent'].map((cls, i) => (
|
||||
<div key={i} className={cn('w-3 h-3 rounded-[3px]', cls)} />
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[9px] text-concrete/50">Plus</span>
|
||||
<span className="text-[13px] text-concrete/60">{t('flashcards.heatmapMore')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -35,6 +35,8 @@ import { NotebookOrganizerDialog } from '@/components/wizard/notebook-organizer-
|
||||
import { toast } from 'sonner'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
|
||||
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
|
||||
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
|
||||
|
||||
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||||
@@ -160,6 +162,7 @@ export function HomeClient({
|
||||
const aiMenuRef = useRef<HTMLDivElement>(null)
|
||||
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
|
||||
const [showOrganizer, setShowOrganizer] = useState(false)
|
||||
const [notePendingDelete, setNotePendingDelete] = useState<Note | null>(null)
|
||||
|
||||
const handleExportCSV = useCallback(() => {
|
||||
if (!searchParams.get('notebook')) return
|
||||
@@ -535,21 +538,25 @@ export function HomeClient({
|
||||
[patchNoteInList, t]
|
||||
)
|
||||
|
||||
const handleDeleteNoteFromList = useCallback(
|
||||
async (note: Note) => {
|
||||
removeNoteFromList(note.id)
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
try {
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
toast.success(t('notes.deleted') || 'Note supprimée')
|
||||
} catch {
|
||||
setNotes((prev) => [note, ...prev])
|
||||
emitNoteChange({ type: 'created', note })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
},
|
||||
[removeNoteFromList, t]
|
||||
)
|
||||
const handleDeleteNoteFromList = useCallback((note: Note) => {
|
||||
setNotePendingDelete(note)
|
||||
}, [])
|
||||
|
||||
const confirmDeleteNoteFromList = useCallback(async () => {
|
||||
const note = notePendingDelete
|
||||
if (!note) return
|
||||
setNotePendingDelete(null)
|
||||
removeNoteFromList(note.id)
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
try {
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
showNoteTrashedToast(note, t, () => setNotes((prev) => [note, ...prev]))
|
||||
} catch {
|
||||
setNotes((prev) => [note, ...prev])
|
||||
emitNoteChange({ type: 'created', note })
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
}, [notePendingDelete, removeNoteFromList, t])
|
||||
|
||||
const handleArchiveNoteFromList = useCallback(
|
||||
async (note: Note) => {
|
||||
@@ -1491,6 +1498,14 @@ export function HomeClient({
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteNoteDialog
|
||||
open={notePendingDelete != null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setNotePendingDelete(null)
|
||||
}}
|
||||
onConfirm={confirmDeleteNoteFromList}
|
||||
/>
|
||||
|
||||
{showNotebookSlides && currentNotebook && (
|
||||
<NotebookSlidesDialog
|
||||
notebookId={currentNotebook.id}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row'
|
||||
import { MEMORY_ECHO_LEGACY_EN_FALLBACKS } from '@/lib/ai/memory-echo-i18n'
|
||||
import { pathNoteTitle } from '@/lib/dashboard/path-title'
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────
|
||||
|
||||
@@ -104,14 +105,14 @@ function ConnectionDiagram({
|
||||
className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start"
|
||||
style={{ borderColor: `${color}30` }}
|
||||
>
|
||||
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate leading-tight">
|
||||
<p className="text-sm font-semibold text-ink dark:text-dark-ink line-clamp-2 leading-tight" title={note1Title}>
|
||||
{note1Title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 flex flex-col items-center gap-0.5 px-1">
|
||||
<div className="w-8 h-px" style={{ background: `linear-gradient(90deg, transparent, ${color}, transparent)` }} />
|
||||
<span
|
||||
className="text-[9px] font-mono font-bold px-2 py-0.5 rounded-full"
|
||||
className="text-[13px] font-medium px-2 py-0.5 rounded-full"
|
||||
style={{ color, backgroundColor: `${color}14`, border: `1px solid ${color}25` }}
|
||||
>
|
||||
{Math.round(score * 100)}%
|
||||
@@ -122,7 +123,7 @@ function ConnectionDiagram({
|
||||
className="flex-1 min-w-0 p-2.5 rounded-xl border border-border/30 bg-white/80 dark:bg-zinc-900/60 text-start"
|
||||
style={{ borderColor: `${color}30` }}
|
||||
>
|
||||
<p className="text-[10px] font-semibold text-ink dark:text-dark-ink truncate leading-tight">
|
||||
<p className="text-sm font-semibold text-ink dark:text-dark-ink line-clamp-2 leading-tight" title={note2Title}>
|
||||
{note2Title}
|
||||
</p>
|
||||
</div>
|
||||
@@ -153,6 +154,7 @@ export interface IntelligenceHubProps {
|
||||
dismissingInsightId: string | null
|
||||
actingBridgeSuggestionKey: string | null
|
||||
prefersReducedMotion: boolean
|
||||
excludeBridgeSuggestionKeys?: string[]
|
||||
}
|
||||
|
||||
// ─── Component ─────────────────────────────────────────
|
||||
@@ -178,6 +180,7 @@ export function IntelligenceHub({
|
||||
dismissingInsightId,
|
||||
actingBridgeSuggestionKey,
|
||||
prefersReducedMotion,
|
||||
excludeBridgeSuggestionKeys,
|
||||
}: IntelligenceHubProps) {
|
||||
const router = useRouter()
|
||||
const { t } = useLanguage()
|
||||
@@ -226,10 +229,13 @@ export function IntelligenceHub({
|
||||
})
|
||||
}
|
||||
|
||||
const hiddenSuggestions = new Set(excludeBridgeSuggestionKeys ?? [])
|
||||
for (const suggestion of bridgeSuggestions) {
|
||||
const key = `${suggestion.clusterAId}-${suggestion.clusterBId}`
|
||||
if (hiddenSuggestions.has(key)) continue
|
||||
items.push({
|
||||
kind: 'suggestion',
|
||||
id: `suggestion-${suggestion.clusterAId}-${suggestion.clusterBId}`,
|
||||
id: `suggestion-${key}`,
|
||||
priority: 80,
|
||||
suggestion,
|
||||
})
|
||||
@@ -245,7 +251,7 @@ export function IntelligenceHub({
|
||||
}
|
||||
|
||||
return items.sort((a, b) => b.priority - a.priority).slice(0, 10)
|
||||
}, [insights, bridgeNotes, bridgeSuggestions, agentActions])
|
||||
}, [insights, bridgeNotes, bridgeSuggestions, agentActions, excludeBridgeSuggestionKeys])
|
||||
|
||||
const counts = useMemo(() => ({
|
||||
all: allItems.length,
|
||||
@@ -302,32 +308,32 @@ export function IntelligenceHub({
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Sparkles size={12} className="text-indigo-500" />
|
||||
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">
|
||||
<Sparkles size={12} className="text-brand-accent" />
|
||||
<span className="text-[13px] font-medium text-brand-accent">
|
||||
{t('homeDashboard.semanticConnection')}
|
||||
</span>
|
||||
{!insight.viewed && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-ochre animate-pulse" />
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-brand-accent animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
<ConnectionDiagram
|
||||
note1Title={insight.note1.title || t('homeDashboard.untitled')}
|
||||
note2Title={insight.note2.title || t('homeDashboard.untitled')}
|
||||
note1Title={pathNoteTitle(insight.note1.title, insight.note1Excerpt) || t('homeDashboard.untitled')}
|
||||
note2Title={pathNoteTitle(insight.note2.title, insight.note2Excerpt) || t('homeDashboard.untitled')}
|
||||
score={insight.score}
|
||||
/>
|
||||
<p className="text-[11px] text-ink/75 dark:text-dark-ink/75 font-serif italic leading-relaxed line-clamp-3 px-1">
|
||||
<p className="text-sm text-ink/75 dark:text-dark-ink/75 font-serif italic leading-relaxed line-clamp-3 px-1">
|
||||
« {text} »
|
||||
</p>
|
||||
{excerpt && (
|
||||
<p className="text-[9px] text-concrete/80 line-clamp-2 mt-2 px-1 border-s-2 border-indigo-500/20 ps-2">
|
||||
<p className="text-sm text-concrete/80 line-clamp-2 mt-2 px-1 border-s-2 border-brand-accent/20 ps-2">
|
||||
{excerpt}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 mt-auto pt-3 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 mt-auto pt-3 flex-wrap shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenInsightNote(insight, insight.note1.id)}
|
||||
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
|
||||
className="inline-flex items-center gap-1 text-[13px] font-medium px-2.5 py-1.5 rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors"
|
||||
>
|
||||
<ExternalLink size={9} />
|
||||
{t('homeDashboard.intelOpenNote')}
|
||||
@@ -335,7 +341,7 @@ export function IntelligenceHub({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenInsightNote(insight, insight.note1.id, insight.note2.id)}
|
||||
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-border/40 hover:border-indigo-400/40 transition-colors"
|
||||
className="inline-flex items-center gap-1 text-[13px] font-medium px-2.5 py-1.5 rounded-lg border border-border/40 hover:border-brand-accent/40 transition-colors"
|
||||
>
|
||||
<GitCompare size={9} />
|
||||
{t('homeDashboard.intelCompare')}
|
||||
@@ -358,19 +364,19 @@ export function IntelligenceHub({
|
||||
|
||||
case 'bridge': {
|
||||
const { bridge } = item
|
||||
const title = bridge.note?.title || t('homeDashboard.untitled')
|
||||
const title = pathNoteTitle(bridge.note?.title, bridge.note?.content) || t('homeDashboard.untitled')
|
||||
const excerpt = bridge.note?.content ? stripHtml(bridge.note.content).slice(0, 160) : ''
|
||||
const names = bridge.clusterNames ?? []
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap size={12} className="text-ochre" />
|
||||
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre">
|
||||
<Zap size={12} className="text-brand-accent" />
|
||||
<span className="text-[13px] font-medium text-brand-accent">
|
||||
{t('homeDashboard.bridgeNote')}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[9px] font-mono font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
|
||||
<span className="text-[13px] font-medium text-brand-accent bg-brand-accent/10 px-2 py-0.5 rounded-full">
|
||||
{Math.round(bridge.bridgeScore * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -379,11 +385,11 @@ export function IntelligenceHub({
|
||||
onClick={() => onNoteSelect(bridge.noteId)}
|
||||
className="text-start group flex-1"
|
||||
>
|
||||
<p className="text-sm font-semibold text-ink dark:text-dark-ink group-hover:text-ochre transition-colors mb-2 leading-snug">
|
||||
<p className="text-sm font-semibold text-ink dark:text-dark-ink group-hover:text-brand-accent transition-colors mb-2 leading-snug">
|
||||
{title}
|
||||
</p>
|
||||
{excerpt && (
|
||||
<p className="text-[10px] text-concrete leading-relaxed line-clamp-3 mb-3">{excerpt}</p>
|
||||
<p className="text-sm text-concrete leading-relaxed line-clamp-3 mb-3">{excerpt}</p>
|
||||
)}
|
||||
{names.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
@@ -396,7 +402,7 @@ export function IntelligenceHub({
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: CLUSTER_COLORS[i % CLUSTER_COLORS.length] }}
|
||||
/>
|
||||
<span className="text-[8px] font-mono uppercase text-concrete">{name}</span>
|
||||
<span className="text-[13px] text-concrete">{name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -405,7 +411,7 @@ export function IntelligenceHub({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNoteSelect(bridge.noteId)}
|
||||
className="mt-3 inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-ochre/90 text-white hover:bg-ochre transition-colors w-fit"
|
||||
className="mt-3 inline-flex items-center gap-1 text-[13px] font-medium px-2.5 py-1.5 rounded-lg bg-brand-accent/90 text-white hover:bg-brand-accent transition-colors w-fit"
|
||||
>
|
||||
<ExternalLink size={9} />
|
||||
{t('homeDashboard.intelOpenNote')}
|
||||
@@ -421,28 +427,28 @@ export function IntelligenceHub({
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Lightbulb size={12} className="text-violet-500" />
|
||||
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-violet-600 dark:text-violet-400 truncate">
|
||||
<Lightbulb size={12} className="text-brand-accent" />
|
||||
<span className="text-[13px] font-medium text-brand-accent truncate">
|
||||
{t('homeDashboard.intelMissingLink')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="px-2 py-1 rounded-lg bg-violet-500/10 text-[9px] font-mono font-bold text-violet-700 dark:text-violet-300 truncate max-w-[45%]">
|
||||
<span className="px-2 py-1 rounded-lg bg-brand-accent/10 text-[13px] font-medium text-brand-accent truncate max-w-[45%]" title={suggestion.clusterAName}>
|
||||
{suggestion.clusterAName}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-violet-400/40 via-ochre/60 to-violet-400/40" />
|
||||
<span className="px-2 py-1 rounded-lg bg-ochre/10 text-[9px] font-mono font-bold text-ochre truncate max-w-[45%]">
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-brand-accent/40 via-brand-accent/60 to-brand-accent/40" />
|
||||
<span className="px-2 py-1 rounded-lg bg-brand-accent/10 text-[13px] font-medium text-brand-accent truncate max-w-[45%]" title={suggestion.clusterBName}>
|
||||
{suggestion.clusterBName}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm font-semibold text-ink dark:text-dark-ink mb-1.5">{suggestion.suggestedTitle}</p>
|
||||
<p className="text-[10px] text-concrete leading-relaxed line-clamp-2 flex-1">{suggestion.suggestedContent}</p>
|
||||
<div className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border/15">
|
||||
<p className="text-sm text-concrete leading-relaxed line-clamp-2 flex-1">{suggestion.suggestedContent}</p>
|
||||
<div className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border/15 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onCreateBridgeSuggestion(suggestion)}
|
||||
className="inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg bg-violet-600 text-white hover:bg-violet-700 transition-colors disabled:opacity-40"
|
||||
className="inline-flex items-center gap-1 text-[13px] font-medium px-2.5 py-1.5 rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{busy ? <Loader2 size={9} className="animate-spin" /> : <Link2 size={9} />}
|
||||
{t('homeDashboard.createBridgeNote')}
|
||||
@@ -451,7 +457,7 @@ export function IntelligenceHub({
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onDismissBridgeSuggestion(suggestion)}
|
||||
className="text-[8.5px] font-mono uppercase px-2 py-1.5 rounded-lg border border-border/30 text-concrete hover:text-rose-500 disabled:opacity-40"
|
||||
className="text-[13px] font-medium px-2 py-1.5 rounded-lg border border-border/30 text-concrete hover:text-rose-500 disabled:opacity-40"
|
||||
>
|
||||
{t('homeDashboard.dismiss')}
|
||||
</button>
|
||||
@@ -465,25 +471,25 @@ export function IntelligenceHub({
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Bot size={12} className="text-ochre" />
|
||||
<span className="text-[9px] font-mono font-bold uppercase tracking-wider text-ochre">
|
||||
<Bot size={12} className="text-brand-accent" />
|
||||
<span className="text-[13px] font-medium text-brand-accent">
|
||||
{agent.agentName}
|
||||
</span>
|
||||
<span className="text-[8px] font-mono text-concrete/60 ms-auto">
|
||||
<span className="text-[13px] text-concrete/60 ms-auto">
|
||||
{formatRelativeTime(agent.createdAt, t)}
|
||||
</span>
|
||||
</div>
|
||||
{agent.result ? (
|
||||
<p className="text-[11px] text-concrete leading-relaxed line-clamp-4 flex-1">
|
||||
<p className="text-sm text-concrete leading-relaxed line-clamp-4 flex-1">
|
||||
{agent.result}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-concrete/60 italic flex-1">{t('homeDashboard.intelAgentNoResult')}</p>
|
||||
<p className="text-sm text-concrete/60 italic flex-1">{t('homeDashboard.intelAgentNoResult')}</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/agents')}
|
||||
className="mt-3 inline-flex items-center gap-1 text-[8.5px] font-mono uppercase font-bold px-2.5 py-1.5 rounded-lg border border-ochre/30 text-ochre hover:bg-ochre/10 transition-colors w-fit"
|
||||
className="mt-3 inline-flex items-center gap-1 text-[13px] font-medium px-2.5 py-1.5 rounded-lg border border-brand-accent/30 text-brand-accent hover:bg-brand-accent/10 transition-colors w-fit"
|
||||
>
|
||||
{t('homeDashboard.intelViewAgent')}
|
||||
<ArrowRight size={9} />
|
||||
@@ -497,10 +503,10 @@ export function IntelligenceHub({
|
||||
const spotlightAccent = (item: IntelItem | null) => {
|
||||
if (!item) return 'from-stone-100/50 to-transparent border-border/30'
|
||||
switch (item.kind) {
|
||||
case 'insight': return 'from-indigo-500/[0.06] via-transparent to-transparent border-indigo-400/25'
|
||||
case 'bridge': return 'from-ochre/[0.06] via-transparent to-transparent border-ochre/25'
|
||||
case 'suggestion': return 'from-violet-500/[0.06] via-transparent to-transparent border-violet-400/25'
|
||||
case 'agent': return 'from-ochre/[0.04] via-transparent to-transparent border-border/30'
|
||||
case 'insight': return 'from-brand-accent/[0.06] via-transparent to-transparent border-brand-accent/25'
|
||||
case 'bridge': return 'from-brand-accent/[0.06] via-transparent to-transparent border-brand-accent/25'
|
||||
case 'suggestion': return 'from-brand-accent/[0.06] via-transparent to-transparent border-brand-accent/25'
|
||||
case 'agent': return 'from-brand-accent/[0.04] via-transparent to-transparent border-border/30'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -518,7 +524,7 @@ export function IntelligenceHub({
|
||||
actions={(
|
||||
<>
|
||||
{newCount > 0 && (
|
||||
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-2 py-0.5 rounded uppercase">
|
||||
<span className="text-[13px] font-medium text-brand-accent bg-brand-accent/10 px-2 py-0.5 rounded">
|
||||
{newCount} {t('homeDashboard.new')}
|
||||
</span>
|
||||
)}
|
||||
@@ -543,7 +549,7 @@ export function IntelligenceHub({
|
||||
<div className="h-[220px] rounded-2xl bg-stone-50 dark:bg-zinc-950/30 animate-pulse" />
|
||||
) : !aiActive ? (
|
||||
<div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center">
|
||||
<p className="text-xs text-concrete leading-relaxed">
|
||||
<p className="text-sm text-concrete leading-relaxed">
|
||||
{!hasAiConsent
|
||||
? t('homeDashboard.aiConsentRequired')
|
||||
: !providerReady
|
||||
@@ -554,7 +560,7 @@ export function IntelligenceHub({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEnableAi}
|
||||
className="text-[9px] font-mono uppercase font-bold px-3 py-1.5 rounded-lg bg-ink text-white dark:bg-white dark:text-black hover:opacity-90 transition-opacity mx-auto"
|
||||
className="text-[13px] font-medium px-3 py-1.5 rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors mx-auto"
|
||||
>
|
||||
{t('homeDashboard.enableAi')}
|
||||
</button>
|
||||
@@ -563,12 +569,12 @@ export function IntelligenceHub({
|
||||
) : allItems.length === 0 ? (
|
||||
<div className="p-5 rounded-2xl border border-dashed border-border/40 bg-stone-50/50 dark:bg-zinc-950/30 text-center space-y-3 min-h-[180px] flex flex-col justify-center">
|
||||
<Brain size={22} className="mx-auto text-concrete/40" strokeWidth={1.25} />
|
||||
<p className="text-xs text-concrete leading-relaxed">{t('homeDashboard.noConnections')}</p>
|
||||
<p className="text-sm text-concrete leading-relaxed">{t('homeDashboard.noConnections')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefreshEcho}
|
||||
disabled={echoRefreshing}
|
||||
className="inline-flex items-center gap-1.5 text-[9px] font-mono uppercase font-bold px-3 py-1.5 rounded-lg border border-ochre/30 text-ochre hover:bg-ochre/10 transition-all disabled:opacity-40 mx-auto"
|
||||
className="inline-flex items-center gap-1.5 text-[13px] font-medium px-3 py-1.5 rounded-lg border border-brand-accent/30 text-brand-accent hover:bg-brand-accent/10 transition-all disabled:opacity-40 mx-auto"
|
||||
>
|
||||
{echoRefreshing ? <Loader2 size={11} className="animate-spin" /> : <Sparkles size={11} />}
|
||||
{t('homeDashboard.analyzeNotes')}
|
||||
@@ -587,15 +593,15 @@ export function IntelligenceHub({
|
||||
key={f.key}
|
||||
type="button"
|
||||
onClick={() => setFilter(f.key)}
|
||||
className={`shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[8.5px] font-mono font-bold uppercase tracking-wider transition-all ${
|
||||
className={`shrink-0 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[13px] font-medium transition-all ${
|
||||
active
|
||||
? 'bg-ink text-white dark:bg-white dark:text-black shadow-sm'
|
||||
? 'bg-brand-accent text-white shadow-sm'
|
||||
: 'bg-stone-100 dark:bg-zinc-800 text-concrete hover:text-ink dark:hover:text-dark-ink'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
{count > 0 && (
|
||||
<span className={`text-[7px] px-1 py-px rounded-full ${active ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
|
||||
<span className={`text-[13px] px-1.5 py-0.5 rounded-full ${active ? 'bg-white/20' : 'bg-black/5 dark:bg-white/10'}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
@@ -605,7 +611,7 @@ export function IntelligenceHub({
|
||||
</div>
|
||||
|
||||
{filteredItems.length === 0 ? (
|
||||
<p className="text-xs text-concrete italic text-center py-8">{t('homeDashboard.intelFilterEmpty')}</p>
|
||||
<p className="text-sm text-concrete italic text-center py-8">{t('homeDashboard.intelFilterEmpty')}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Spotlight carousel */}
|
||||
@@ -616,7 +622,7 @@ export function IntelligenceHub({
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
disabled={activeIndex === 0}
|
||||
className="absolute start-0 top-1/2 -translate-y-1/2 -translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-ochre/40 transition-all"
|
||||
className="absolute start-0 top-1/2 -translate-y-1/2 -translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-brand-accent/40 transition-all"
|
||||
aria-label={t('homeDashboard.intelPrev')}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
@@ -625,7 +631,7 @@ export function IntelligenceHub({
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
disabled={activeIndex >= filteredItems.length - 1}
|
||||
className="absolute end-0 top-1/2 -translate-y-1/2 translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-ochre/40 transition-all"
|
||||
className="absolute end-0 top-1/2 -translate-y-1/2 translate-x-1 z-10 p-1 rounded-full border border-border/40 bg-white/90 dark:bg-zinc-900/90 shadow-sm disabled:opacity-25 hover:border-brand-accent/40 transition-all"
|
||||
aria-label={t('homeDashboard.intelNext')}
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
@@ -664,14 +670,14 @@ export function IntelligenceHub({
|
||||
onClick={() => setActiveIndex(idx)}
|
||||
className={`rounded-full transition-all ${
|
||||
idx === activeIndex
|
||||
? 'w-5 h-1.5 bg-ochre'
|
||||
? 'w-5 h-1.5 bg-brand-accent'
|
||||
: 'w-1.5 h-1.5 bg-concrete/25 hover:bg-concrete/50'
|
||||
}`}
|
||||
aria-label={t('homeDashboard.intelGoTo', { index: idx + 1 })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[8px] font-mono text-concrete/60 uppercase">
|
||||
<span className="text-[13px] text-concrete/60">
|
||||
{t('homeDashboard.intelPosition', { current: activeIndex + 1, total: filteredItems.length })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -684,37 +690,39 @@ export function IntelligenceHub({
|
||||
const isActive = idx === activeIndex
|
||||
let label = ''
|
||||
let Icon = Sparkles
|
||||
let accent = 'text-indigo-500'
|
||||
let accent = 'text-brand-accent'
|
||||
if (item.kind === 'insight') {
|
||||
label = item.insight.note1.title?.slice(0, 28) || t('homeDashboard.untitled')
|
||||
label = pathNoteTitle(item.insight.note1.title, item.insight.note1Excerpt) || t('homeDashboard.untitled')
|
||||
Icon = Sparkles
|
||||
accent = 'text-indigo-500'
|
||||
accent = 'text-brand-accent'
|
||||
} else if (item.kind === 'bridge') {
|
||||
label = item.bridge.note?.title?.slice(0, 28) || t('homeDashboard.untitled')
|
||||
label = pathNoteTitle(item.bridge.note?.title, item.bridge.note?.content) || t('homeDashboard.untitled')
|
||||
Icon = Zap
|
||||
accent = 'text-ochre'
|
||||
accent = 'text-brand-accent'
|
||||
} else if (item.kind === 'suggestion') {
|
||||
label = item.suggestion.suggestedTitle.slice(0, 28)
|
||||
label = item.suggestion.suggestedTitle
|
||||
Icon = Lightbulb
|
||||
accent = 'text-violet-500'
|
||||
accent = 'text-brand-accent'
|
||||
} else {
|
||||
label = item.agent.agentName
|
||||
Icon = Bot
|
||||
accent = 'text-ochre'
|
||||
accent = 'text-brand-accent'
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setActiveIndex(idx)}
|
||||
className={`shrink-0 flex items-center gap-1.5 px-2 py-1.5 rounded-lg border text-start max-w-[130px] transition-all ${
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className={`shrink-0 flex items-center gap-1.5 px-2 py-1.5 rounded-lg border text-start max-w-[180px] transition-all ${
|
||||
isActive
|
||||
? 'border-ochre/40 bg-ochre/5 shadow-sm'
|
||||
? 'border-brand-accent/40 bg-brand-accent/5 shadow-sm'
|
||||
: 'border-border/25 bg-stone-50/50 dark:bg-zinc-950/30 hover:border-border/50'
|
||||
}`}
|
||||
>
|
||||
<Icon size={9} className={`shrink-0 ${accent}`} />
|
||||
<span className="text-[8px] font-medium text-ink dark:text-dark-ink truncate">{label}</span>
|
||||
<span className="text-[13px] font-medium text-ink dark:text-dark-ink truncate">{label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useLanguage } from '@/lib/i18n'
|
||||
import type { SupportedLanguage } from '@/lib/i18n/load-translations'
|
||||
import { SUBSCRIPTION_TRIAL_DAYS } from '@/lib/billing/trial-constants'
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
const ECHO_LINES = ['echo0', 'echo1', 'echo2'] as const
|
||||
|
||||
@@ -39,6 +40,16 @@ export function LandingPage() {
|
||||
const [langOpen, setLangOpen] = useState(false)
|
||||
const [echoIndex, setEchoIndex] = useState(0)
|
||||
const langRef = useRef<HTMLDivElement>(null)
|
||||
const { data: byokCatalog } = useQuery({
|
||||
queryKey: ['public', 'byok-catalog'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/public/byok-catalog')
|
||||
if (!res.ok) throw new Error('catalog')
|
||||
return res.json() as Promise<{ providers: { id: string; name: string }[] }>
|
||||
},
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const byokProviders = byokCatalog?.providers ?? []
|
||||
|
||||
useEffect(() => {
|
||||
if (!langOpen) return
|
||||
@@ -102,6 +113,22 @@ export function LandingPage() {
|
||||
{ href: '#pricing', label: t('landing.nav.pricing') },
|
||||
]
|
||||
|
||||
const scrollPublicHash = (hash: string) => {
|
||||
const id = hash.replace(/^#/, '')
|
||||
const target = document.getElementById(id)
|
||||
const root = document.querySelector<HTMLElement>('[data-public-scroll-root]')
|
||||
if (!target || !root) return
|
||||
const top = target.getBoundingClientRect().top - root.getBoundingClientRect().top + root.scrollTop - 88
|
||||
root.scrollTo({ top, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const hash = window.location.hash
|
||||
if (!hash) return
|
||||
const id = window.requestAnimationFrame(() => scrollPublicHash(hash))
|
||||
return () => window.cancelAnimationFrame(id)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#0B0A09] text-[#F4F1EA] font-[family-name:var(--font-manrope)] selection:bg-[#D4A373]/40 selection:text-white">
|
||||
{/* Nav */}
|
||||
@@ -114,7 +141,16 @@ export function LandingPage() {
|
||||
</Link>
|
||||
<div className="hidden lg:flex items-center gap-8">
|
||||
{NAV.map((l) => (
|
||||
<a key={l.href} href={l.href} className="text-[13px] text-white/55 hover:text-white transition-colors">
|
||||
<a
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
scrollPublicHash(l.href)
|
||||
window.history.replaceState(null, '', l.href)
|
||||
}}
|
||||
className="text-[13px] text-white/75 hover:text-white transition-colors"
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
@@ -167,7 +203,7 @@ export function LandingPage() {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<Link href="/login" className="hidden sm:inline text-[13px] text-white/55 hover:text-white transition-colors px-2">
|
||||
<Link href="/login" className="hidden sm:inline text-[13px] text-white/75 hover:text-white transition-colors px-2">
|
||||
{t('landing.nav.login')}
|
||||
</Link>
|
||||
<Link
|
||||
@@ -200,13 +236,25 @@ export function LandingPage() {
|
||||
<a
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
setMenuOpen(false)
|
||||
scrollPublicHash(l.href)
|
||||
window.history.replaceState(null, '', l.href)
|
||||
}}
|
||||
className="py-4 text-3xl font-serif border-b border-white/10"
|
||||
>
|
||||
{l.label}
|
||||
</a>
|
||||
))}
|
||||
<Link href="/register" onClick={() => setMenuOpen(false)} className="mt-10 py-4 rounded-2xl bg-[#F4F1EA] text-[#0B0A09] text-center font-semibold">
|
||||
<Link
|
||||
href="/login"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="mt-10 py-4 text-2xl font-serif text-white/80 text-center border-b border-white/10"
|
||||
>
|
||||
{t('landing.nav.login')}
|
||||
</Link>
|
||||
<Link href="/register" onClick={() => setMenuOpen(false)} className="mt-4 py-4 rounded-2xl bg-[#F4F1EA] text-[#0B0A09] text-center font-semibold">
|
||||
{t('landing.nav.cta')}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -237,7 +285,7 @@ export function LandingPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[13px] tracking-[0.12em] uppercase text-white/40 mb-5 font-medium">
|
||||
<p className="text-[13px] tracking-[0.12em] uppercase text-white/70 mb-5 font-medium">
|
||||
{t('landing.hero.eyebrow')}
|
||||
</p>
|
||||
|
||||
@@ -247,7 +295,7 @@ export function LandingPage() {
|
||||
<span className="italic text-[#D4A373]">{t('landing.hero.headlineAccent')}</span>
|
||||
</h1>
|
||||
|
||||
<p className="max-w-xl mx-auto text-[17px] sm:text-lg text-white/55 leading-relaxed mb-10">
|
||||
<p className="max-w-xl mx-auto text-[17px] sm:text-lg text-white/75 leading-relaxed mb-10">
|
||||
{t('landing.hero.subtitle')}
|
||||
</p>
|
||||
|
||||
@@ -259,7 +307,7 @@ export function LandingPage() {
|
||||
{t('landing.hero.cta')}
|
||||
<ArrowRight size={18} className="transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
<p className="text-[12px] text-white/35">{t('landing.hero.ctaHint')}</p>
|
||||
<p className="text-[12px] text-white/60">{t('landing.hero.ctaHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -307,7 +355,7 @@ export function LandingPage() {
|
||||
|
||||
{/* Trust strip */}
|
||||
<section className="px-5 sm:px-8 py-10 border-y border-white/[0.06]">
|
||||
<div className="max-w-5xl mx-auto flex flex-wrap items-center justify-center gap-x-10 gap-y-4 text-[12px] sm:text-[13px] text-white/40 tracking-wide">
|
||||
<div className="max-w-5xl mx-auto flex flex-wrap items-center justify-center gap-x-10 gap-y-4 text-[12px] sm:text-[13px] text-white/70 tracking-wide">
|
||||
{['trust0', 'trust1', 'trust2', 'trust3'].map((k) => (
|
||||
<span key={k} className="flex items-center gap-2">
|
||||
<Check size={14} className="text-[#D4A373]" />
|
||||
@@ -324,7 +372,7 @@ export function LandingPage() {
|
||||
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight leading-[1.15] mb-6">
|
||||
{t('landing.pain.title')}
|
||||
</h2>
|
||||
<p className="text-lg text-white/50 leading-relaxed mb-8">{t('landing.pain.desc')}</p>
|
||||
<p className="text-lg text-white/70 leading-relaxed mb-8">{t('landing.pain.desc')}</p>
|
||||
<p className="text-base sm:text-lg font-serif italic text-[#D4A373]/90 leading-relaxed">
|
||||
{t('landing.pain.secondBrain')}
|
||||
</p>
|
||||
@@ -366,7 +414,7 @@ export function LandingPage() {
|
||||
<div className="grid grid-cols-2 gap-2 p-2">
|
||||
{['w0', 'w1', 'w2', 'w3'].map((w) => (
|
||||
<div key={w} className="rounded-xl bg-[#0B0A09]/40 border border-black/10 p-4 min-h-[88px]">
|
||||
<p className="text-[10px] uppercase tracking-wider text-[#A47148] mb-2">{t(`landing.moments.dashboard.${w}Label`)}</p>
|
||||
<p className="text-[13px] font-medium text-[#A47148] mb-2">{t(`landing.moments.dashboard.${w}Label`)}</p>
|
||||
<p className="text-sm font-medium text-[#0B0A09]/80">{t(`landing.moments.dashboard.${w}`)}</p>
|
||||
</div>
|
||||
))}
|
||||
@@ -383,7 +431,7 @@ export function LandingPage() {
|
||||
>
|
||||
<div className="relative h-36 flex items-center justify-center">
|
||||
<Network className="text-[#D4A373]/80" size={48} strokeWidth={1.25} />
|
||||
<p className="absolute bottom-2 left-4 text-[11px] uppercase tracking-wider text-white/35">
|
||||
<p className="absolute bottom-2 left-4 text-[13px] font-medium text-white/75">
|
||||
{t('landing.moments.insights.chip')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -399,7 +447,7 @@ export function LandingPage() {
|
||||
<p className="font-serif text-[#0B0A09] text-base mb-3">{t('landing.moments.revision.card')}</p>
|
||||
<div className="flex gap-2">
|
||||
<span className="flex-1 py-2 rounded-lg bg-[#0B0A09]/5 text-center text-[11px] font-semibold text-[#0B0A09]/50">?</span>
|
||||
<span className="flex-1 py-2 rounded-lg bg-[#0B0A09] text-center text-[11px] font-semibold text-[#F4F1EA]">SM-2</span>
|
||||
<span className="flex-1 py-2 rounded-lg bg-[#0B0A09] text-center text-[13px] font-semibold text-[#F4F1EA]">{t('landing.moments.revision.badge')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</ProductMoment>
|
||||
@@ -418,7 +466,7 @@ export function LandingPage() {
|
||||
<div key={s} className="relative text-center md:text-left">
|
||||
<div className="text-[13px] font-semibold text-[#D4A373] mb-4 tracking-widest">0{i + 1}</div>
|
||||
<h3 className="font-serif text-xl mb-3">{t(`landing.how.${s}.title`)}</h3>
|
||||
<p className="text-sm text-white/45 leading-relaxed">{t(`landing.how.${s}.desc`)}</p>
|
||||
<p className="text-sm text-white/70 leading-relaxed">{t(`landing.how.${s}.desc`)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -431,14 +479,14 @@ export function LandingPage() {
|
||||
<div className="max-w-2xl mb-14">
|
||||
<p className="text-[12px] uppercase tracking-[0.25em] text-[#D4A373] mb-4 font-medium">{t('landing.agents.label')}</p>
|
||||
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-5">{t('landing.agents.title')}</h2>
|
||||
<p className="text-white/50 text-lg leading-relaxed">{t('landing.agents.desc')}</p>
|
||||
<p className="text-white/70 text-lg leading-relaxed">{t('landing.agents.desc')}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{(['scraper', 'researcher', 'slideGen', 'monitor', 'diagramGen', 'custom'] as const).map((key) => (
|
||||
<div key={key} className="p-6 rounded-2xl border border-white/[0.08] bg-white/[0.03] hover:bg-white/[0.06] transition-colors">
|
||||
<Bot size={18} className="text-[#D4A373] mb-4" />
|
||||
<h4 className="font-serif text-lg mb-2">{t(`landing.agents.${key}.title`)}</h4>
|
||||
<p className="text-sm text-white/40 leading-relaxed">{t(`landing.agents.${key}.desc`)}</p>
|
||||
<p className="text-sm text-white/70 leading-relaxed">{t(`landing.agents.${key}.desc`)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -454,15 +502,19 @@ export function LandingPage() {
|
||||
<span className="text-[12px] uppercase tracking-[0.2em] font-semibold">{t('landing.byok.label')}</span>
|
||||
</div>
|
||||
<h3 className="font-serif text-3xl tracking-tight mb-4">{t('landing.byok.title')}</h3>
|
||||
<p className="text-white/50 leading-relaxed">{t('landing.byok.desc')}</p>
|
||||
<p className="text-white/70 leading-relaxed">{t('landing.byok.desc')}</p>
|
||||
</div>
|
||||
<div className="w-full md:w-[320px] font-mono text-[11px] text-white/35 space-y-1.5 rounded-2xl border border-white/10 bg-black/40 p-5">
|
||||
<p className="text-[#D4A373]">{'{'}</p>
|
||||
<p className="pl-3">"provider": "anthropic",</p>
|
||||
<p className="pl-3">"model": "claude-sonnet",</p>
|
||||
<p className="pl-3 text-[#F4F1EA]/70">"apiKey": "sk-ant-…",</p>
|
||||
<p className="pl-3">"yours": true</p>
|
||||
<p className="text-[#D4A373]">{'}'}</p>
|
||||
<div className="w-full md:w-[320px] text-[13px] text-white/70 space-y-4 rounded-2xl border border-white/10 bg-black/40 p-5">
|
||||
{byokProviders.length > 0 ? (
|
||||
<ul className="grid grid-cols-1 gap-2 text-sm text-[#F4F1EA]">
|
||||
{byokProviders.map((p) => (
|
||||
<li key={p.id}>{p.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-white/70">{t('landing.byok.pointProvider')}</p>
|
||||
)}
|
||||
<p className="text-[#D4A373]">{t('landing.byok.pointYours')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -472,19 +524,19 @@ export function LandingPage() {
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="font-serif text-3xl sm:text-5xl tracking-tight mb-4">{t('landing.pricing.title')}</h2>
|
||||
<p className="text-white/45 mb-8">{t('landing.pricing.desc')}</p>
|
||||
<p className="text-white/70 mb-8">{t('landing.pricing.desc')}</p>
|
||||
<div className="inline-flex p-1 rounded-full border border-white/10 bg-white/[0.03]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingInterval('monthly')}
|
||||
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all ${billingInterval === 'monthly' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`}
|
||||
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all ${billingInterval === 'monthly' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/70'}`}
|
||||
>
|
||||
{t('landing.pricing.monthly')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBillingInterval('annual')}
|
||||
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/45'}`}
|
||||
className={`px-5 py-2 rounded-full text-[12px] font-semibold transition-all relative ${billingInterval === 'annual' ? 'bg-[#F4F1EA] text-[#0B0A09]' : 'text-white/70'}`}
|
||||
>
|
||||
{t('landing.pricing.annual')}
|
||||
<span className="absolute -top-3 -right-1 text-[10px] text-[#D4A373] whitespace-nowrap">
|
||||
@@ -508,19 +560,19 @@ export function LandingPage() {
|
||||
{t('landing.pricing.popular')}
|
||||
</span>
|
||||
)}
|
||||
<h4 className="text-[12px] uppercase tracking-widest text-white/40 mb-2">
|
||||
<h4 className="text-[13px] font-medium tracking-wide text-white/80 mb-2">
|
||||
{t(`landing.pricing.${plan.key}.name`)}
|
||||
</h4>
|
||||
<div className="flex items-baseline gap-1 mb-2">
|
||||
<span className="text-3xl font-serif">{plan.price}</span>
|
||||
{plan.period && <span className="text-xs text-white/35">{plan.period}</span>}
|
||||
{plan.period && <span className="text-sm text-white/70">{plan.period}</span>}
|
||||
</div>
|
||||
{plan.hasTrial && (
|
||||
<p className="text-[11px] font-semibold text-[#D4A373] mb-3">
|
||||
{t('landing.pricing.trialBadge', { days: trialDays })}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-white/45 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||
<p className="text-sm text-white/70 mb-6">{t(`landing.pricing.${plan.key}.desc`)}</p>
|
||||
<ul className="space-y-2.5 mb-8 flex-1">
|
||||
{plan.hasTrial && (
|
||||
<li className="flex gap-2 text-xs text-[#D4A373]/90">
|
||||
@@ -529,10 +581,12 @@ export function LandingPage() {
|
||||
</li>
|
||||
)}
|
||||
{[0, 1, 2, 3, 4, 5].map((j) => {
|
||||
const feat = t(`landing.pricing.${plan.key}.feature${j}`)
|
||||
const feat = t(`landing.pricing.${plan.key}.feature${j}`, {
|
||||
count: byokProviders.length || '…',
|
||||
})
|
||||
if (!feat || feat.startsWith('landing.')) return null
|
||||
return (
|
||||
<li key={j} className="flex gap-2 text-xs text-white/60">
|
||||
<li key={j} className="flex gap-2 text-sm text-white/80">
|
||||
<Check size={12} className="text-[#D4A373] mt-0.5 shrink-0" />
|
||||
{feat}
|
||||
</li>
|
||||
@@ -541,7 +595,7 @@ export function LandingPage() {
|
||||
</ul>
|
||||
<Link
|
||||
href="/register"
|
||||
className={`py-3 rounded-xl text-center text-[12px] font-semibold transition-colors ${
|
||||
className={`py-3 rounded-xl text-center text-[13px] font-semibold transition-colors ${
|
||||
plan.popular
|
||||
? 'bg-[#F4F1EA] text-[#0B0A09] hover:bg-white'
|
||||
: 'bg-white/10 text-white hover:bg-white/15'
|
||||
@@ -567,7 +621,7 @@ export function LandingPage() {
|
||||
<h2 className="font-serif text-4xl sm:text-6xl tracking-tight leading-tight mb-6">
|
||||
{t('landing.cta.title')}
|
||||
</h2>
|
||||
<p className="text-white/50 text-lg mb-10">{t('landing.cta.desc')}</p>
|
||||
<p className="text-white/70 text-lg mb-10">{t('landing.cta.desc')}</p>
|
||||
<Link
|
||||
href="/register"
|
||||
className="inline-flex items-center gap-3 px-10 py-4 rounded-full bg-[#F4F1EA] text-[#0B0A09] text-[15px] font-semibold hover:bg-white transition-all hover:scale-[1.02]"
|
||||
@@ -575,7 +629,7 @@ export function LandingPage() {
|
||||
{t('landing.cta.button')}
|
||||
<ArrowRight size={18} />
|
||||
</Link>
|
||||
<p className="mt-4 text-[12px] text-white/30">{t('landing.hero.ctaHint')}</p>
|
||||
<p className="mt-4 text-[12px] text-white/75">{t('landing.hero.ctaHint')}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -588,15 +642,15 @@ export function LandingPage() {
|
||||
</div>
|
||||
<span className="font-serif text-lg">Memento</span>
|
||||
</div>
|
||||
<p className="text-sm text-white/35">{t('landing.footer.desc')}</p>
|
||||
<p className="text-sm text-white/60">{t('landing.footer.desc')}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-10 text-sm">
|
||||
{(['product', 'community', 'legal'] as const).map((section) => (
|
||||
<div key={section}>
|
||||
<p className="text-[11px] uppercase tracking-widest text-white/40 mb-3">
|
||||
<p className="text-[13px] font-medium tracking-wide text-white/70 mb-3">
|
||||
{t(`landing.footer.${section}.title`)}
|
||||
</p>
|
||||
<ul className="space-y-2 text-white/50">
|
||||
<ul className="space-y-2 text-white/70">
|
||||
{[0, 1, 2].map((j) => {
|
||||
const label = t(`landing.footer.${section}.link${j}`)
|
||||
const href = t(`landing.footer.${section}.link${j}Href`)
|
||||
@@ -616,7 +670,7 @@ export function LandingPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="max-w-6xl mx-auto mt-12 pt-8 border-t border-white/[0.06] text-[11px] text-white/25 tracking-wide">
|
||||
<p className="max-w-6xl mx-auto mt-12 pt-8 border-t border-white/[0.06] text-[13px] text-white/70 tracking-wide">
|
||||
© 2026 Memento. {t('landing.footer.rights')}
|
||||
</p>
|
||||
</footer>
|
||||
@@ -659,7 +713,7 @@ function ProductMoment({
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h3 className="font-serif text-2xl sm:text-3xl tracking-tight mb-3 leading-tight">{title}</h3>
|
||||
<p className={`text-[15px] leading-relaxed ${dark ? 'text-white/50' : 'text-[#0B0A09]/55'}`}>{desc}</p>
|
||||
<p className={`text-[15px] leading-relaxed ${dark ? 'text-white/70' : 'text-[#0B0A09]/75'}`}>{desc}</p>
|
||||
</div>
|
||||
<div className={`${compact ? 'px-4 pb-6' : 'p-6 sm:p-8'} flex items-center`}>
|
||||
<div className="w-full">{children}</div>
|
||||
|
||||
@@ -131,18 +131,18 @@ export function McpSettingsPanel({
|
||||
<Info size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[13px] font-bold text-ink">{t('mcpSettings.whatIsMcp.title')}</h4>
|
||||
<h4 className="text-sm font-semibold text-ink">{t('mcpSettings.whatIsMcp.title')}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-[11px] text-concrete leading-relaxed">
|
||||
<p className="text-sm text-concrete leading-relaxed">
|
||||
{t('mcpSettings.whatIsMcp.description')}
|
||||
</p>
|
||||
<a
|
||||
href="https://modelcontextprotocol.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[10px] font-bold text-brand-accent uppercase tracking-widest hover:underline mt-4"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-brand-accent hover:underline mt-4"
|
||||
>
|
||||
{t('mcpSettings.whatIsMcp.learnMore')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
@@ -156,21 +156,21 @@ export function McpSettingsPanel({
|
||||
<Server size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[13px] font-bold text-ink">{t('mcpSettings.serverStatus.title')}</h4>
|
||||
<h4 className="text-sm font-semibold text-ink">{t('mcpSettings.serverStatus.title')}</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold text-concrete uppercase tracking-widest">{t('mcpSettings.serverStatus.mode')}</span>
|
||||
<span className="text-[10px] font-bold text-ink uppercase tracking-widest bg-paper dark:bg-white/10 px-3 py-1 rounded-lg border border-border">
|
||||
<span className="text-sm text-concrete">{t('mcpSettings.serverStatus.mode')}</span>
|
||||
<span className="text-xs font-medium text-ink bg-paper dark:bg-white/10 px-3 py-1 rounded-lg border border-border">
|
||||
{serverStatus.mode.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
{serverStatus.mode === 'sse' && serverStatus.url && (
|
||||
<div className="space-y-2">
|
||||
<span className="text-[10px] font-bold text-concrete uppercase tracking-widest">{t('mcpSettings.serverStatus.url')}</span>
|
||||
<code className="text-[10px] bg-paper dark:bg-black/30 p-3 rounded-xl block break-all font-mono border border-border text-ink">
|
||||
<span className="text-sm text-concrete">{t('mcpSettings.serverStatus.url')}</span>
|
||||
<code className="text-xs bg-paper dark:bg-black/30 p-3 rounded-xl block break-all font-mono border border-border text-ink">
|
||||
{serverStatus.url}
|
||||
</code>
|
||||
</div>
|
||||
@@ -182,22 +182,22 @@ export function McpSettingsPanel({
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl overflow-hidden">
|
||||
<div className="flex items-center justify-between p-6 border-b border-border/40">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="p-3 bg-violet-500/10 rounded-2xl text-violet-500 border border-violet-500/20">
|
||||
<div className="p-3 bg-brand-accent/10 rounded-2xl text-brand-accent border border-brand-accent/20">
|
||||
<Key size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[13px] font-bold text-ink">{t('mcpSettings.apiKeys.title')}</h4>
|
||||
<p className="text-[10px] text-concrete mt-0.5">{t('mcpSettings.apiKeys.description')}</p>
|
||||
<h4 className="text-sm font-semibold text-ink">{t('mcpSettings.apiKeys.title')}</h4>
|
||||
<p className="text-sm text-concrete mt-0.5">{t('mcpSettings.apiKeys.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!mcpAllowed ? (
|
||||
<p className="text-[10px] text-concrete max-w-[200px] text-end leading-relaxed">
|
||||
<p className="text-sm text-concrete max-w-[200px] text-end leading-relaxed">
|
||||
{t('mcpSettings.tierRequired', { tier })}
|
||||
</p>
|
||||
) : (
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<button className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-ink text-paper text-[10px] font-bold uppercase tracking-[0.15em] hover:scale-[1.02] active:scale-95 transition-all duration-300 shadow-lg shadow-ink/20">
|
||||
<button className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-ink text-paper text-sm font-medium hover:opacity-90 active:scale-[0.98] transition-all">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t('mcpSettings.apiKeys.generate')}
|
||||
</button>
|
||||
@@ -209,7 +209,7 @@ export function McpSettingsPanel({
|
||||
|
||||
{!mcpAllowed && (
|
||||
<div className="px-6 pb-4">
|
||||
<p className="text-[11px] text-amber-800 dark:text-amber-200 bg-amber-500/10 border border-amber-500/20 rounded-xl px-4 py-3 leading-relaxed">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 bg-amber-500/10 border border-amber-500/20 rounded-xl px-4 py-3 leading-relaxed">
|
||||
{t('mcpSettings.upgradeHint')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -219,7 +219,7 @@ export function McpSettingsPanel({
|
||||
{keys.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Key className="h-8 w-8 mx-auto mb-2 text-concrete opacity-30" />
|
||||
<p className="text-[11px] text-concrete">{t('mcpSettings.apiKeys.empty')}</p>
|
||||
<p className="text-sm text-concrete">{t('mcpSettings.apiKeys.empty')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -243,7 +243,7 @@ export function McpSettingsPanel({
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-[10px] font-bold text-concrete uppercase tracking-widest">{rawKeyName}</Label>
|
||||
<Label className="text-sm text-concrete">{rawKeyName}</Label>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<code className="flex-1 text-[10px] bg-paper dark:bg-black/30 p-3 rounded-xl break-all font-mono border border-border text-ink">
|
||||
{showRawKey}
|
||||
@@ -260,7 +260,7 @@ export function McpSettingsPanel({
|
||||
<DialogFooter>
|
||||
<button
|
||||
onClick={() => setShowRawKey(null)}
|
||||
className="px-6 py-2.5 rounded-xl bg-ink text-paper text-[10px] font-bold uppercase tracking-[0.15em]"
|
||||
className="px-6 py-2.5 rounded-xl bg-ink text-paper text-sm font-medium"
|
||||
>
|
||||
{t('mcpSettings.createDialog.done')}
|
||||
</button>
|
||||
@@ -283,7 +283,7 @@ function CreateKeyDialog({ onGenerate, isPending }: { onGenerate: (name: string)
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="key-name" className="text-[10px] font-bold text-concrete uppercase tracking-widest">{t('mcpSettings.createDialog.nameLabel')}</Label>
|
||||
<Label htmlFor="key-name" className="text-sm text-concrete">{t('mcpSettings.createDialog.nameLabel')}</Label>
|
||||
<Input
|
||||
id="key-name"
|
||||
placeholder={t('mcpSettings.createDialog.namePlaceholder')}
|
||||
@@ -297,7 +297,7 @@ function CreateKeyDialog({ onGenerate, isPending }: { onGenerate: (name: string)
|
||||
<button
|
||||
onClick={() => onGenerate(name)}
|
||||
disabled={isPending}
|
||||
className="px-6 py-2.5 rounded-xl bg-ink text-paper text-[10px] font-bold uppercase tracking-[0.15em] disabled:opacity-60"
|
||||
className="px-6 py-2.5 rounded-xl bg-ink text-paper text-sm font-medium disabled:opacity-60"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Key className="h-4 w-4" />}
|
||||
@@ -321,9 +321,9 @@ function KeyCard({ keyInfo, onRevoke, onDelete, isPending }: { keyInfo: McpKeyIn
|
||||
<div className="flex items-center justify-between p-4 rounded-2xl border border-border/60 bg-paper/30 dark:bg-black/10">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-bold text-ink">{keyInfo.name}</span>
|
||||
<span className="text-sm font-semibold text-ink">{keyInfo.name}</span>
|
||||
<span className={cn(
|
||||
'text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded-lg',
|
||||
'text-xs font-medium px-2 py-0.5 rounded-lg',
|
||||
keyInfo.active
|
||||
? 'bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-300'
|
||||
: 'bg-concrete/10 text-concrete'
|
||||
@@ -331,7 +331,7 @@ function KeyCard({ keyInfo, onRevoke, onDelete, isPending }: { keyInfo: McpKeyIn
|
||||
{keyInfo.active ? t('mcpSettings.apiKeys.active') : t('mcpSettings.apiKeys.revoked')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-4 text-[10px] text-concrete">
|
||||
<div className="flex gap-4 text-sm text-concrete">
|
||||
<span>{t('mcpSettings.apiKeys.createdAt')}: {formatDate(keyInfo.createdAt)}</span>
|
||||
<span>{t('mcpSettings.apiKeys.lastUsed')}: {formatDate(keyInfo.lastUsedAt)}</span>
|
||||
</div>
|
||||
@@ -341,7 +341,7 @@ function KeyCard({ keyInfo, onRevoke, onDelete, isPending }: { keyInfo: McpKeyIn
|
||||
<button
|
||||
onClick={() => onRevoke(keyInfo.shortId)}
|
||||
disabled={isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-border text-[10px] font-bold uppercase tracking-widest text-concrete hover:text-ink hover:border-ink/30 transition-colors disabled:opacity-60"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl border border-border text-sm font-medium text-concrete hover:text-ink hover:border-ink/30 transition-colors disabled:opacity-60"
|
||||
>
|
||||
<Ban className="h-3 w-3" />
|
||||
{t('mcpSettings.apiKeys.revoke')}
|
||||
@@ -350,7 +350,7 @@ function KeyCard({ keyInfo, onRevoke, onDelete, isPending }: { keyInfo: McpKeyIn
|
||||
<button
|
||||
onClick={() => onDelete(keyInfo.shortId)}
|
||||
disabled={isPending}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-rose-500/10 text-rose-600 dark:text-rose-400 text-[10px] font-bold uppercase tracking-widest hover:bg-rose-500/20 transition-colors disabled:opacity-60"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-rose-500/10 text-rose-600 dark:text-rose-400 text-sm font-medium hover:bg-rose-500/20 transition-colors disabled:opacity-60"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t('mcpSettings.apiKeys.delete')}
|
||||
@@ -428,8 +428,8 @@ function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus })
|
||||
<ExternalLink size={18} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-[13px] font-bold text-ink">{t('mcpSettings.configInstructions.title')}</h4>
|
||||
<p className="text-[10px] text-concrete mt-0.5">{t('mcpSettings.configInstructions.description')}</p>
|
||||
<h4 className="text-sm font-semibold text-ink">{t('mcpSettings.configInstructions.title')}</h4>
|
||||
<p className="text-sm text-concrete mt-0.5">{t('mcpSettings.configInstructions.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 space-y-3">
|
||||
@@ -439,7 +439,7 @@ function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus })
|
||||
className="w-full flex items-center justify-between px-5 py-3.5 text-left hover:bg-paper/50 dark:hover:bg-white/5 transition-colors"
|
||||
onClick={() => setExpanded(expanded === cfg.id ? null : cfg.id)}
|
||||
>
|
||||
<span className="text-[11px] font-bold text-ink">{cfg.title}</span>
|
||||
<span className="text-sm font-medium text-ink">{cfg.title}</span>
|
||||
{expanded === cfg.id ? (
|
||||
<ChevronDown className="h-4 w-4 text-concrete" />
|
||||
) : (
|
||||
@@ -448,7 +448,7 @@ function ConfigInstructions({ serverStatus }: { serverStatus: McpServerStatus })
|
||||
</button>
|
||||
{expanded === cfg.id && (
|
||||
<div className="px-5 pb-5">
|
||||
<p className="text-[11px] text-concrete mb-3">{cfg.description}</p>
|
||||
<p className="text-sm text-concrete mb-3">{cfg.description}</p>
|
||||
<div className="relative">
|
||||
<pre className="text-[10px] bg-paper dark:bg-black/30 p-4 rounded-xl overflow-x-auto border border-border">
|
||||
<code>{cfg.snippet}</code>
|
||||
|
||||
@@ -310,7 +310,6 @@ export function NetworkGraph({
|
||||
.attr('stroke-width', d => d.isCentral ? 3 : d.isBridge ? 2.5 : 1.5)
|
||||
.style('filter', d => d.isBridge ? 'drop-shadow(0 0 6px rgba(212, 175, 55, 0.5))' : 'none')
|
||||
|
||||
// Labels de textes ultra-lisibles claire/sombre sans chevauchement
|
||||
node.append('text')
|
||||
.attr('dy', d => d.radius + 13)
|
||||
.attr('text-anchor', 'middle')
|
||||
|
||||
@@ -48,6 +48,8 @@ import { hi } from 'date-fns/locale/hi'
|
||||
import { nl } from 'date-fns/locale/nl'
|
||||
import { pl } from 'date-fns/locale/pl'
|
||||
import { LabelBadge } from './label-badge'
|
||||
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
|
||||
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
|
||||
import DOMPurify from 'isomorphic-dompurify'
|
||||
import { NoteImages } from './note-images'
|
||||
import { NoteChecklist } from './note-checklist'
|
||||
@@ -308,6 +310,7 @@ export const NoteCard = memo(function NoteCard({
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
await refreshLabels()
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
showNoteTrashedToast(note, t, () => setIsHidden(false))
|
||||
} catch (error) {
|
||||
console.error('Failed to delete note:', error)
|
||||
setIsHidden(false)
|
||||
@@ -869,23 +872,11 @@ export const NoteCard = memo(function NoteCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t('notes.confirmDeleteTitle') || t('notes.delete')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t('notes.confirmDelete')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleDelete}>
|
||||
{t('notes.delete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ConfirmDeleteNoteDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
|
||||
{/* Leave Share Confirmation Dialog */}
|
||||
<AlertDialog open={showLeaveDialog} onOpenChange={setShowLeaveDialog}>
|
||||
|
||||
@@ -26,6 +26,8 @@ import { FlashcardGenerateDialog } from '@/components/flashcards/flashcard-gener
|
||||
import { NoteShareDialog } from './note-share-dialog'
|
||||
import { InteractivePagePublishDialog } from '@/components/interactive-page/interactive-page-publish-dialog'
|
||||
import { deleteNote, leaveSharedNote } from '@/app/actions/notes'
|
||||
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
|
||||
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { NOTE_COLORS, NoteColor, Note } from '@/lib/types'
|
||||
@@ -65,6 +67,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
template: (note.publishedTemplate as PublishTemplateId | null) ?? null,
|
||||
})
|
||||
const [publishLinkCopied, setPublishLinkCopied] = useState(false)
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
|
||||
const [publishTemplate, setPublishTemplate] = useState<PublishTemplateId>('magazine')
|
||||
const [publishRewrite, setPublishRewrite] = useState(false)
|
||||
const [publishEnhanceRemaining, setPublishEnhanceRemaining] = useState<number | null>(null)
|
||||
@@ -887,12 +890,12 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
onClick={() => { setShowEduMenu(false); setFlashcardsOpen(true) }}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted transition-colors text-left"
|
||||
>
|
||||
<div className="p-1.5 rounded-lg bg-purple-50 dark:bg-purple-950/30 text-purple-600 dark:text-purple-400">
|
||||
<div className="p-1.5 rounded-lg bg-brand-accent/10 text-brand-accent">
|
||||
<GraduationCap size={16} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('flashcards.toolbarGenerate')}</div>
|
||||
<div className="text-[10px] text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Révision espacée SM-2'}</div>
|
||||
<div className="text-sm text-muted-foreground">{t('flashcards.toolbarGenerateHint') || 'Elles reviennent au bon moment'}</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
@@ -1019,14 +1022,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
toast.success(t('notes.noteDeletedToast'))
|
||||
onClose()
|
||||
} catch { toast.error(t('notes.deleteNoteFailedToast')) }
|
||||
}}
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
className="text-red-600 dark:text-red-400 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 me-2" />
|
||||
@@ -1036,6 +1032,19 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteNoteDialog
|
||||
open={confirmDeleteOpen}
|
||||
onOpenChange={setConfirmDeleteOpen}
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
showNoteTrashedToast(note, t)
|
||||
onClose()
|
||||
} catch { toast.error(t('notes.deleteNoteFailedToast')) }
|
||||
}}
|
||||
/>
|
||||
|
||||
{shareOpen && (
|
||||
<NoteShareDialog
|
||||
noteId={note.id}
|
||||
|
||||
@@ -23,6 +23,8 @@ import { deleteNote, toggleArchive, togglePin, updateNote } from '@/app/actions/
|
||||
import { ReminderDialog } from '@/components/reminder-dialog'
|
||||
import { useNotebooks } from '@/context/notebooks-context'
|
||||
import { toast } from 'sonner'
|
||||
import { ConfirmDeleteNoteDialog } from '@/components/confirm-delete-note-dialog'
|
||||
import { showNoteTrashedToast } from '@/lib/notes/trash-toast'
|
||||
import { fr } from 'date-fns/locale/fr'
|
||||
import { enUS } from 'date-fns/locale/en-US'
|
||||
import { formatAbsoluteDateLocalized } from '@/lib/utils/format-localized-date'
|
||||
@@ -68,6 +70,7 @@ export function EditorialNoteMenu({
|
||||
const [, startTransition] = useTransition()
|
||||
const [showReminder, setShowReminder] = useState(false)
|
||||
const [movePickerOpen, setMovePickerOpen] = useState(false)
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false)
|
||||
const menuTriggerRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
@@ -76,11 +79,15 @@ export function EditorialNoteMenu({
|
||||
onDeleteNote(note)
|
||||
return
|
||||
}
|
||||
setConfirmDeleteOpen(true)
|
||||
}
|
||||
|
||||
const confirmFallbackDelete = () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await deleteNote(note.id, { skipRevalidation: true })
|
||||
emitNoteChange({ type: 'deleted', noteId: note.id, notebookId: note.notebookId })
|
||||
toast.success(t('notes.deleted') || 'Note supprimée')
|
||||
showNoteTrashedToast(note, t)
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
@@ -229,6 +236,12 @@ export function EditorialNoteMenu({
|
||||
onSave={(date) => patchReminder(date)}
|
||||
onRemove={() => patchReminder(null)}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteNoteDialog
|
||||
open={confirmDeleteOpen}
|
||||
onOpenChange={setConfirmDeleteOpen}
|
||||
onConfirm={confirmFallbackDelete}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2078,10 +2078,10 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts, onGenerateIn
|
||||
{ ...sc('Subscript'), title: t('richTextEditor.slashSubscript'), description: t('richTextEditor.slashSubscriptDesc'), categoryId: 'text' },
|
||||
{ ...sc('Diagramme'), title: t('richTextEditor.slashDiagram'), description: t('richTextEditor.slashDiagramDesc'), categoryId: 'ai' },
|
||||
{ ...sc('Présentation'), title: t('richTextEditor.slashSlides'), description: t('richTextEditor.slashSlidesDesc'), categoryId: 'ai' },
|
||||
{ ...sc('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Graphiques IA', description: t('richTextEditor.slashChartsDesc') || 'IA suggère des graphiques', categoryId: 'ai' },
|
||||
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc vivant', description: t('richTextEditor.slashLivingBlockDesc') || 'Insérer depuis une autre note', categoryId: 'embed' },
|
||||
{ ...sc('Suggest Charts'), title: t('richTextEditor.slashCharts') || 'Proposer des graphiques', description: t('richTextEditor.slashChartsDesc') || 'Suggérer des graphiques d’après la note', categoryId: 'ai' },
|
||||
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc lié', description: t('richTextEditor.slashLivingBlockDesc') || 'Ouvrir un passage d’une autre note à côté', categoryId: 'embed' },
|
||||
{ ...sc('Database'), title: t('richTextEditor.slashDatabase'), description: t('richTextEditor.slashDatabaseDesc'), categoryId: 'data', slashKeywords: ['database', 'db', 'base', 'données', 'donnees', 'vue', 'structured', 'structuree', 'structurée'] },
|
||||
{ ...sc('Interactive Demo'), title: t('richTextEditor.slashInteractiveDemo') || 'Démo interactive', description: t('richTextEditor.slashInteractiveDemoDesc') || 'Démo pédagogique étape par étape', categoryId: 'ai', slashKeywords: ['demo', 'interactive', 'attn', 'tutorial', 'démo', 'demo interactive'] },
|
||||
{ ...sc('Interactive Demo'), title: t('richTextEditor.slashInteractiveDemo') || 'Démo pas à pas', description: t('richTextEditor.slashInteractiveDemoDesc') || 'Parcours pédagogique dans la note', categoryId: 'ai', slashKeywords: ['demo', 'interactive', 'attn', 'tutorial', 'démo', 'demo interactive'] },
|
||||
{ ...sc('Toggle'), title: t('richTextEditor.slashToggle'), description: t('richTextEditor.slashToggleDesc'), categoryId: 'text', slashKeywords: ['toggle', 'accordion', 'replier', 'deroulant', 'déroulant', 'section'] },
|
||||
{ ...sc('Callout'), title: t('richTextEditor.slashCallout'), description: t('richTextEditor.slashCalloutDesc'), categoryId: 'text', slashKeywords: ['callout', 'encadre', 'encadré', 'info', 'alerte', 'astuce', 'tip', 'warning'] },
|
||||
{ ...sc('Outline'), title: t('richTextEditor.slashOutline'), description: t('richTextEditor.slashOutlineDesc'), categoryId: 'text', slashKeywords: ['outline', 'sommaire', 'toc', 'matieres', 'matières', 'plan'] },
|
||||
@@ -2194,12 +2194,12 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts, onGenerateIn
|
||||
finally { setAiLoading(false) }
|
||||
} else if (
|
||||
item.title === 'Suggest Charts'
|
||||
|| item.title === (t('richTextEditor.slashCharts') || 'Graphiques IA')
|
||||
|| item.title === (t('richTextEditor.slashCharts') || 'Proposer des graphiques')
|
||||
) {
|
||||
deleteSlashText(); closeMenu(); onSuggestCharts()
|
||||
} else if (
|
||||
item.title === 'Interactive Demo'
|
||||
|| item.title === (t('richTextEditor.slashInteractiveDemo') || 'Démo interactive')
|
||||
|| item.title === (t('richTextEditor.slashInteractiveDemo') || 'Démo pas à pas')
|
||||
) {
|
||||
deleteSlashText(); closeMenu(); onGenerateInteractiveDemo()
|
||||
} else if (item.title === t('richTextEditor.slashDatabase')) {
|
||||
@@ -2397,7 +2397,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts, onGenerateIn
|
||||
|
||||
const selectedItem = filtered[selectedIndex]
|
||||
const showPreview = selectedItem && [
|
||||
'Table', 'Tableau', 'Database', 'Suggest Charts', 'Suggest Chart', 'Living Block', 'Bloc vivant', 'Diagramme', 'Diagram', 'Présentation', 'Presentation', 'Code Block', 'Code', 'Bloc de code'
|
||||
'Table', 'Tableau', 'Database', 'Suggest Charts', 'Suggest Chart', 'Living Block', 'Bloc vivant', 'Bloc lié', 'Linked block', 'Diagramme', 'Diagram', 'Présentation', 'Presentation', 'Code Block', 'Code', 'Bloc de code'
|
||||
].includes(selectedItem.title)
|
||||
|
||||
return createPortal(
|
||||
|
||||
@@ -78,7 +78,9 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
// Load saved queries from localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('momento-search-saved')
|
||||
const stored =
|
||||
localStorage.getItem('memento-search-saved')
|
||||
?? localStorage.getItem('momento-search-saved')
|
||||
if (stored) setSavedQueries(JSON.parse(stored))
|
||||
} catch {}
|
||||
}, [])
|
||||
@@ -405,14 +407,20 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
if (!query.trim()) return
|
||||
setSavedQueries(prev => {
|
||||
const next = prev.includes(query.trim()) ? prev : [...prev.slice(-9), query.trim()]
|
||||
try { localStorage.setItem('momento-search-saved', JSON.stringify(next)) } catch {}
|
||||
try {
|
||||
localStorage.setItem('memento-search-saved', JSON.stringify(next))
|
||||
localStorage.removeItem('momento-search-saved')
|
||||
} catch {}
|
||||
return next
|
||||
})
|
||||
}
|
||||
const handleRemoveQuery = () => {
|
||||
setSavedQueries(prev => {
|
||||
const next = prev.filter(q => q !== query.trim())
|
||||
try { localStorage.setItem('momento-search-saved', JSON.stringify(next)) } catch {}
|
||||
try {
|
||||
localStorage.setItem('memento-search-saved', JSON.stringify(next))
|
||||
localStorage.removeItem('momento-search-saved')
|
||||
} catch {}
|
||||
return next
|
||||
})
|
||||
}
|
||||
@@ -671,7 +679,7 @@ export function SearchModal({ isOpen, onClose }: SearchModalProps) {
|
||||
router.push(`/home?openNote=${activeMatch.noteId}`)
|
||||
onClose()
|
||||
}}
|
||||
className="px-5 py-2.5 bg-ink text-white dark:bg-white dark:text-black hover:opacity-90 text-xs font-semibold rounded-xl flex items-center gap-2 transition-all shadow-sm"
|
||||
className="px-5 py-2.5 bg-brand-accent text-white hover:bg-brand-accent/90 text-xs font-semibold rounded-xl flex items-center gap-2 transition-all shadow-sm"
|
||||
>
|
||||
<CornerDownRight size={13} />
|
||||
<span>{t('searchModal.openInEditor')}</span>
|
||||
|
||||
@@ -35,7 +35,7 @@ export function SettingsNav({ className }: SettingsNavProps) {
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className="flex items-center gap-1.5 sm:gap-2.5 px-2 sm:px-4 py-3 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap text-concrete hover:text-ink/60"
|
||||
className="flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-3 py-2.5 text-[13px] font-medium transition-all relative whitespace-nowrap text-concrete hover:text-ink"
|
||||
style={{ color: isActive(tab.href) ? 'var(--ink)' : undefined }}
|
||||
>
|
||||
<span style={{ color: isActive(tab.href) ? 'var(--ink)' : 'var(--concrete)' }}>{tab.icon}</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
|
||||
@@ -59,7 +59,7 @@ function getStripePromise(enabled: boolean) {
|
||||
}
|
||||
|
||||
export function BillingPlans() {
|
||||
const { t } = useLanguage();
|
||||
const { t, language } = useLanguage();
|
||||
const queryClient = useQueryClient();
|
||||
const [interval, setInterval] = useState<Interval>('month');
|
||||
const [checkoutClientSecret, setCheckoutClientSecret] = useState<string | null>(null);
|
||||
@@ -69,6 +69,7 @@ export function BillingPlans() {
|
||||
const [portalLoading, setPortalLoading] = useState(false);
|
||||
const [cancelLoading, setCancelLoading] = useState(false);
|
||||
const [successBanner, setSuccessBanner] = useState<string | null>(null);
|
||||
const plansSectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: status, isLoading } = useQuery<BillingStatus>({
|
||||
queryKey: ['billing', 'status'],
|
||||
@@ -80,6 +81,17 @@ export function BillingPlans() {
|
||||
},
|
||||
});
|
||||
|
||||
const { data: byokCatalog } = useQuery({
|
||||
queryKey: ['public', 'byok-catalog'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/public/byok-catalog')
|
||||
if (!res.ok) throw new Error('catalog')
|
||||
return res.json() as Promise<{ providers: { id: string }[] }>
|
||||
},
|
||||
staleTime: 60_000,
|
||||
})
|
||||
const providerCount = byokCatalog?.providers.length || '…'
|
||||
|
||||
const { data: usageData } = useQuery({
|
||||
queryKey: ['usage', 'current'],
|
||||
queryFn: async () => {
|
||||
@@ -235,6 +247,18 @@ export function BillingPlans() {
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToPlans = () => {
|
||||
plansSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
const handleChangePlan = (tier: Tier) => {
|
||||
if (status?.hasStripeSubscription) {
|
||||
void handlePortal('portal');
|
||||
return;
|
||||
}
|
||||
void handleCheckout(tier);
|
||||
};
|
||||
|
||||
const handleCheckoutComplete = useCallback(() => {
|
||||
setIsCheckoutOpen(false);
|
||||
setCheckoutClientSecret(null);
|
||||
@@ -276,11 +300,15 @@ export function BillingPlans() {
|
||||
t('billing.freeF5'),
|
||||
],
|
||||
current: effectiveTier === 'BASIC',
|
||||
buttonText: effectiveTier === 'BASIC' ? (t('billing.currentPlan') || 'Plan Actuel') : t('billing.startCheckout'),
|
||||
buttonText: effectiveTier === 'BASIC'
|
||||
? (t('billing.currentPlan') || 'Plan Actuel')
|
||||
: t('billing.downgradeToFree'),
|
||||
buttonClass: effectiveTier === 'BASIC'
|
||||
? 'bg-paper text-concrete cursor-default'
|
||||
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||
onClick: () => {},
|
||||
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
|
||||
onClick: () => {
|
||||
if (effectiveTier !== 'BASIC') void handleCancelSubscription();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
@@ -293,7 +321,7 @@ export function BillingPlans() {
|
||||
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
|
||||
t('billing.proFeature1'),
|
||||
t('billing.proFeature2'),
|
||||
t('billing.proFeature3'),
|
||||
t('billing.proFeature3', { count: providerCount }),
|
||||
t('billing.proFeature4'),
|
||||
t('billing.proFeature5'),
|
||||
t('billing.proFeature6'),
|
||||
@@ -304,7 +332,7 @@ export function BillingPlans() {
|
||||
buttonClass: effectiveTier === 'PRO'
|
||||
? 'bg-paper text-concrete cursor-default'
|
||||
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
|
||||
onClick: () => handleCheckout('PRO'),
|
||||
onClick: () => handleChangePlan('PRO'),
|
||||
},
|
||||
{
|
||||
id: 'business',
|
||||
@@ -316,7 +344,7 @@ export function BillingPlans() {
|
||||
...(trialEligible ? [t('billing.trialFeature', { days: trialDays })] : []),
|
||||
t('billing.businessFeature1'),
|
||||
t('billing.businessFeature2'),
|
||||
t('billing.businessFeature3'),
|
||||
t('billing.businessFeature3', { count: providerCount }),
|
||||
t('billing.businessFeature4'),
|
||||
t('billing.businessFeature5'),
|
||||
t('billing.businessFeature6'),
|
||||
@@ -325,8 +353,8 @@ export function BillingPlans() {
|
||||
buttonText: effectiveTier === 'BUSINESS' ? (t('billing.currentPlan') || 'Plan Actuel') : trialCta(t('billing.businessCta') || 'Choisir Plan Business'),
|
||||
buttonClass: effectiveTier === 'BUSINESS'
|
||||
? 'bg-paper text-concrete cursor-default'
|
||||
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||
onClick: () => handleCheckout('BUSINESS'),
|
||||
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
|
||||
onClick: () => handleChangePlan('BUSINESS'),
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
@@ -345,22 +373,24 @@ export function BillingPlans() {
|
||||
buttonText: effectiveTier === 'ENTERPRISE' ? (t('billing.currentPlan') || 'Plan Actuel') : (t('billing.contactSales') || 'Contact Sales'),
|
||||
buttonClass: effectiveTier === 'ENTERPRISE'
|
||||
? 'bg-paper text-concrete cursor-default'
|
||||
: 'bg-ink text-white shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||
: 'bg-brand-accent text-white shadow-xl shadow-brand-accent/20 hover:scale-[1.02] active:scale-95',
|
||||
onClick: () => { window.location.href = 'mailto:sales@memento-note.com'; },
|
||||
},
|
||||
];
|
||||
|
||||
const plansToShow = isPaid ? plans.filter((p) => p.id !== 'free') : plans;
|
||||
const plansToShow = plans;
|
||||
|
||||
const formatDate = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return '—';
|
||||
try {
|
||||
const date = new Date(dateStr);
|
||||
const locale = typeof window !== 'undefined' ? window.navigator.language : 'fr-FR';
|
||||
const locale = language === 'fa' ? 'fa-IR' : language === 'zh' ? 'zh-CN' : language;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
...(language === 'fa' ? { calendar: 'persian' as const } : {}),
|
||||
}).format(date);
|
||||
} catch (e) {
|
||||
return dateStr;
|
||||
@@ -476,30 +506,44 @@ export function BillingPlans() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPaid && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToPlans}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-brand-accent text-white rounded-xl text-xs font-semibold hover:opacity-90 transition-all shadow-md shadow-brand-accent/20"
|
||||
>
|
||||
{t('billing.changeOffer')}
|
||||
</button>
|
||||
|
||||
{isPaid && !status?.cancelAtPeriodEnd && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelSubscription}
|
||||
disabled={cancelLoading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 border border-rose-200 text-rose-600 dark:border-rose-800/40 dark:text-rose-400 hover:bg-rose-50/50 dark:hover:bg-rose-950/15 rounded-xl text-xs font-semibold transition-all"
|
||||
>
|
||||
{cancelLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('billing.cancelSubscription')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isPaid && status?.hasStripeSubscription && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePortal}
|
||||
disabled={portalLoading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-ink text-white dark:bg-white dark:text-black rounded-xl text-xs font-semibold hover:opacity-90 disabled:opacity-60 transition-all shadow-md shadow-black/5"
|
||||
className="flex items-center gap-2 px-5 py-2.5 border border-border text-ink rounded-xl text-xs font-semibold hover:bg-paper/60 dark:hover:bg-white/5 disabled:opacity-60 transition-all"
|
||||
>
|
||||
{portalLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ExternalLink className="h-4 w-4" />}
|
||||
{t('billing.manageBilling') || 'Gérer la facturation'}
|
||||
{t('billing.manageBilling')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status?.hasStripeSubscription && !status?.cancelAtPeriodEnd && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelSubscription}
|
||||
disabled={cancelLoading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 border border-rose-200 text-rose-600 dark:border-rose-800/40 dark:text-rose-400 hover:bg-rose-50/50 dark:hover:bg-rose-950/15 rounded-xl text-xs font-semibold transition-all"
|
||||
>
|
||||
{cancelLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{t('billing.cancelSubscription') || "Résilier l'abonnement"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{isPaid && status?.cancelAtPeriodEnd && (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-300 bg-amber-500/10 border border-amber-500/20 rounded-xl px-3 py-2">
|
||||
{t('billing.cancellingNotice', { date: formatDate(status.currentPeriodEnd) })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -600,7 +644,7 @@ export function BillingPlans() {
|
||||
type="button"
|
||||
onClick={() => handleBuyPack(pack.id)}
|
||||
disabled={!pack.configured || packLoading !== null}
|
||||
className="mt-auto w-full py-3 rounded-2xl bg-ink text-white dark:bg-white dark:text-black text-[10px] font-bold uppercase tracking-[0.15em] hover:opacity-90 disabled:opacity-40 transition-all"
|
||||
className="mt-auto w-full py-3 rounded-2xl bg-brand-accent text-white text-[13px] font-semibold uppercase tracking-wider hover:opacity-90 disabled:opacity-40 transition-all"
|
||||
>
|
||||
{packLoading === pack.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mx-auto" />
|
||||
@@ -661,8 +705,8 @@ export function BillingPlans() {
|
||||
const pct = totalUsed > 0 && used > 0 ? (used / totalUsed) * 100 : 0
|
||||
const barFillColor =
|
||||
pct >= 40
|
||||
? 'bg-gradient-to-r from-violet-400 to-purple-400'
|
||||
: 'bg-gradient-to-r from-violet-300/80 to-purple-300/80'
|
||||
? 'bg-brand-accent'
|
||||
: 'bg-brand-accent/60'
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
@@ -693,8 +737,7 @@ export function BillingPlans() {
|
||||
{isPaid && <BillingHistory />}
|
||||
|
||||
{/* Interval Toggle & Plan Cards */}
|
||||
{!isPaid && (
|
||||
<div className="space-y-8 pt-6 border-t border-border/40">
|
||||
<div ref={plansSectionRef} className="space-y-8 pt-6 border-t border-border/40">
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-concrete">
|
||||
{t('billing.upgradePlan') || 'Changer de plan'}
|
||||
@@ -708,7 +751,7 @@ export function BillingPlans() {
|
||||
onClick={() => setInterval('month')}
|
||||
className={cn(
|
||||
'px-4 py-1.5 text-xs font-medium rounded-full transition-all',
|
||||
interval === 'month' ? 'bg-ink text-paper' : 'text-concrete hover:text-ink'
|
||||
interval === 'month' ? 'bg-brand-accent text-white' : 'text-concrete hover:text-ink'
|
||||
)}
|
||||
>
|
||||
{t('billing.monthly')}
|
||||
@@ -718,7 +761,7 @@ export function BillingPlans() {
|
||||
onClick={() => setInterval('year')}
|
||||
className={cn(
|
||||
'px-4 py-1.5 text-xs font-medium rounded-full transition-all',
|
||||
interval === 'year' ? 'bg-ink text-paper' : 'text-concrete hover:text-ink'
|
||||
interval === 'year' ? 'bg-brand-accent text-white' : 'text-concrete hover:text-ink'
|
||||
)}
|
||||
>
|
||||
{t('billing.annual')}
|
||||
@@ -773,7 +816,12 @@ export function BillingPlans() {
|
||||
|
||||
<button
|
||||
onClick={plan.onClick}
|
||||
disabled={plan.current || checkoutLoading !== null}
|
||||
disabled={
|
||||
plan.current
|
||||
|| checkoutLoading !== null
|
||||
|| cancelLoading
|
||||
|| (plan.id === 'free' && !!status?.cancelAtPeriodEnd)
|
||||
}
|
||||
className={cn('w-full py-4 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300', plan.buttonClass)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
@@ -784,8 +832,7 @@ export function BillingPlans() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
<div className="bg-slate-50 dark:bg-black/20 rounded-[32px] p-8 border border-border/40 flex flex-col md:flex-row items-center justify-between gap-8">
|
||||
|
||||
31
memento-note/components/settings/settings-document-title.tsx
Normal file
31
memento-note/components/settings/settings-document-title.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
const TITLE_KEYS: Record<string, string> = {
|
||||
'/settings/general': 'generalSettings.title',
|
||||
'/settings/ai': 'aiSettings.title',
|
||||
'/settings/billing': 'billing.title',
|
||||
'/settings/appearance': 'appearance.title',
|
||||
'/settings/profile': 'profile.title',
|
||||
'/settings/data': 'dataManagement.title',
|
||||
'/settings/published': 'settings.publishedTitle',
|
||||
'/settings/integrations': 'integrations.title',
|
||||
'/settings/mcp': 'mcpSettings.title',
|
||||
'/settings/about': 'about.title',
|
||||
}
|
||||
|
||||
export function SettingsDocumentTitle() {
|
||||
const pathname = usePathname()
|
||||
const { t } = useLanguage()
|
||||
|
||||
useEffect(() => {
|
||||
const key = Object.keys(TITLE_KEYS).find((href) => pathname === href || pathname.startsWith(`${href}/`))
|
||||
const section = key ? t(TITLE_KEYS[key]) : t('settings.title')
|
||||
document.title = `${section} — Memento`
|
||||
}, [pathname, t])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export function SettingsHelpBox({ title, steps, defaultOpen = false, className }
|
||||
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-border/10 transition-colors"
|
||||
>
|
||||
<HelpCircle size={14} className="text-brand-accent shrink-0" />
|
||||
<span className="text-[12px] font-semibold text-ink flex-1">{title}</span>
|
||||
<span className="text-sm font-semibold text-ink flex-1">{title}</span>
|
||||
{open ? (
|
||||
<ChevronUp size={13} className="text-concrete shrink-0" />
|
||||
) : (
|
||||
@@ -40,10 +40,10 @@ export function SettingsHelpBox({ title, steps, defaultOpen = false, className }
|
||||
<ol className="px-4 pb-4 space-y-2.5 border-t border-border/30 pt-3">
|
||||
{steps.map((step, i) => (
|
||||
<li key={i} className="flex items-start gap-2.5">
|
||||
<span className="w-5 h-5 rounded-full bg-brand-accent/10 text-brand-accent text-[10px] font-bold flex items-center justify-center shrink-0 mt-0.5">
|
||||
<span className="w-5 h-5 rounded-full bg-brand-accent/10 text-brand-accent text-xs font-semibold flex items-center justify-center shrink-0 mt-0.5">
|
||||
{step.icon ?? i + 1}
|
||||
</span>
|
||||
<span className="text-[12px] text-concrete leading-relaxed">
|
||||
<span className="text-sm text-concrete leading-relaxed">
|
||||
{step.text}
|
||||
{step.link && (
|
||||
<>
|
||||
|
||||
@@ -36,6 +36,13 @@ import {
|
||||
Folder,
|
||||
FolderOpen,
|
||||
LayoutGrid,
|
||||
Palette,
|
||||
CreditCard,
|
||||
Database,
|
||||
Globe,
|
||||
Plug,
|
||||
Key,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
import { useSearchModal } from '@/context/search-modal-context'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
@@ -67,7 +74,7 @@ import { performSignOut } from '@/lib/auth-client'
|
||||
import { isDashboardHomeRoute } from '@/lib/dashboard/home-route'
|
||||
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
||||
|
||||
type NavigationView = 'dashboard' | 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights'
|
||||
type NavigationView = 'dashboard' | 'notebooks' | 'agents' | 'reminders' | 'brainstorms' | 'revision' | 'insights' | 'settings'
|
||||
type SortOrder = 'newest' | 'oldest' | 'alpha' | 'manual'
|
||||
|
||||
const NOTEBOOKS_PANEL_HEIGHT_KEY = 'memento-sidebar-notebooks-height'
|
||||
@@ -859,6 +866,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
}, [currentNotebookId, notebooks])
|
||||
|
||||
const isDashboardRoute = isDashboardHomeRoute(pathname, searchParams)
|
||||
const panelView: NavigationView = pathname.startsWith('/settings') ? 'settings' : activeView
|
||||
|
||||
const isInboxActive =
|
||||
pathname === '/home' &&
|
||||
@@ -873,6 +881,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
else if (pathname.startsWith('/agents') || pathname.startsWith('/lab')) setActiveView('agents')
|
||||
else if (pathname === '/insights') setActiveView('insights')
|
||||
else if (pathname.startsWith('/revision')) setActiveView('revision')
|
||||
else if (pathname.startsWith('/settings')) setActiveView('settings')
|
||||
else if (searchParams.get('reminders') === '1' && pathname === '/home') setActiveView('reminders')
|
||||
else if (isDashboardRoute) setActiveView('dashboard')
|
||||
else if (pathname === '/home' || pathname.startsWith('/notes')) setActiveView('notebooks')
|
||||
@@ -1593,6 +1602,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<Link
|
||||
href="/settings"
|
||||
aria-label={t('nav.settings')}
|
||||
onClick={() => setActiveView('settings')}
|
||||
className={cn(
|
||||
'w-9 h-9 rounded-lg flex items-center justify-center transition-all relative group',
|
||||
pathname.startsWith('/settings')
|
||||
@@ -1627,14 +1637,14 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 flex flex-col min-h-0 -mx-0 pb-4',
|
||||
activeView === 'notebooks'
|
||||
panelView === 'notebooks'
|
||||
? 'overflow-hidden'
|
||||
: 'overflow-y-auto custom-scrollbar space-y-6',
|
||||
)}
|
||||
>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{activeView === 'dashboard' ? (
|
||||
{panelView === 'dashboard' ? (
|
||||
<motion.div
|
||||
key="dashboard"
|
||||
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
||||
@@ -1653,45 +1663,6 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
{t('sidebar.dashboardPanelBody')}
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInboxClick}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||
>
|
||||
<span className="flex items-center gap-2 min-w-0">
|
||||
<Inbox size={14} className="text-brand-accent shrink-0" />
|
||||
{t('homeDashboard.inbox')}
|
||||
</span>
|
||||
{inboxCount > 0 && (
|
||||
<span className="text-[10px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded shrink-0">
|
||||
{inboxCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/revision')}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||
>
|
||||
<GraduationCap size={14} className="text-brand-accent shrink-0" />
|
||||
{t('homeDashboard.review')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemindersClick}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||
>
|
||||
<Bell size={14} className="text-brand-accent shrink-0" />
|
||||
{t('homeDashboard.reminders')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/insights')}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||
>
|
||||
<Sparkles size={14} className="text-brand-accent shrink-0" />
|
||||
{t('homeDashboard.themes')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -1705,7 +1676,54 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : activeView === 'notebooks' ? (
|
||||
) : panelView === 'settings' ? (
|
||||
<motion.div
|
||||
key="settings"
|
||||
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="px-4 pt-4"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-6">
|
||||
<Settings size={14} className="text-brand-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
||||
{t('settings.title')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ id: 'general', href: '/settings/general', label: t('generalSettings.title'), icon: Settings },
|
||||
{ id: 'ai', href: '/settings/ai', label: t('aiSettings.title'), icon: Sparkles },
|
||||
{ id: 'billing', href: '/settings/billing', label: t('billing.title'), icon: CreditCard },
|
||||
{ id: 'appearance', href: '/settings/appearance', label: t('appearance.title'), icon: Palette },
|
||||
{ id: 'profile', href: '/settings/profile', label: t('profile.title'), icon: User },
|
||||
{ id: 'data', href: '/settings/data', label: t('dataManagement.title'), icon: Database },
|
||||
{ id: 'published', href: '/settings/published', label: t('settings.publishedTitle') || 'Mes pages', icon: Globe },
|
||||
{ id: 'integrations', href: '/settings/integrations', label: t('integrations.title') || 'Intégrations', icon: Plug },
|
||||
{ id: 'mcp', href: '/settings/mcp', label: t('mcpSettings.title'), icon: Key },
|
||||
{ id: 'about', href: '/settings/about', label: t('about.title'), icon: Info },
|
||||
].map((tab) => {
|
||||
const isActive = pathname === tab.href || pathname.startsWith(`${tab.href}/`)
|
||||
return (
|
||||
<Link
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
'w-full text-start px-3 py-2 text-[11px] transition-all rounded-lg flex items-center gap-2.5',
|
||||
isActive
|
||||
? 'text-ink bg-brand-accent/10'
|
||||
: 'text-muted-foreground hover:text-ink hover:bg-black/5 dark:hover:bg-white/5',
|
||||
)}
|
||||
>
|
||||
<tab.icon size={12} className="text-concrete shrink-0" />
|
||||
<span className="font-semibold">{tab.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : panelView === 'notebooks' ? (
|
||||
<motion.div
|
||||
key="notebooks"
|
||||
ref={notebooksContainerRef}
|
||||
@@ -1721,7 +1739,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<div className="flex items-center gap-1.5">
|
||||
<BookMarked size={14} className="text-brand-accent" />
|
||||
<h3 className="text-xs font-black tracking-widest uppercase text-ink dark:text-dark-ink">
|
||||
{t('sidebar.documents')}
|
||||
{t('nav.notebooks')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
@@ -1893,7 +1911,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : activeView === 'insights' ? (
|
||||
) : panelView === 'insights' ? (
|
||||
<motion.div
|
||||
key="insights"
|
||||
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
||||
@@ -1915,12 +1933,20 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
type="button"
|
||||
onClick={() => router.push('/home')}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||
>
|
||||
<Home size={14} className="text-brand-accent shrink-0" />
|
||||
{t('sidebar.backToHome')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/home?forceList=1')}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||
>
|
||||
<BookOpen size={14} className="text-brand-accent shrink-0" />
|
||||
{t('sidebar.backToNotebooks')}
|
||||
</button>
|
||||
</motion.div>
|
||||
) : activeView === 'revision' ? (
|
||||
) : panelView === 'revision' ? (
|
||||
<motion.div
|
||||
key="revision"
|
||||
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
||||
@@ -1942,12 +1968,20 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
type="button"
|
||||
onClick={() => router.push('/home')}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||
>
|
||||
<Home size={14} className="text-brand-accent shrink-0" />
|
||||
{t('sidebar.backToHome')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/home?forceList=1')}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-xl border border-border/40 bg-white/60 dark:bg-zinc-800/40 hover:border-brand-accent/30 hover:bg-brand-accent/5 transition-all text-[12px] font-medium text-foreground"
|
||||
>
|
||||
<BookOpen size={14} className="text-brand-accent shrink-0" />
|
||||
{t('sidebar.backToNotebooks')}
|
||||
</button>
|
||||
</motion.div>
|
||||
) : activeView === 'reminders' ? (
|
||||
) : panelView === 'reminders' ? (
|
||||
<motion.div
|
||||
key="reminders"
|
||||
initial={{ opacity: 0, x: isRtl ? 10 : -10 }}
|
||||
@@ -1966,7 +2000,7 @@ export function Sidebar({ className, user }: { className?: string; user?: any })
|
||||
<SidebarReminders onOpenNote={handleReminderNoteClick} />
|
||||
</div>
|
||||
</motion.div>
|
||||
) : activeView === 'agents' ? (
|
||||
) : panelView === 'agents' ? (
|
||||
<motion.div
|
||||
key="agents"
|
||||
initial={{ opacity: 0, x: isRtl ? -10 : 10 }}
|
||||
|
||||
@@ -68,10 +68,11 @@ export function ThemeInitializer({ theme, fontSize, fontFamily, accentColor }: T
|
||||
}
|
||||
|
||||
const localAccent = localStorage.getItem('accent-color')
|
||||
const effectiveAccent = localAccent || accentColor || '#A47148'
|
||||
const serverAccent = accentColor || null
|
||||
const effectiveAccent = serverAccent || localAccent || '#A47148'
|
||||
root.style.setProperty('--color-brand-accent', effectiveAccent)
|
||||
if (!localAccent && accentColor) {
|
||||
localStorage.setItem('accent-color', accentColor)
|
||||
if (serverAccent && localAccent !== serverAccent) {
|
||||
localStorage.setItem('accent-color', serverAccent)
|
||||
}
|
||||
}, [theme, fontSize, fontFamily, accentColor])
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-medium tabular-nums shrink-0',
|
||||
'text-[10px] font-medium tabular-nums shrink-0 max-w-[42%] truncate',
|
||||
totalPct >= 90
|
||||
? 'text-rose-500'
|
||||
: totalPct >= 70
|
||||
@@ -174,7 +174,7 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
)}
|
||||
title={t('usageMeter.creditsRemaining')}
|
||||
>
|
||||
{remaining}
|
||||
{t('usageMeter.remaining', { count: remaining })}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
@@ -185,12 +185,6 @@ export function UsageMeter({ className }: UsageMeterProps) {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isProPlus && !unlimited && (
|
||||
<span className="text-[9px] font-bold text-brand-accent uppercase tracking-widest ml-auto">
|
||||
{data.tier}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<ChevronDown
|
||||
size={12}
|
||||
className={cn(
|
||||
|
||||
Reference in New Issue
Block a user