fix(ui): thème, textes et lisibilité restants de la revue
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 6m57s
CI / Deploy production (on server) (push) Successful in 24s

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:
Antigravity
2026-08-30 21:13:07 +00:00
parent ebf6f16fde
commit afbb0dfc2d
77 changed files with 2842 additions and 1546 deletions

View File

@@ -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>
)}

View File

@@ -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>
)
}

View File

@@ -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" />}

View File

@@ -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}
/>
</>
)
}

View 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>
)
}

View File

@@ -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>

View File

@@ -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')}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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 ? (

View File

@@ -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>
)

View File

@@ -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)

View File

@@ -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
}}
/>

View File

@@ -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">

View File

@@ -10,6 +10,7 @@ interface DashboardWidgetTitleRowProps {
title: string
actions?: ReactNode
className?: string
wrapTitle?: boolean
}
/** Titre widget : actions dabord, 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>

View File

@@ -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>
)

View File

@@ -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}

View File

@@ -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>
)
})}

View File

@@ -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">&quot;provider&quot;: &quot;anthropic&quot;,</p>
<p className="pl-3">&quot;model&quot;: &quot;claude-sonnet&quot;,</p>
<p className="pl-3 text-[#F4F1EA]/70">&quot;apiKey&quot;: &quot;sk-ant-&quot;,</p>
<p className="pl-3">&quot;yours&quot;: 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>

View File

@@ -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>

View File

@@ -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')

View File

@@ -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}>

View File

@@ -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}

View File

@@ -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}
/>
</>
)
}

View File

@@ -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 daprès la note', categoryId: 'ai' },
{ ...sc('Living Block'), title: t('richTextEditor.slashLivingBlock') || 'Bloc lié', description: t('richTextEditor.slashLivingBlockDesc') || 'Ouvrir un passage dune 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(

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View 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
}

View File

@@ -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 && (
<>

View File

@@ -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 }}

View File

@@ -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])

View File

@@ -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(