fix(ui): thème, textes et lisibilité restants de la revue
Les boutons suivent la couleur d’apparence, les libellés trop petits ou trop techniques sont clarifiés, et le catalogue des fournisseurs se met à jour tout seul. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
23
memento-note/lib/ai/byok-catalog.ts
Normal file
23
memento-note/lib/ai/byok-catalog.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { getAllowedByokProviders } from '@/lib/byok'
|
||||
import { getLivePublicModels } from '@/lib/ai/live-public-catalog'
|
||||
import { providerDisplayName } from '@/lib/ai/provider-labels'
|
||||
|
||||
const ENDPOINT_ONLY = new Set(['custom', 'custom_openai', 'custom_anthropic', 'anthropic_custom'])
|
||||
|
||||
export type PublicByokProvider = {
|
||||
id: string
|
||||
name: string
|
||||
models: string[]
|
||||
}
|
||||
|
||||
/** Catalogue public : fournisseurs BYOK Business, hors adresses perso. */
|
||||
export async function getPublicByokCatalog(): Promise<PublicByokProvider[]> {
|
||||
const liveModels = await getLivePublicModels()
|
||||
return getAllowedByokProviders('BUSINESS')
|
||||
.filter((id) => !ENDPOINT_ONLY.has(id))
|
||||
.map((id) => ({
|
||||
id,
|
||||
name: providerDisplayName(id),
|
||||
models: liveModels[id] ?? [],
|
||||
}))
|
||||
}
|
||||
205
memento-note/lib/ai/live-public-catalog.ts
Normal file
205
memento-note/lib/ai/live-public-catalog.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { PROVIDER_MODEL_SUGGESTIONS } from '@/lib/ai/models-list'
|
||||
import { redis } from '@/lib/redis'
|
||||
|
||||
const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models'
|
||||
const REDIS_KEY = 'memento:public-byok-models:v2'
|
||||
const REDIS_TTL_SEC = 12 * 60 * 60
|
||||
const MEMORY_TTL_MS = 30 * 60 * 1000
|
||||
const DISPLAY_PER_PROVIDER = 3
|
||||
|
||||
type OpenRouterModel = {
|
||||
id?: string
|
||||
created?: number
|
||||
architecture?: {
|
||||
modality?: string
|
||||
output_modalities?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
const VENDOR_TO_PROVIDER: Record<string, string> = {
|
||||
openai: 'openai',
|
||||
anthropic: 'anthropic',
|
||||
google: 'google',
|
||||
deepseek: 'deepseek',
|
||||
minimax: 'minimax',
|
||||
mistralai: 'mistral',
|
||||
'z-ai': 'glm',
|
||||
}
|
||||
|
||||
let memoryCache: { at: number; models: Record<string, string[]> } | null = null
|
||||
|
||||
function slugOf(id: string): string {
|
||||
return (id.includes('/') ? id.split('/')[1] : id).replace(/:.*$/, '')
|
||||
}
|
||||
|
||||
function isNoisyId(id: string): boolean {
|
||||
const slug = slugOf(id)
|
||||
if (/:(batch|free|nitro)/i.test(id)) return true
|
||||
if (/embed/i.test(slug)) return true
|
||||
if (/-fast$/i.test(slug)) return true
|
||||
if (/-vision|-image|-audio|-preview|-exp|codex|oss|safeguard/i.test(slug)) return true
|
||||
if (/gemma|lyria|voxtral|ministral|devstral|codestral/i.test(slug)) return true
|
||||
if (/chat-latest/i.test(slug)) return true
|
||||
if (/-\d{4,8}$/.test(slug)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function cleanliness(id: string): number {
|
||||
const slug = slugOf(id)
|
||||
let score = 0
|
||||
if (/-pro$/i.test(slug)) score += 20
|
||||
if (/-lite$/i.test(slug)) score += 5
|
||||
return score
|
||||
}
|
||||
|
||||
function isChatModel(model: OpenRouterModel): boolean {
|
||||
const id = model.id ?? ''
|
||||
if (!id || isNoisyId(id)) return false
|
||||
const outputs = model.architecture?.output_modalities
|
||||
if (outputs && !outputs.includes('text')) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function familyKey(id: string): string {
|
||||
return slugOf(id)
|
||||
.replace(/-pro$/i, '')
|
||||
.replace(/-latest$/i, '')
|
||||
}
|
||||
|
||||
function nativeName(openRouterId: string): string {
|
||||
const slug = openRouterId.includes('/') ? openRouterId.split('/')[1] : openRouterId
|
||||
if (/^minimax-/i.test(slug)) {
|
||||
return slug.replace(/^minimax-/i, 'MiniMax-').replace(/-m(\d)/i, '-M$1')
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
function pickLatestIds(models: OpenRouterModel[], keepPrefix: boolean): string[] {
|
||||
const byFamily = new Map<string, OpenRouterModel[]>()
|
||||
const order: string[] = []
|
||||
const sorted = [...models].sort((a, b) => (b.created ?? 0) - (a.created ?? 0))
|
||||
for (const model of sorted) {
|
||||
const id = model.id
|
||||
if (!id) continue
|
||||
const key = familyKey(id)
|
||||
if (!byFamily.has(key)) {
|
||||
byFamily.set(key, [])
|
||||
order.push(key)
|
||||
}
|
||||
byFamily.get(key)!.push(model)
|
||||
}
|
||||
|
||||
const picked: string[] = []
|
||||
for (const key of order) {
|
||||
const best = [...(byFamily.get(key) ?? [])].sort(
|
||||
(a, b) => cleanliness(a.id ?? '') - cleanliness(b.id ?? ''),
|
||||
)[0]
|
||||
const id = best?.id
|
||||
if (!id) continue
|
||||
picked.push(keepPrefix ? id.replace(/:.*$/, '') : nativeName(id))
|
||||
if (picked.length >= DISPLAY_PER_PROVIDER) break
|
||||
}
|
||||
return picked
|
||||
}
|
||||
|
||||
function buildFromOpenRouter(models: OpenRouterModel[]): Record<string, string[]> {
|
||||
const byVendor = new Map<string, OpenRouterModel[]>()
|
||||
for (const model of models) {
|
||||
if (!isChatModel(model) || !model.id?.includes('/')) continue
|
||||
const vendor = model.id.split('/')[0]
|
||||
const list = byVendor.get(vendor) ?? []
|
||||
list.push(model)
|
||||
byVendor.set(vendor, list)
|
||||
}
|
||||
|
||||
const result: Record<string, string[]> = {}
|
||||
for (const [vendor, provider] of Object.entries(VENDOR_TO_PROVIDER)) {
|
||||
result[provider] = pickLatestIds(byVendor.get(vendor) ?? [], false)
|
||||
}
|
||||
|
||||
const showcaseVendors = ['openai', 'anthropic', 'google']
|
||||
result.openrouter = showcaseVendors
|
||||
.map((vendor) => {
|
||||
const latest = pickLatestIds(byVendor.get(vendor) ?? [], true)[0]
|
||||
return latest
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
result.zai = [
|
||||
result.openai?.[0],
|
||||
result.anthropic?.[0],
|
||||
result.google?.[0],
|
||||
].filter(Boolean) as string[]
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function fetchOpenRouterModels(): Promise<OpenRouterModel[]> {
|
||||
const response = await fetch(OPENROUTER_MODELS_URL, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'HTTP-Referer': process.env.NEXTAUTH_URL || 'https://memento-note.com',
|
||||
'X-Title': 'Memento',
|
||||
},
|
||||
signal: AbortSignal.timeout(8000),
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`catalog ${response.status}`)
|
||||
}
|
||||
const data = (await response.json()) as { data?: OpenRouterModel[] }
|
||||
return Array.isArray(data.data) ? data.data : []
|
||||
}
|
||||
|
||||
function mergeWithFallback(live: Record<string, string[]>): Record<string, string[]> {
|
||||
const merged: Record<string, string[]> = { ...PROVIDER_MODEL_SUGGESTIONS }
|
||||
for (const [id, models] of Object.entries(live)) {
|
||||
if (models.length > 0) merged[id] = models
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
async function readRedis(): Promise<Record<string, string[]> | null> {
|
||||
try {
|
||||
const raw = await redis.get(REDIS_KEY)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as Record<string, string[]>
|
||||
return parsed && typeof parsed === 'object' ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRedis(models: Record<string, string[]>): Promise<void> {
|
||||
try {
|
||||
await redis.set(REDIS_KEY, JSON.stringify(models), 'EX', REDIS_TTL_SEC)
|
||||
} catch {
|
||||
/* le cache mémoire suffit */
|
||||
}
|
||||
}
|
||||
|
||||
/** Noms de modèles à jour, lus périodiquement — jamais une liste figée seule. */
|
||||
export async function getLivePublicModels(): Promise<Record<string, string[]>> {
|
||||
if (memoryCache && Date.now() - memoryCache.at < MEMORY_TTL_MS) {
|
||||
return memoryCache.models
|
||||
}
|
||||
|
||||
const cached = await readRedis()
|
||||
if (cached) {
|
||||
memoryCache = { at: Date.now(), models: cached }
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
const remote = await fetchOpenRouterModels()
|
||||
const merged = mergeWithFallback(buildFromOpenRouter(remote))
|
||||
memoryCache = { at: Date.now(), models: merged }
|
||||
await writeRedis(merged)
|
||||
return merged
|
||||
} catch (error) {
|
||||
console.warn('[live-public-catalog] lecture distante impossible, liste de secours', error)
|
||||
const fallback = { ...PROVIDER_MODEL_SUGGESTIONS }
|
||||
memoryCache = { at: Date.now(), models: fallback }
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,15 @@ const PROVIDER_URLS: Record<string, string> = {
|
||||
|
||||
// Fallback popular models when live fetching fails or for providers without /models endpoint (e.g. Anthropic, Google)
|
||||
export const PROVIDER_MODEL_SUGGESTIONS: Record<string, string[]> = {
|
||||
openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo', 'gpt-3.5-turbo'],
|
||||
anthropic: ['claude-3-5-sonnet-latest', 'claude-3-5-haiku-latest', 'claude-3-opus-latest'],
|
||||
google: ['gemini-1.5-flash', 'gemini-1.5-pro', 'gemini-2.0-flash-exp'],
|
||||
deepseek: ['deepseek-chat', 'deepseek-coder'],
|
||||
minimax: ['MiniMax-M2.7', 'MiniMax-M2.5', 'MiniMax-M2-her'],
|
||||
mistral: ['mistral-small-latest', 'mistral-medium-latest', 'mistral-large-latest'],
|
||||
glm: ['glm-4', 'glm-4-flash'],
|
||||
openrouter: ['openai/gpt-4o-mini', 'anthropic/claude-3.5-sonnet', 'deepseek/deepseek-chat'],
|
||||
openai: ['gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'],
|
||||
anthropic: ['claude-opus-5', 'claude-sonnet-5', 'claude-fable-5'],
|
||||
google: ['gemini-3.7-flash', 'gemini-3.6-flash', 'gemini-3.5-flash-lite'],
|
||||
deepseek: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-chat'],
|
||||
minimax: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.5'],
|
||||
mistral: ['mistral-medium-latest', 'mistral-small-latest', 'mistral-large-latest'],
|
||||
glm: ['glm-5.3', 'glm-5.3-flash', 'glm-5.2'],
|
||||
openrouter: ['openai/gpt-5.6-sol', 'anthropic/claude-opus-5', 'google/gemini-3.7-flash'],
|
||||
zai: ['gpt-5.6-sol', 'claude-sonnet-5', 'gemini-3.7-flash'],
|
||||
custom: [],
|
||||
};
|
||||
|
||||
|
||||
20
memento-note/lib/ai/provider-labels.ts
Normal file
20
memento-note/lib/ai/provider-labels.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Noms affichés des fournisseurs — une seule source pour réglages et page publique. */
|
||||
export const PROVIDER_LABELS: Record<string, string> = {
|
||||
openai: 'OpenAI',
|
||||
anthropic: 'Anthropic',
|
||||
minimax: 'MiniMax',
|
||||
google: 'Google AI',
|
||||
deepseek: 'DeepSeek',
|
||||
openrouter: 'OpenRouter',
|
||||
mistral: 'Mistral AI',
|
||||
glm: 'GLM (Zhipu)',
|
||||
zai: 'Zuki Journey',
|
||||
anthropic_custom: 'Anthropic (custom)',
|
||||
custom_openai: 'Compatible OpenAI',
|
||||
custom_anthropic: 'Compatible Anthropic',
|
||||
custom: 'Custom API',
|
||||
}
|
||||
|
||||
export function providerDisplayName(provider: string): string {
|
||||
return PROVIDER_LABELS[provider] ?? provider
|
||||
}
|
||||
56
memento-note/lib/billing/period.ts
Normal file
56
memento-note/lib/billing/period.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/** Avance une date d’un mois, en gardant le jour du mois (ou le dernier jour si besoin). */
|
||||
export function addUtcMonths(date: Date, months: number): Date {
|
||||
const year = date.getUTCFullYear()
|
||||
const month = date.getUTCMonth() + months
|
||||
const day = date.getUTCDate()
|
||||
const lastDay = new Date(Date.UTC(year, month + 1, 0)).getUTCDate()
|
||||
return new Date(Date.UTC(
|
||||
year,
|
||||
month,
|
||||
Math.min(day, lastDay),
|
||||
date.getUTCHours(),
|
||||
date.getUTCMinutes(),
|
||||
date.getUTCSeconds(),
|
||||
date.getUTCMilliseconds(),
|
||||
))
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalcule la période en cours à partir de la date d’origine.
|
||||
* Sans fiche de paiement, les dates étaient écrites une fois (souvent +1 an) et ne bougeaient plus.
|
||||
*/
|
||||
export function rollBillingPeriod(
|
||||
periodStart: Date,
|
||||
now: Date = new Date(),
|
||||
): { currentPeriodStart: Date; currentPeriodEnd: Date } {
|
||||
if (Number.isNaN(periodStart.getTime())) {
|
||||
const start = new Date(now)
|
||||
return { currentPeriodStart: start, currentPeriodEnd: addUtcMonths(start, 1) }
|
||||
}
|
||||
|
||||
let start = new Date(periodStart)
|
||||
let end = addUtcMonths(start, 1)
|
||||
|
||||
if (start.getTime() > now.getTime()) {
|
||||
return { currentPeriodStart: start, currentPeriodEnd: end }
|
||||
}
|
||||
|
||||
let guard = 0
|
||||
while (end.getTime() <= now.getTime() && guard < 240) {
|
||||
start = end
|
||||
end = addUtcMonths(start, 1)
|
||||
guard += 1
|
||||
}
|
||||
|
||||
return { currentPeriodStart: start, currentPeriodEnd: end }
|
||||
}
|
||||
|
||||
export function periodsDiffer(
|
||||
a: { currentPeriodStart: Date; currentPeriodEnd: Date },
|
||||
b: { currentPeriodStart: Date; currentPeriodEnd: Date },
|
||||
): boolean {
|
||||
return (
|
||||
a.currentPeriodStart.getTime() !== b.currentPeriodStart.getTime()
|
||||
|| a.currentPeriodEnd.getTime() !== b.currentPeriodEnd.getTime()
|
||||
)
|
||||
}
|
||||
@@ -167,9 +167,9 @@ export function normalizeDashboardLayout(raw: unknown): DashboardLayout {
|
||||
const data = raw as Partial<DashboardLayout> & { version?: number }
|
||||
if (!Array.isArray(data.widgets) || data.widgets.length === 0) return getDefaultDashboardLayout()
|
||||
|
||||
// Layout périmé ou vide → preset canonique
|
||||
// Layout périmé, vide, ou version inconnue (essai abandonné) → preset actuel
|
||||
const incomingVersion = typeof data.version === 'number' ? data.version : 0
|
||||
if (incomingVersion < DASHBOARD_LAYOUT_VERSION) {
|
||||
if (incomingVersion !== DASHBOARD_LAYOUT_VERSION) {
|
||||
return getDefaultDashboardLayout()
|
||||
}
|
||||
|
||||
|
||||
38
memento-note/lib/dashboard/path-title.ts
Normal file
38
memento-note/lib/dashboard/path-title.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
const PLACEHOLDER_TITLES = new Set([
|
||||
'untitled',
|
||||
'sans titre',
|
||||
'(sans titre)',
|
||||
'(untitled)',
|
||||
])
|
||||
|
||||
function usableTitle(title: string | null | undefined): string | null {
|
||||
const trimmed = title?.trim()
|
||||
if (!trimmed) return null
|
||||
if (PLACEHOLDER_TITLES.has(trimmed.toLowerCase())) return null
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/** Titre affichable : vrai titre, sinon premier extrait du contenu. */
|
||||
export function pathNoteTitle(
|
||||
title: string | null | undefined,
|
||||
content?: string | null,
|
||||
): string | null {
|
||||
const named = usableTitle(title)
|
||||
if (named) return named
|
||||
if (!content?.trim()) return null
|
||||
const plain = content
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/ /gi, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
if (!plain) return null
|
||||
return plain.length > 80 ? `${plain.slice(0, 80).trim()}…` : plain
|
||||
}
|
||||
|
||||
/** Première note récente qui a un titre ou un extrait — évite un héros « Sans titre ». */
|
||||
export function pickFocusNote<T extends { title: string | null; content: string }>(
|
||||
notes: T[],
|
||||
): T | undefined {
|
||||
if (notes.length === 0) return undefined
|
||||
return notes.find(n => pathNoteTitle(n.title, n.content)) ?? notes[0]
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DashboardPath } from '@/lib/dashboard/path-types'
|
||||
import { pathNoteTitle, pickFocusNote } from '@/lib/dashboard/path-title'
|
||||
|
||||
function excerpt(text: string, max = 120): string {
|
||||
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
@@ -45,14 +46,15 @@ export interface BriefingPathsInput {
|
||||
/** Pistes instantanées à partir du briefing déjà chargé — sans requête lourde. */
|
||||
export function buildFastPathsFromBriefing(input: BriefingPathsInput): DashboardPath[] {
|
||||
const paths: DashboardPath[] = []
|
||||
const focus = input.recentNotes[0]
|
||||
const focus = pickFocusNote(input.recentNotes)
|
||||
const continueTitle = focus ? pathNoteTitle(focus.title, focus.content) : null
|
||||
|
||||
if (focus) {
|
||||
if (focus && continueTitle) {
|
||||
paths.push({
|
||||
id: `continue-${focus.id}`,
|
||||
type: 'continue',
|
||||
priority: 100,
|
||||
title: focus.title || 'Untitled',
|
||||
title: continueTitle,
|
||||
description: excerpt(focus.content, 140),
|
||||
actionKey: 'continue',
|
||||
noteId: focus.id,
|
||||
@@ -63,11 +65,12 @@ export function buildFastPathsFromBriefing(input: BriefingPathsInput): Dashboard
|
||||
.filter(i => i.note1.id === focus.id || i.note2.id === focus.id)
|
||||
.slice(0, 2)) {
|
||||
const other = ins.note1.id === focus.id ? ins.note2 : ins.note1
|
||||
const otherTitle = pathNoteTitle(other.title, ins.note1.id === focus.id ? ins.note2Excerpt : ins.note1Excerpt)
|
||||
paths.push({
|
||||
id: `connect-${focus.id}-${other.id}`,
|
||||
type: 'connect',
|
||||
priority: 88,
|
||||
title: other.title || 'Untitled',
|
||||
title: otherTitle || excerpt(ins.insight, 80),
|
||||
description: ins.insight,
|
||||
actionKey: 'compare',
|
||||
noteId: focus.id,
|
||||
@@ -80,11 +83,13 @@ export function buildFastPathsFromBriefing(input: BriefingPathsInput): Dashboard
|
||||
|
||||
const freshInsight = input.insights.find(i => !i.viewed)
|
||||
if (freshInsight) {
|
||||
const left = pathNoteTitle(freshInsight.note1.title, freshInsight.note1Excerpt)
|
||||
const right = pathNoteTitle(freshInsight.note2.title, freshInsight.note2Excerpt)
|
||||
paths.push({
|
||||
id: `resurface-${freshInsight.id}`,
|
||||
type: 'resurface',
|
||||
priority: 85,
|
||||
title: `${freshInsight.note1.title || '…'} ↔ ${freshInsight.note2.title || '…'}`,
|
||||
title: left && right ? `${left} ↔ ${right}` : excerpt(freshInsight.insight, 80),
|
||||
description: freshInsight.insight,
|
||||
actionKey: 'openInsight',
|
||||
insightId: freshInsight.id,
|
||||
@@ -121,28 +126,6 @@ export function buildFastPathsFromBriefing(input: BriefingPathsInput): Dashboard
|
||||
})
|
||||
}
|
||||
|
||||
if (input.inboxCount > 0) {
|
||||
paths.push({
|
||||
id: 'organize-inbox',
|
||||
type: 'organize',
|
||||
priority: 60,
|
||||
title: `${input.inboxCount} notes`,
|
||||
description: 'inbox',
|
||||
actionKey: 'organizeInbox',
|
||||
})
|
||||
}
|
||||
|
||||
if (input.dueFlashcards > 0) {
|
||||
paths.push({
|
||||
id: 'review-flashcards',
|
||||
type: 'review',
|
||||
priority: 55,
|
||||
title: `${input.dueFlashcards}`,
|
||||
description: 'flashcards',
|
||||
actionKey: 'reviewCards',
|
||||
})
|
||||
}
|
||||
|
||||
paths.push({
|
||||
id: 'daily-journal',
|
||||
type: 'daily',
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'server-only'
|
||||
import prisma from '@/lib/prisma'
|
||||
import { clusteringService } from '@/lib/ai/services/clustering.service'
|
||||
import type { DashboardPath } from '@/lib/dashboard/path-types'
|
||||
import { pathNoteTitle, pickFocusNote } from '@/lib/dashboard/path-title'
|
||||
|
||||
export type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
|
||||
|
||||
@@ -103,11 +104,15 @@ function pathsFromInsights(
|
||||
.slice(0, 2)) {
|
||||
const other = ins.note1.id === focus.id ? ins.note2 : ins.note1
|
||||
if (paths.some(p => p.note2Id === other.id && p.type === 'connect')) continue
|
||||
const otherTitle = pathNoteTitle(
|
||||
other.title,
|
||||
ins.note1.id === focus.id ? ins.note2Excerpt : ins.note1Excerpt,
|
||||
)
|
||||
paths.push({
|
||||
id: `connect-${focus.id}-${other.id}`,
|
||||
type: 'connect',
|
||||
priority: 90 - paths.length,
|
||||
title: other.title || 'Untitled',
|
||||
title: otherTitle || excerpt(ins.insight || ins.note2Excerpt || ins.note1Excerpt || '', 80),
|
||||
description: ins.insight || ins.note2Excerpt || ins.note1Excerpt || '',
|
||||
actionKey: 'compare',
|
||||
noteId: focus.id,
|
||||
@@ -152,7 +157,8 @@ async function findOpenLoops(userId: string, limit = 3): Promise<Array<{ id: str
|
||||
|
||||
export async function buildDashboardPaths(input: BuildPathsInput): Promise<DashboardPath[]> {
|
||||
const paths: DashboardPath[] = []
|
||||
const focus = input.recentNotes[0]
|
||||
const focus = pickFocusNote(input.recentNotes)
|
||||
const continueTitle = focus ? pathNoteTitle(focus.title, focus.content) : null
|
||||
|
||||
let focusClusterId: number | null = null
|
||||
if (focus) {
|
||||
@@ -162,16 +168,18 @@ export async function buildDashboardPaths(input: BuildPathsInput): Promise<Dashb
|
||||
})
|
||||
focusClusterId = member?.clusterId ?? null
|
||||
|
||||
paths.push({
|
||||
id: `continue-${focus.id}`,
|
||||
type: 'continue',
|
||||
priority: 100,
|
||||
title: focus.title || 'Untitled',
|
||||
description: excerpt(focus.content, 140),
|
||||
actionKey: 'continue',
|
||||
noteId: focus.id,
|
||||
notebookId: focus.notebookId ?? undefined,
|
||||
})
|
||||
if (continueTitle) {
|
||||
paths.push({
|
||||
id: `continue-${focus.id}`,
|
||||
type: 'continue',
|
||||
priority: 100,
|
||||
title: continueTitle,
|
||||
description: excerpt(focus.content, 140),
|
||||
actionKey: 'continue',
|
||||
noteId: focus.id,
|
||||
notebookId: focus.notebookId ?? undefined,
|
||||
})
|
||||
}
|
||||
|
||||
pathsFromInsights(focus, input.insights, paths)
|
||||
|
||||
@@ -181,7 +189,7 @@ export async function buildDashboardPaths(input: BuildPathsInput): Promise<Dashb
|
||||
id: `add-link-${focus.id}-${link.noteId}`,
|
||||
type: 'add-link',
|
||||
priority: 75,
|
||||
title: link.noteTitle || 'Untitled',
|
||||
title: pathNoteTitle(link.noteTitle, link.snippet) || excerpt(link.snippet, 80),
|
||||
description: link.snippet,
|
||||
actionKey: 'addLink',
|
||||
noteId: focus.id,
|
||||
@@ -198,7 +206,11 @@ export async function buildDashboardPaths(input: BuildPathsInput): Promise<Dashb
|
||||
id: `resurface-${freshInsight.id}`,
|
||||
type: 'resurface',
|
||||
priority: 85,
|
||||
title: `${freshInsight.note1.title || '…'} ↔ ${freshInsight.note2.title || '…'}`,
|
||||
title: (() => {
|
||||
const left = pathNoteTitle(freshInsight.note1.title, freshInsight.note1Excerpt)
|
||||
const right = pathNoteTitle(freshInsight.note2.title, freshInsight.note2Excerpt)
|
||||
return left && right ? `${left} ↔ ${right}` : excerpt(freshInsight.insight, 80)
|
||||
})(),
|
||||
description: freshInsight.insight,
|
||||
actionKey: 'openInsight',
|
||||
insightId: freshInsight.id,
|
||||
@@ -257,28 +269,6 @@ export async function buildDashboardPaths(input: BuildPathsInput): Promise<Dashb
|
||||
}
|
||||
}
|
||||
|
||||
if (input.inboxCount > 0) {
|
||||
paths.push({
|
||||
id: 'organize-inbox',
|
||||
type: 'organize',
|
||||
priority: 70,
|
||||
title: `${input.inboxCount} notes`,
|
||||
description: 'inbox',
|
||||
actionKey: 'organizeInbox',
|
||||
})
|
||||
}
|
||||
|
||||
if (input.dueFlashcards > 0) {
|
||||
paths.push({
|
||||
id: 'review-flashcards',
|
||||
type: 'review',
|
||||
priority: 65,
|
||||
title: `${input.dueFlashcards}`,
|
||||
description: 'flashcards',
|
||||
actionKey: 'reviewCards',
|
||||
})
|
||||
}
|
||||
|
||||
paths.push({
|
||||
id: 'daily-journal',
|
||||
type: 'daily',
|
||||
|
||||
@@ -37,7 +37,11 @@ export const THEME_INIT_SCRIPT = `(function () {
|
||||
}
|
||||
root.setAttribute('data-applied-theme', theme);
|
||||
var accentStored = localStorage.getItem('accent-color');
|
||||
root.style.setProperty('--color-brand-accent', accentStored || defaultAccent);
|
||||
var effectiveAccent = defaultAccent || accentStored || '#A47148';
|
||||
root.style.setProperty('--color-brand-accent', effectiveAccent);
|
||||
if (defaultAccent && accentStored !== defaultAccent) {
|
||||
try { localStorage.setItem('accent-color', defaultAccent); } catch (e) {}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Theme script error', e);
|
||||
}
|
||||
|
||||
29
memento-note/lib/notes/trash-toast.ts
Normal file
29
memento-note/lib/notes/trash-toast.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import { restoreNote } from '@/app/actions/notes'
|
||||
import { emitNoteChange } from '@/lib/note-change-sync'
|
||||
import type { Note } from '@/lib/types'
|
||||
|
||||
export function showNoteTrashedToast(
|
||||
note: Note,
|
||||
t: (key: string) => string,
|
||||
onRestored?: () => void,
|
||||
) {
|
||||
toast.success(t('notes.noteDeletedToast'), {
|
||||
action: {
|
||||
label: t('notes.undoDelete'),
|
||||
onClick: async () => {
|
||||
try {
|
||||
await restoreNote(note.id)
|
||||
const restored = { ...note, trashedAt: null }
|
||||
emitNoteChange({ type: 'created', note: restored })
|
||||
onRestored?.()
|
||||
toast.success(t('trash.noteRestored'))
|
||||
} catch {
|
||||
toast.error(t('general.error'))
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -10,6 +10,17 @@ export const MAX_EMBEDDING_CHARS = EMBEDDING_CHUNK_CHARS
|
||||
const CLIP_FOOTER_PATTERN =
|
||||
/<hr\s*\/?>\s*<p[^>]*>\s*<small>[\s\S]*?<\/small>\s*<\/p>\s*$/i
|
||||
|
||||
/** Seuil serveur : en dessous, pas de génération de titre (clic volontaire inclus). */
|
||||
export const MIN_WORDS_FOR_TITLE_SUGGESTION = 10
|
||||
/** Seuil automatique — aligné sur le texte des réglages (« après 50+ mots »). */
|
||||
export const MIN_WORDS_FOR_AUTO_TITLE = 50
|
||||
|
||||
export function countPlainWords(htmlOrText: string): number {
|
||||
const plain = stripHtmlToPlainText(htmlOrText)
|
||||
if (!plain) return 0
|
||||
return plain.split(/\s+/).filter((w) => w.length > 0).length
|
||||
}
|
||||
|
||||
export function stripHtmlToPlainText(html: string): string {
|
||||
if (!html) return ''
|
||||
return html
|
||||
|
||||
@@ -29,8 +29,12 @@ export function getThemeScript(serverTheme: string = 'light', serverAccentColor:
|
||||
}
|
||||
root.setAttribute('data-applied-theme', theme);
|
||||
var accentStored = localStorage.getItem('accent-color');
|
||||
var effectiveAccent = accentStored || ${JSON.stringify(defaultAccent)};
|
||||
var serverAccent = ${JSON.stringify(defaultAccent)};
|
||||
var effectiveAccent = serverAccent || accentStored || '#A47148';
|
||||
root.style.setProperty('--color-brand-accent', effectiveAccent);
|
||||
if (serverAccent && accentStored !== serverAccent) {
|
||||
try { localStorage.setItem('accent-color', serverAccent); } catch (e) {}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Theme script error', e);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user