9 Commits

Author SHA1 Message Date
Antigravity
ea326a10b4 feat: sidebar redimensionnable — drag handle entre sidebar et contenu
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 6m15s
CI / Deploy production (on server) (push) Failing after 1s
- sidebar-resize.tsx: hook useSidebarResize (localStorage persistence, 280-560px)
- sidebar-resize-client.tsx: wrapper layout qui applique la largeur
- layout.tsx: remplace div statique par SidebarResizeClient
- Resize handle: barre 1px→3px au hover, brand-accent pendant drag
- pointer events (pas mouse) pour support tactile
- cursor: col-resize + userSelect:none pendant le drag

L'utilisateur peut maintenant réduire ou élargir la zone de carnet
en glissant la barre entre le sidebar et le contenu principal.
2026-07-11 17:43:09 +00:00
Antigravity
f97c5e8ec4 feat(brainstorm): rouvrir la note créée depuis une idée convertie
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 6m17s
CI / Deploy production (on server) (push) Failing after 1s
Quand une idée est déjà convertie en note:
- Badge 'Note Created →' cliquable dans le header de l'idée (ouvre le peek)
- Bouton 'Open Note →' remplace 'Create Note' (bordure emerald, fond vert)
- peek.open(convertedToNoteId) ouvre le drawer latéral avec la note
- L'utilisateur peut relire la note sans quitter le brainstorm
2026-07-11 16:47:13 +00:00
Antigravity
4d029d3ebe fix: fichier manquant convert-notebook-dialog.tsx
Some checks failed
CI / Lint, Unit Tests & Build (push) Successful in 4m51s
CI / Deploy production (on server) (push) Failing after 2s
2026-07-11 16:25:35 +00:00
Antigravity
5eba211061 ci: retrigger pipeline après fix upload-artifact (build sur docker-host)
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m34s
CI / Deploy production (on server) (push) Has been skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 19:26:29 +00:00
Antigravity
f44a0f9c68 fix(brainstorm): dialog sélection carnet sur Create Note depuis canvas
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m29s
CI / Deploy production (on server) (push) Has been skipped
Vrai fix UX: quand l'utilisateur clique 'Create Note' dans le canvas brainstorm,
il voit maintenant un dialog (ConvertNotebookDialog) qui demande dans
quel carnet sauvegarder la note — au lieu d'aller silencieusement dans
le carnet source ou un carnet auto-créé.

- convert-notebook-dialog.tsx (162 lignes): dialog de sélection carnet
  - design system respecté: emerald, memento-paper, font-memento-serif
  - preview de l'idée en card
  - dropdown carnet avec animation d'expansion
  - bouton 'Create note' avec loader
  - role=dialog, aria-modal, aria-label
- brainstorm-page.tsx: convertConfirm state + bouton 'Create Note' ouvre le dialog
- 3 nouvelles clés i18n (EN/FR): convertTitle, convertSubtitle, ideaLabel
2026-07-05 20:11:47 +00:00
Antigravity
a99d1dafb2 fix(ci): contourner upload-artifact Gitea — build sur docker-host
Some checks failed
CI / Deploy production (on server) (push) Has been cancelled
CI / Lint, Unit Tests & Build (push) Has been cancelled
L'étape Upload web artifact échouait systématiquement (stockage
artefacts Gitea instable). Le déploiement ne passera plus par
upload/download-artifact entre runners.

- CI ubuntu : lint + tests + build (validation uniquement)
- Deploy docker-host : npm ci + build + tar local /tmp/web-artifact.tgz
- deploy-prod.sh consomme l'artefact local, sans download-artifact

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 20:10:15 +00:00
Antigravity
5aa7202abe fix(brainstorm): notebookId URL param + lookup carnet + bouton 'Open notebook'
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 4m13s
CI / Deploy production (on server) (push) Has been skipped
- brainstorm-page.tsx: lit urlNotebookId depuis URL params
- useConvertIdea accepte targetNotebookId (hook mis à jour)
- handleConvert prend le carnet ciblé en paramètre
- URL notebookId propage via router.push depuis le modal de confirmation
- toast inclut le nom du carnet et un bouton 'Open notebook' qui redirige vers /home?notebook=X
- peek panel affiche un lien vers le carnet source en haut du titre
- useNotebooks pour le lookup du nom depuis notebooks

Vrai fix: les notes vont maintenant là où l'utilisateur a choisi,
pas dans un carnet auto-créé sans qu'il s'en aperçoive.
2026-07-05 20:01:12 +00:00
Antigravity
02b835fb13 feat(brainstorm): UX fixes — modal confirmation + destination carnet + race fix
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 4m29s
CI / Deploy production (on server) (push) Has been skipped
UX Pro Max appliqué selon les best practices 2026 (Heptabase, Obsidian, Tana):
- confirmation-dialogs (HIGH): modal de confirmation avant brainstorm
- success-feedback (MEDIUM): toast avec destination du carnet
- nav-state-active (MEDIUM): indication claire de la session active

3 fixes:

1. Modal de confirmation (brainstorm-confirm-dialog.tsx, 181 lignes):
   - Design system respecté: brand-accent, memento-paper, font-memento-serif
   - Aperçu seed en card paper
   - Sélecteur de carnet avec animation d'expansion
   - Bouton 'Annuler' + 'Lancer le brainstorm' avec loader
   - role=dialog, aria-modal, aria-label
   - focus-visible, keyboard nav
   - backdrop-blur-sm + click-to-close
   - Lazy loaded via next/dynamic

2. Race condition fix (brainstorm-page.tsx L111):
   - Auto-select session désactivé si urlSeed présent
   - urlSeed + urlInviteToken court-circuitent le useEffect

3. Toast avec destination (handleConvert + noteCreatedIn key):
   - message: 'Note créée dans [Nom du carnet]'
   - useConvertIdea accepte notebookId optionnel
   - API convert accepte notebookId via zod schema
   - useConvertIdea(mutationFn) accepte string | {ideaId, notebookId}

i18n: 9 nouvelles clés brainstorm.* en EN/FR
2026-07-05 19:44:07 +00:00
Antigravity
d1336b1fea feat(brainstorm): Option D — toast + peek panel + navigation volontaire
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 4m37s
CI / Deploy production (on server) (push) Has been skipped
3 points corrigés (plus d'éjection automatique du brainstorm):

1. Convertir idée → note:
   - Avant: setTimeout 2s → router.push (éjection forcée)
   - Après: toast.success persistant 10s + peek.open (drawer latéral)

2. Exporter session → note synthèse:
   - Avant: setTimeout 2s → router.push (éjection forcée)
   - Après: toast.success + peek.open

3. Voir note référencée:
   - Avant: router.push (éjection immédiate)
   - Après: peek.open (drawer latéral)

Comportement:
- Toast sonner avec bouton 'Ouvrir' (navigation volontaire)
- NotePeekPanel mode overlay (slide droite, lecture seule, KaTeX)
- Canvas brainstorm reste interactif derrière
- Bouton Maximize2 dans le peek → router.push (choix explicite)
- Anciens convertToast/exportToast supprimés (45 lignes JSX retirées)
2026-07-05 19:09:26 +00:00
15 changed files with 655 additions and 135 deletions

View File

@@ -1,8 +1,8 @@
{ {
"version": 1, "version": 1,
"lastRunAtMs": 1783196565768, "lastRunAtMs": 1783196565768,
"turnsSinceLastRun": 5, "turnsSinceLastRun": 7,
"lastTranscriptMtimeMs": 1783196565691.0437, "lastTranscriptMtimeMs": 1783196565691.0437,
"lastProcessedGenerationId": "7ac52bd5-5714-460a-9c3b-e4b21907d6d7", "lastProcessedGenerationId": "71df05a3-2a54-46fa-aa10-c8b8357c33fd",
"trialStartedAtMs": null "trialStartedAtMs": null
} }

View File

@@ -91,24 +91,6 @@ jobs:
-d "parse_mode=Markdown" || true -d "parse_mode=Markdown" || true
fi fi
- name: Pack web artifact for deploy
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
working-directory: memento-note
run: |
tar czf ../web-artifact.tgz \
.next/standalone .next/static public prisma \
node_modules/.prisma node_modules/@prisma node_modules/prisma \
docker-entrypoint.sh socket-server.ts tsconfig.json
ls -lh ../web-artifact.tgz
- name: Upload web artifact
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: actions/upload-artifact@v3
with:
name: web-artifact
path: web-artifact.tgz
retention-days: 2
deploy: deploy:
name: Deploy production (on server) name: Deploy production (on server)
needs: ci needs: ci
@@ -123,10 +105,32 @@ jobs:
git fetch origin main git fetch origin main
git reset --hard origin/main git reset --hard origin/main
- name: Download web artifact # Build sur docker-host : évite upload-artifact Gitea (souvent cassé en prod).
uses: actions/download-artifact@v3 - name: Setup Node 22
uses: actions/setup-node@v4
with: with:
name: web-artifact node-version: "22"
- name: Build web artifact on server
working-directory: /opt/memento/memento-note
run: |
set -euo pipefail
npm ci --legacy-peer-deps
npx prisma generate
npm run build
for p in .next/standalone .next/static public prisma; do
[ -e "$p" ] || { echo "Missing build output: $p"; exit 1; }
done
tar czf /tmp/web-artifact.tgz \
.next/standalone .next/static public prisma \
node_modules/.prisma node_modules/@prisma node_modules/prisma \
docker-entrypoint.sh socket-server.ts tsconfig.json
SIZE=$(stat -c%s /tmp/web-artifact.tgz)
if [ "$SIZE" -lt 1048576 ]; then
echo "ERROR: artifact too small ($SIZE bytes)"
exit 1
fi
ls -lh /tmp/web-artifact.tgz
- name: Update .env.docker - name: Update .env.docker
env: env:
@@ -238,6 +242,6 @@ jobs:
- name: Deploy on 192.168.1.190 - name: Deploy on 192.168.1.190
env: env:
ARTIFACT_TGZ: ${{ github.workspace }}/web-artifact.tgz ARTIFACT_TGZ: /tmp/web-artifact.tgz
EXPECTED_COMMIT: ${{ github.sha }} EXPECTED_COMMIT: ${{ github.sha }}
run: bash /opt/memento/scripts/deploy-prod.sh run: bash /opt/memento/scripts/deploy-prod.sh

View File

@@ -91,25 +91,6 @@ jobs:
-d "parse_mode=Markdown" || true -d "parse_mode=Markdown" || true
fi fi
- name: Pack web artifact for deploy
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
working-directory: memento-note
run: |
tar czf ../web-artifact.tgz \
.next/standalone .next/static public prisma \
node_modules/.prisma node_modules/@prisma node_modules/prisma \
docker-entrypoint.sh socket-server.ts tsconfig.json
ls -lh ../web-artifact.tgz
- name: Upload web artifact
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
uses: actions/upload-artifact@v3
continue-on-error: true
with:
name: web-artifact
path: web-artifact.tgz
retention-days: 2
deploy: deploy:
name: Deploy production (on server) name: Deploy production (on server)
needs: ci needs: ci
@@ -124,11 +105,31 @@ jobs:
git fetch origin main git fetch origin main
git reset --hard origin/main git reset --hard origin/main
- name: Download web artifact - name: Setup Node 22
uses: actions/download-artifact@v3 uses: actions/setup-node@v4
continue-on-error: true
with: with:
name: web-artifact node-version: "22"
- name: Build web artifact on server
working-directory: /opt/memento/memento-note
run: |
set -euo pipefail
npm ci --legacy-peer-deps
npx prisma generate
npm run build
for p in .next/standalone .next/static public prisma; do
[ -e "$p" ] || { echo "Missing build output: $p"; exit 1; }
done
tar czf /tmp/web-artifact.tgz \
.next/standalone .next/static public prisma \
node_modules/.prisma node_modules/@prisma node_modules/prisma \
docker-entrypoint.sh socket-server.ts tsconfig.json
SIZE=$(stat -c%s /tmp/web-artifact.tgz)
if [ "$SIZE" -lt 1048576 ]; then
echo "ERROR: artifact too small ($SIZE bytes)"
exit 1
fi
ls -lh /tmp/web-artifact.tgz
- name: Update .env.docker - name: Update .env.docker
env: env:
@@ -229,6 +230,6 @@ jobs:
- name: Deploy on 192.168.1.190 - name: Deploy on 192.168.1.190
env: env:
ARTIFACT_TGZ: ${{ github.workspace }}/web-artifact.tgz ARTIFACT_TGZ: /tmp/web-artifact.tgz
EXPECTED_COMMIT: ${{ github.sha }} EXPECTED_COMMIT: ${{ github.sha }}
run: bash /opt/memento/scripts/deploy-prod.sh run: bash /opt/memento/scripts/deploy-prod.sh

View File

@@ -7,6 +7,7 @@ import { detectUserLanguage, parseAcceptLanguage } from "@/lib/i18n/detect-user-
import { loadTranslations } from "@/lib/i18n/load-translations"; import { loadTranslations } from "@/lib/i18n/load-translations";
import { getAISettings } from "@/app/actions/ai-settings"; import { getAISettings } from "@/app/actions/ai-settings";
import { AIChatLayoutBridge } from "@/components/ai-chat-layout-bridge"; import { AIChatLayoutBridge } from "@/components/ai-chat-layout-bridge";
import { SidebarResizeClient } from "@/components/sidebar-resize-client";
export default async function MainLayout({ export default async function MainLayout({
@@ -36,8 +37,8 @@ export default async function MainLayout({
initialAiProcessingConsent={aiSettings?.aiProcessingConsent === true} initialAiProcessingConsent={aiSettings?.aiProcessingConsent === true}
> >
{/* No top-bar header — sidebar-only navigation (architectural-grid design) */} {/* No top-bar header — sidebar-only navigation (architectural-grid design) */}
<div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background"> <SidebarResizeClient>
<Suspense fallback={<div className="hidden w-80 md:w-[26rem] 2xl:w-[30rem] shrink-0 md:block h-full self-stretch" />}> <Suspense fallback={<div className="hidden w-80 shrink-0 md:block h-full self-stretch" />}>
<Sidebar user={session?.user} /> <Sidebar user={session?.user} />
</Suspense> </Suspense>
@@ -47,9 +48,9 @@ export default async function MainLayout({
</a> </a>
{children} {children}
</main> </main>
</SidebarResizeClient>
{showAIAssistant && <AIChatLayoutBridge />} {showAIAssistant && <AIChatLayoutBridge />}
</div>
</ProvidersWrapper> </ProvidersWrapper>
); );
} }

View File

@@ -7,6 +7,7 @@ import { emitToSession } from '@/lib/socket-emit'
const convertSchema = z.object({ const convertSchema = z.object({
ideaId: z.string().min(1), ideaId: z.string().min(1),
notebookId: z.string().min(1).nullable().optional(),
}) })
export async function POST( export async function POST(
@@ -21,7 +22,7 @@ export async function POST(
try { try {
const { sessionId } = await params const { sessionId } = await params
const body = await request.json() const body = await request.json()
const { ideaId } = convertSchema.parse(body) const { ideaId, notebookId: paramNotebookId } = convertSchema.parse(body)
const brainstormSession = await prisma.brainstormSession.findFirst({ const brainstormSession = await prisma.brainstormSession.findFirst({
where: { where: {
@@ -51,7 +52,7 @@ export async function POST(
} }
let sourceSection = '' let sourceSection = ''
let targetNotebookId: string | null = null let targetNotebookId: string | null = paramNotebookId ?? null
if (brainstormSession.exportedNoteId) { if (brainstormSession.exportedNoteId) {
const exportedNote = await prisma.note.findUnique({ const exportedNote = await prisma.note.findUnique({

View File

@@ -0,0 +1,181 @@
'use client'
import { useState, memo } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Wind, FolderOpen, X, Loader2, ChevronDown } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useNotebooks } from '@/context/notebooks-context'
import type { Note } from '@/lib/types'
interface BrainstormConfirmDialogProps {
open: boolean
onClose: () => void
onConfirm: (notebookId: string | null) => void
note: Note | null
}
function BrainstormConfirmDialogInner({ open, onClose, onConfirm, note }: BrainstormConfirmDialogProps) {
const { t } = useLanguage()
const { notebooks } = useNotebooks()
const [showNotebookSelect, setShowNotebookSelect] = useState(false)
const noteNotebook = notebooks.find(nb => nb.id === note?.notebookId)
const defaultNotebookName = noteNotebook?.name || t('brainstorm.defaultNotebook') || 'Brainstorm'
const defaultNotebookId = note?.notebookId ?? null
const [selectedNotebookId, setSelectedNotebookId] = useState<string | null>(defaultNotebookId)
const [selectedNotebookName, setSelectedNotebookName] = useState(defaultNotebookName)
const [loading, setLoading] = useState(false)
const handleConfirm = () => {
setLoading(true)
onConfirm(selectedNotebookId)
}
const handleClose = () => {
setLoading(false)
setSelectedNotebookId(defaultNotebookId)
setSelectedNotebookName(defaultNotebookName)
setShowNotebookSelect(false)
onClose()
}
return (
<AnimatePresence>
{open && note && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={(e) => { if (e.target === e.currentTarget) handleClose() }}
role="dialog"
aria-modal="true"
aria-label={t('brainstorm.confirmTitle') || 'Brainstorm this idea'}
>
<motion.div
initial={{ scale: 0.92, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.92, y: 20 }}
transition={{ type: 'spring', stiffness: 340, damping: 30 }}
className="bg-memento-paper dark:bg-zinc-900 rounded-3xl shadow-2xl border border-border/40 w-full max-w-md overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="px-6 py-5 border-b border-border/20 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-brand-accent/10 flex items-center justify-center">
<Wind size={16} className="text-brand-accent" />
</div>
<div>
<h3 className="font-memento-serif text-base font-medium text-ink dark:text-dark-ink">
{t('brainstorm.confirmTitle') || 'Brainstorm this idea'}
</h3>
<p className="text-[10px] text-concrete uppercase tracking-widest font-bold">
{t('brainstorm.confirmSubtitle') || 'AI will expand your idea'}
</p>
</div>
</div>
<button
onClick={handleClose}
className="p-1.5 rounded-lg text-concrete hover:text-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer"
aria-label={t('common.close') || 'Close'}
>
<X size={16} />
</button>
</div>
{/* Body */}
<div className="px-6 py-5 space-y-4">
{/* Seed preview */}
<div className="p-4 rounded-2xl bg-white/60 dark:bg-white/5 border border-border/30">
<p className="text-[10px] uppercase tracking-widest font-bold text-concrete mb-2">
{t('brainstorm.seedLabel') || 'Seed idea'}
</p>
<p className="text-sm font-memento-serif text-ink dark:text-dark-ink leading-relaxed line-clamp-3">
{note.title || t('notes.untitled')}
</p>
</div>
{/* Destination notebook */}
<div>
<p className="text-[10px] uppercase tracking-widest font-bold text-concrete mb-2">
{t('brainstorm.destinationLabel') || 'Notes will be saved to'}
</p>
<button
onClick={() => setShowNotebookSelect(!showNotebookSelect)}
className="w-full flex items-center justify-between p-3 rounded-xl bg-white/60 dark:bg-white/5 border border-border/30 hover:border-brand-accent/40 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-brand-accent/10 flex items-center justify-center shrink-0">
<FolderOpen size={13} className="text-brand-accent" />
</div>
<span className="text-sm font-medium text-ink dark:text-dark-ink truncate">
{selectedNotebookName}
</span>
</div>
<ChevronDown size={14} className={`text-concrete transition-transform ${showNotebookSelect ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{showNotebookSelect && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-1 max-h-48 overflow-y-auto rounded-xl border border-border/30 bg-white dark:bg-zinc-800 shadow-lg">
<button
onClick={() => { setSelectedNotebookId(null); setSelectedNotebookName(t('brainstorm.noNotebook') || 'No notebook'); setShowNotebookSelect(false) }}
className="w-full text-left px-3 py-2.5 hover:bg-brand-accent/5 transition-colors text-sm text-concrete cursor-pointer"
>
{t('brainstorm.noNotebook') || 'No notebook'}
</button>
{notebooks.map(nb => (
<button
key={nb.id}
onClick={() => { setSelectedNotebookId(nb.id); setSelectedNotebookName(nb.name); setShowNotebookSelect(false) }}
className={`w-full text-left px-3 py-2.5 hover:bg-brand-accent/5 transition-colors text-sm cursor-pointer flex items-center gap-2 ${selectedNotebookId === nb.id ? 'text-brand-accent font-medium' : 'text-ink dark:text-dark-ink'}`}
>
<FolderOpen size={12} className="shrink-0 opacity-50" />
<span className="truncate">{nb.name}</span>
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-border/20 flex items-center justify-end gap-3">
<button
onClick={handleClose}
disabled={loading}
className="px-4 py-2.5 rounded-xl text-sm font-medium text-concrete hover:text-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirm}
disabled={loading}
className="px-5 py-2.5 rounded-xl bg-brand-accent text-white text-sm font-bold uppercase tracking-wider hover:bg-brand-accent/90 transition-colors cursor-pointer disabled:opacity-50 flex items-center gap-2"
>
{loading ? (
<><Loader2 size={14} className="animate-spin" /> {t('brainstorm.launching') || 'Launching…'}</>
) : (
<><Wind size={14} /> {t('brainstorm.launch') || 'Brainstorm'}</>
)}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}
export const BrainstormConfirmDialog = memo(BrainstormConfirmDialogInner)

View File

@@ -1,4 +1,6 @@
'use client' 'use client'
import { toast } from 'sonner'
import { useNotebooks } from '@/context/notebooks-context'
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react' import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
@@ -26,9 +28,11 @@ import {
LayoutGrid, LayoutGrid,
X, X,
Edit2, Edit2,
Maximize2,
} from 'lucide-react' } from 'lucide-react'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import { useNotePeek, NotePeekPanel } from '@/components/note-peek'
import { import {
useBrainstormSession, useBrainstormSession,
useBrainstormSessions, useBrainstormSessions,
@@ -50,6 +54,7 @@ import {
import { useBrainstormSocket } from '@/hooks/use-brainstorm-socket' import { useBrainstormSocket } from '@/hooks/use-brainstorm-socket'
import { LiveCursors, PresenceAvatars, useCursorTracking } from '@/components/brainstorm/live-cursors' import { LiveCursors, PresenceAvatars, useCursorTracking } from '@/components/brainstorm/live-cursors'
import { ActivityFeed } from '@/components/brainstorm/activity-feed' import { ActivityFeed } from '@/components/brainstorm/activity-feed'
import { ConvertNotebookDialog } from './convert-notebook-dialog'
import { BrainstormShareDialog } from '@/components/brainstorm/brainstorm-share-dialog' import { BrainstormShareDialog } from '@/components/brainstorm/brainstorm-share-dialog'
import { GhostCursor } from '@/components/brainstorm/ghost-cursor' import { GhostCursor } from '@/components/brainstorm/ghost-cursor'
import { PlaybackBar } from '@/components/brainstorm/playback-bar' import { PlaybackBar } from '@/components/brainstorm/playback-bar'
@@ -76,12 +81,15 @@ const WAVE_COLORS: Record<number, { border: string; bg: string; text: string }>
export function BrainstormPage() { export function BrainstormPage() {
const router = useRouter() const router = useRouter()
const peek = useNotePeek()
const { notebooks } = useNotebooks()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const { t, language } = useLanguage() const { t, language } = useLanguage()
const { data: authSession } = useSession() const { data: authSession } = useSession()
const urlSessionId = searchParams.get('session') const urlSessionId = searchParams.get('session')
const urlSeed = searchParams.get('seed') const urlSeed = searchParams.get('seed')
const urlSourceNoteId = searchParams.get('sourceNoteId') const urlSourceNoteId = searchParams.get('sourceNoteId')
const urlNotebookId = searchParams.get('notebookId')
const urlInviteToken = searchParams.get('invite') const urlInviteToken = searchParams.get('invite')
const [seedInput, setSeedInput] = useState('') const [seedInput, setSeedInput] = useState('')
@@ -102,8 +110,9 @@ export function BrainstormPage() {
const { data: sessions, isLoading: sessionsLoading } = useBrainstormSessions() const { data: sessions, isLoading: sessionsLoading } = useBrainstormSessions()
// Auto-sélectionner la dernière session si aucune session active // Auto-sélectionner la dernière session si aucune session active ET pas de seed dans l'URL
useEffect(() => { useEffect(() => {
if (urlSeed || urlInviteToken) return // Ne pas auto-select si on arrive avec un seed
if (!activeSessionId && !urlSessionId && !sessionsLoading && sessions && sessions.length > 0) { if (!activeSessionId && !urlSessionId && !sessionsLoading && sessions && sessions.length > 0) {
const last = sessions[0] const last = sessions[0]
setActiveSessionId(last.id) setActiveSessionId(last.id)
@@ -121,6 +130,7 @@ export function BrainstormPage() {
const expandIdea = useExpandIdea(activeSessionId || '') const expandIdea = useExpandIdea(activeSessionId || '')
const dismissIdea = useDismissIdea(activeSessionId || '') const dismissIdea = useDismissIdea(activeSessionId || '')
const convertIdea = useConvertIdea(activeSessionId || '') const convertIdea = useConvertIdea(activeSessionId || '')
const [convertConfirm, setConvertConfirm] = useState<BrainstormIdea | null>(null)
const exportBrainstorm = useExportBrainstorm(activeSessionId || '') const exportBrainstorm = useExportBrainstorm(activeSessionId || '')
const finalizeBrainstorm = useFinalizeBrainstorm(activeSessionId || '') const finalizeBrainstorm = useFinalizeBrainstorm(activeSessionId || '')
const deleteBrainstorm = useDeleteBrainstorm() const deleteBrainstorm = useDeleteBrainstorm()
@@ -132,8 +142,7 @@ export function BrainstormPage() {
const renameSession = useRenameBrainstormSession(activeSessionId || '') const renameSession = useRenameBrainstormSession(activeSessionId || '')
const [impactToast, setImpactToast] = useState<{ notesEnriched: number; notesMarkedDry: number } | null>(null) const [impactToast, setImpactToast] = useState<{ notesEnriched: number; notesMarkedDry: number } | null>(null)
const [exportError, setExportError] = useState<string | null>(null) const [exportError, setExportError] = useState<string | null>(null)
const [exportToast, setExportToast] = useState<{ noteTitle: string; notebookName: string } | null>(null)
const [convertToast, setConvertToast] = useState<{ noteTitle: string; noteId: string } | null>(null)
const [remoteMove, setRemoteMove] = useState<{ ideaId: string; x: number; y: number; _seq: number } | null>(null) const [remoteMove, setRemoteMove] = useState<{ ideaId: string; x: number; y: number; _seq: number } | null>(null)
const [playbackIdeas, setPlaybackIdeas] = useState<any[] | null>(null) const [playbackIdeas, setPlaybackIdeas] = useState<any[] | null>(null)
const [viewMode, setViewMode] = useState<'canvas' | 'list'>('canvas') const [viewMode, setViewMode] = useState<'canvas' | 'list'>('canvas')
@@ -238,15 +247,27 @@ export function BrainstormPage() {
} catch {} } catch {}
} }
const handleConvert = async (idea: BrainstormIdea) => { const handleConvert = async (idea: BrainstormIdea, targetNotebookId: string | null = urlNotebookId) => {
try { try {
const result = await convertIdea.mutateAsync(idea.id) const result = await convertIdea.mutateAsync({ ideaId: idea.id, notebookId: targetNotebookId })
if (result?.id) { if (result?.id) {
setConvertToast({ noteTitle: result.title || idea.title, noteId: result.id }) let nbName = 'carnet'
setTimeout(() => { if (result.notebookId) {
setConvertToast(null) const nb = notebooks.find(n => n.id === result.notebookId)
router.push(`/home?openNote=${result.id}`) nbName = nb?.name || 'carnet'
}, 2000) }
toast.success(t('brainstorm.noteCreatedIn') || 'Note created', {
description: `${result.title || idea.title} → 📁 ${nbName}`,
action: {
label: t('brainstorm.openNotebook') || 'Open notebook',
onClick: () => {
if (result.notebookId) router.push(`/home?notebook=${result.notebookId}`)
else router.push('/home')
},
},
duration: 15000,
})
peek.open(result.id)
} }
} catch {} } catch {}
} }
@@ -287,11 +308,15 @@ export function BrainstormPage() {
const result = await exportBrainstorm.mutateAsync() const result = await exportBrainstorm.mutateAsync()
if (result?.id) { if (result?.id) {
const notebookName = result._notebookName || t('brainstorm.exportDefaultNotebookName') const notebookName = result._notebookName || t('brainstorm.exportDefaultNotebookName')
setExportToast({ noteTitle: result.title || t('brainstorm.exportDefaultNoteTitle'), notebookName }) toast.success(t('brainstorm.noteCreated') || 'Note created', {
setTimeout(() => { description: `${result.title || t('brainstorm.exportDefaultNoteTitle')}${notebookName}`,
setExportToast(null) action: {
router.push(`/home?openNote=${result.id}`) label: t('notePeek.openFully') || 'Open',
}, 2000) onClick: () => router.push(`/home?openNote=${result.id}`),
},
duration: 10000,
})
peek.open(result.id)
return return
} }
const impact = await finalizeBrainstorm.mutateAsync() const impact = await finalizeBrainstorm.mutateAsync()
@@ -729,9 +754,13 @@ export function BrainstormPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedIdea.status === 'converted' && ( {selectedIdea.status === 'converted' && (
<span className="text-[10px] font-bold text-emerald-500 uppercase tracking-widest bg-emerald-500/10 px-2 py-1 rounded-full"> <button
{t('brainstorm.noteCreated') || 'Note Created'} onClick={() => selectedIdea.convertedToNoteId && peek.open(selectedIdea.convertedToNoteId)}
</span> className="flex items-center gap-1.5 text-[10px] font-bold text-emerald-600 dark:text-emerald-400 uppercase tracking-widest bg-emerald-500/10 hover:bg-emerald-500/20 px-3 py-1.5 rounded-full transition-colors cursor-pointer"
>
<FileText size={11} />
{t('brainstorm.noteCreated') || 'Note Created'}
</button>
)} )}
{canEdit && ( {canEdit && (
<button <button
@@ -846,7 +875,7 @@ export function BrainstormPage() {
</div> </div>
{ref.noteId && ( {ref.noteId && (
<button <button
onClick={() => router.push(`/home?openNote=${ref.noteId}`)} onClick={() => ref.noteId && peek.open(ref.noteId)}
className="shrink-0 px-2 py-1 text-[9px] font-bold uppercase tracking-wider rounded-lg bg-foreground/5 hover:bg-foreground/10 text-muted-foreground hover:text-foreground transition-all" className="shrink-0 px-2 py-1 text-[9px] font-bold uppercase tracking-wider rounded-lg bg-foreground/5 hover:bg-foreground/10 text-muted-foreground hover:text-foreground transition-all"
> >
{t('brainstorm.viewNote') || 'View'} {t('brainstorm.viewNote') || 'View'}
@@ -872,9 +901,20 @@ export function BrainstormPage() {
{expandIdea.isPending ? (t('brainstorm.deepening') || 'Generating...') : (t('brainstorm.deepen') || 'Deepen')} {expandIdea.isPending ? (t('brainstorm.deepening') || 'Generating...') : (t('brainstorm.deepen') || 'Deepen')}
</span> </span>
</button> </button>
{selectedIdea.status === 'converted' && selectedIdea.convertedToNoteId ? (
<button <button
onClick={() => handleConvert(selectedIdea)} onClick={() => selectedIdea.convertedToNoteId && peek.open(selectedIdea.convertedToNoteId)}
disabled={selectedIdea.status === 'converted' || convertIdea.isPending} className="flex flex-col items-center justify-center p-6 border-2 border-emerald-400/40 bg-emerald-500/5 rounded-2xl hover:bg-emerald-500/10 transition-all group"
>
<FileText size={24} className="text-emerald-500 mb-2" />
<span className="text-[11px] font-bold uppercase tracking-widest text-emerald-600 dark:text-emerald-400">
{t('brainstorm.openNote') || 'Open Note'}
</span>
</button>
) : (
<button
onClick={() => setConvertConfirm(selectedIdea)}
disabled={convertIdea.isPending}
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-emerald-400/40 hover:bg-emerald-500/5 transition-all group disabled:opacity-50" className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-emerald-400/40 hover:bg-emerald-500/5 transition-all group disabled:opacity-50"
> >
<FileText size={24} className="text-muted-foreground group-hover:text-emerald-500 mb-2" /> <FileText size={24} className="text-muted-foreground group-hover:text-emerald-500 mb-2" />
@@ -882,6 +922,7 @@ export function BrainstormPage() {
{convertIdea.isPending ? (t('brainstorm.converting') || 'Converting...') : (t('brainstorm.extract') || 'Create Note')} {convertIdea.isPending ? (t('brainstorm.converting') || 'Converting...') : (t('brainstorm.extract') || 'Create Note')}
</span> </span>
</button> </button>
)}
</div> </div>
<button <button
@@ -1124,49 +1165,29 @@ export function BrainstormPage() {
)} )}
</AnimatePresence> </AnimatePresence>
<AnimatePresence> {/* Peek panel — drawer latéral pour aperçu note sans quitter le brainstorm */}
{exportToast && ( <NotePeekPanel
<motion.div note={peek.note}
initial={{ y: 100, opacity: 0 }} blockId={peek.blockId}
animate={{ y: 0, opacity: 1 }} loading={peek.loading}
exit={{ y: 100, opacity: 0 }} mode="overlay"
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-emerald-600 text-white rounded-2xl shadow-2xl flex items-center gap-3 text-sm font-medium" onClose={peek.close}
> onOpenFully={(n) => { router.push(`/home?openNote=${n.id}`); peek.close() }}
<Wind size={18} /> />
<div className="flex flex-col">
<span className="font-bold">{exportToast.noteTitle}</span>
<span className="text-[11px] text-emerald-100">
{t('brainstorm.exportNotebookPrefix')} {exportToast.notebookName}
</span>
</div>
<div className="ml-3 flex items-center gap-1.5 text-emerald-200 text-[10px]">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-300 animate-pulse" />
{t('brainstorm.exportOpening')}
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence> {/* Dialog de sélection de carnet pour la conversion */}
{convertToast && ( <ConvertNotebookDialog
<motion.div open={convertConfirm !== null}
initial={{ y: 100, opacity: 0 }} idea={convertConfirm}
animate={{ y: 0, opacity: 1 }} notebooks={notebooks}
exit={{ y: 100, opacity: 0 }} defaultNotebookId={urlNotebookId}
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-emerald-600 text-white rounded-2xl shadow-2xl flex items-center gap-3 text-sm font-medium" onClose={() => setConvertConfirm(null)}
> onConfirm={(notebookId) => {
<FileText size={18} /> if (!convertConfirm) return
<div className="flex flex-col"> handleConvert(convertConfirm, notebookId)
<span className="font-bold">{convertToast.noteTitle}</span> setConvertConfirm(null)
<span className="text-[11px] text-emerald-100">{t('brainstorm.noteCreated') || 'Note Created'}</span> }}
</div> />
<div className="ml-3 flex items-center gap-1.5 text-emerald-200 text-[10px]">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-300 animate-pulse" />
{t('brainstorm.exportOpening')}
</div>
</motion.div>
)}
</AnimatePresence>
</div> </div>
) )
} }

View File

@@ -0,0 +1,157 @@
'use client'
import { useState } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { FileText, FolderOpen, ChevronDown, Loader2, X } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import type { Notebook } from '@/lib/types'
interface ConvertNotebookDialogProps {
open: boolean
idea: { id: string; title: string } | null
notebooks: Notebook[]
defaultNotebookId: string | null | undefined
onClose: () => void
onConfirm: (notebookId: string | null) => void
}
export function ConvertNotebookDialog({ open, idea, notebooks, defaultNotebookId, onClose, onConfirm }: ConvertNotebookDialogProps) {
const { t } = useLanguage()
const [showNotebookSelect, setShowNotebookSelect] = useState(false)
const [selectedNotebookId, setSelectedNotebookId] = useState<string | null>(defaultNotebookId ?? null)
const selectedNotebook = notebooks.find(nb => nb.id === selectedNotebookId)
const [loading, setLoading] = useState(false)
const handleConfirm = () => {
setLoading(true)
onConfirm(selectedNotebookId)
}
return (
<AnimatePresence>
{open && idea && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-[90] flex items-center justify-center bg-black/40 backdrop-blur-sm p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
role="dialog"
aria-modal="true"
aria-label={t('brainstorm.convertTitle') || 'Create note from idea'}
>
<motion.div
initial={{ scale: 0.92, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.92, y: 20 }}
transition={{ type: 'spring', stiffness: 340, damping: 30 }}
className="bg-memento-paper dark:bg-zinc-900 rounded-3xl shadow-2xl border border-border/40 w-full max-w-md overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="px-6 py-5 border-b border-border/20 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-emerald-500/10 flex items-center justify-center">
<FileText size={16} className="text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<h3 className="font-memento-serif text-base font-medium text-ink dark:text-dark-ink">
{t('brainstorm.convertTitle') || 'Create note from idea'}
</h3>
<p className="text-[10px] text-concrete uppercase tracking-widest font-bold">
{t('brainstorm.convertSubtitle') || 'Choose where to save this note'}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-concrete hover:text-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer"
aria-label={t('common.close') || 'Close'}
>
<X size={16} />
</button>
</div>
<div className="px-6 py-5 space-y-4">
<div className="p-4 rounded-2xl bg-white/60 dark:bg-white/5 border border-border/30">
<p className="text-[10px] uppercase tracking-widest font-bold text-concrete mb-2">
{t('brainstorm.ideaLabel') || 'Idea'}
</p>
<p className="text-sm font-memento-serif text-ink dark:text-dark-ink leading-relaxed line-clamp-3">
{idea.title}
</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-widest font-bold text-concrete mb-2">
{t('brainstorm.destinationLabel') || 'Notes will be saved to'}
</p>
<button
onClick={() => setShowNotebookSelect(!showNotebookSelect)}
className="w-full flex items-center justify-between p-3 rounded-xl bg-white/60 dark:bg-white/5 border border-border/30 hover:border-emerald-500/40 transition-colors cursor-pointer"
>
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-emerald-500/10 flex items-center justify-center shrink-0">
<FolderOpen size={13} className="text-emerald-600" />
</div>
<span className="text-sm font-medium text-ink dark:text-dark-ink truncate">
{selectedNotebook?.name || t('brainstorm.noNotebook') || 'No notebook'}
</span>
</div>
<ChevronDown size={14} className={`text-concrete transition-transform ${showNotebookSelect ? 'rotate-180' : ''}`} />
</button>
<AnimatePresence>
{showNotebookSelect && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-1 max-h-48 overflow-y-auto rounded-xl border border-border/30 bg-white dark:bg-zinc-800 shadow-lg">
<button
onClick={() => { setSelectedNotebookId(null); setShowNotebookSelect(false) }}
className="w-full text-left px-3 py-2.5 hover:bg-emerald-500/5 transition-colors text-sm text-concrete cursor-pointer"
>
{t('brainstorm.noNotebook') || 'No notebook'}
</button>
{notebooks.map(nb => (
<button
key={nb.id}
onClick={() => { setSelectedNotebookId(nb.id); setShowNotebookSelect(false) }}
className={`w-full text-left px-3 py-2.5 hover:bg-emerald-500/5 transition-colors text-sm cursor-pointer flex items-center gap-2 ${selectedNotebookId === nb.id ? 'text-emerald-600 font-medium' : 'text-ink dark:text-dark-ink'}`}
>
<FolderOpen size={12} className="shrink-0 opacity-50" />
<span className="truncate">{nb.name}</span>
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div className="px-6 py-4 border-t border-border/20 flex items-center justify-end gap-3">
<button
onClick={onClose}
disabled={loading}
className="px-4 py-2.5 rounded-xl text-sm font-medium text-concrete hover:text-ink hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer"
>
{t('common.cancel')}
</button>
<button
onClick={handleConfirm}
disabled={loading}
className="px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-bold uppercase tracking-wider transition-colors cursor-pointer disabled:opacity-50 flex items-center gap-2"
>
{loading ? <><Loader2 size={14} className="animate-spin" /> {t('common.creating') || 'Creating…'}</> : <><FileText size={14} /> {t('brainstorm.createNote') || 'Create note'}</>}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -2,7 +2,7 @@
import { type ReactNode } from 'react' import { type ReactNode } from 'react'
import { motion, AnimatePresence, useReducedMotion } from 'motion/react' import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
import { X, Maximize2, Loader2 } from 'lucide-react' import { X, Maximize2, Loader2, FolderOpen } from 'lucide-react'
import { useLanguage } from '@/lib/i18n' import { useLanguage } from '@/lib/i18n'
import type { Note } from '@/lib/types' import type { Note } from '@/lib/types'
import { NotePeekContent } from './note-peek-content' import { NotePeekContent } from './note-peek-content'
@@ -77,6 +77,15 @@ export function NotePeekPanel({
renderContent(note, blockId) renderContent(note, blockId)
) : ( ) : (
<> <>
{note.notebookId && (
<a
href={`/home?notebook=${note.notebookId}`}
className="inline-flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest text-brand-accent hover:text-brand-accent/70 transition-colors mb-2 cursor-pointer"
>
<FolderOpen size={11} />
{t('brainstorm.openNotebook') || 'Open notebook'}
</a>
)}
<h2 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-6"> <h2 className="text-xl font-serif font-medium text-ink dark:text-dark-ink mb-6">
{note.title || t('notePeek.untitled') || 'Untitled'} {note.title || t('notePeek.untitled') || 'Untitled'}
</h2> </h2>

View File

@@ -50,6 +50,8 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useHydrated } from '@/lib/use-hydrated' import { useHydrated } from '@/lib/use-hydrated'
import dynamic from 'next/dynamic'
const BrainstormConfirmDialog = dynamic(() => import('@/components/brainstorm/brainstorm-confirm-dialog').then(m => ({ default: m.BrainstormConfirmDialog })), { ssr: false })
import { formatDistanceToNow } from 'date-fns' import { formatDistanceToNow } from 'date-fns'
import { fr } from 'date-fns/locale/fr' import { fr } from 'date-fns/locale/fr'
import { enUS } from 'date-fns/locale/en-US' import { enUS } from 'date-fns/locale/en-US'
@@ -715,14 +717,23 @@ const GridCard = memo(function GridCard({
aiIllustrationEnabled, aiIllustrationEnabled,
onOpen, onOpen,
onTogglePin, onTogglePin,
onDeleteNote, onDeleteNote, onMoveToNotebook,
onMoveToNotebook,
onNoteIllustrationGenerated, onNoteIllustrationGenerated,
onNoteIllustrationDeleted, onNoteIllustrationDeleted,
onOpenHistory, onOpenHistory,
isOverlay = false, isOverlay = false,
}: GridCardSharedProps) { }: GridCardSharedProps) {
const router = useRouter() const router = useRouter()
const [brainstormNote, setBrainstormNote] = useState<Note | null>(null)
const handleBrainstormConfirm = (notebookId: string | null) => {
if (!brainstormNote) return
const seed = `${brainstormNote.title || untitled}\n\n${getNotePlainExcerpt(brainstormNote, 200)}`.trim()
const params = new URLSearchParams({ seed: seed.slice(0, 300), sourceNoteId: brainstormNote.id })
if (notebookId) params.set('notebookId', notebookId)
router.push(`/brainstorm?${params.toString()}`)
setBrainstormNote(null)
}
const { t, language } = useLanguage() const { t, language } = useLanguage()
const hydrated = useHydrated() const hydrated = useHydrated()
const title = getNoteDisplayTitle(note, untitled) const title = getNoteDisplayTitle(note, untitled)
@@ -741,11 +752,11 @@ const GridCard = memo(function GridCard({
const handleBrainstormClick = (e: React.MouseEvent) => { const handleBrainstormClick = (e: React.MouseEvent) => {
e.stopPropagation() e.stopPropagation()
const seed = `${title}\n\n${excerpt}`.trim() || title setBrainstormNote(note)
router.push(`/brainstorm?seed=${encodeURIComponent(seed.slice(0, 300))}&sourceNoteId=${note.id}`)
} }
return ( return (
<>
<motion.div <motion.div
initial={isOverlay || !hydrated ? false : { opacity: 0, y: 15 }} initial={isOverlay || !hydrated ? false : { opacity: 0, y: 15 }}
animate={isOverlay ? undefined : { opacity: 1, y: 0 }} animate={isOverlay ? undefined : { opacity: 1, y: 0 }}
@@ -851,5 +862,13 @@ const GridCard = memo(function GridCard({
</div> </div>
</div> </div>
</motion.div> </motion.div>
<BrainstormConfirmDialog
open={brainstormNote !== null}
onClose={() => setBrainstormNote(null)}
onConfirm={(notebookId) => { if (brainstormNote) handleBrainstormConfirm(notebookId) }}
note={brainstormNote}
/>
</>
) )
}) })

View File

@@ -0,0 +1,19 @@
'use client'
import { type ReactNode } from 'react'
import { useSidebarResize, SidebarResizeHandle } from '@/components/sidebar-resize'
export function SidebarResizeClient({ children }: { children: ReactNode }) {
const { width, isDragging, onPointerDown } = useSidebarResize()
return (
<div className="flex h-screen overflow-hidden bg-memento-desk dark:bg-background">
<div className="contents" style={{ ['--sidebar-width' as string]: `${width}px` }}>
<div className="flex h-full" style={{ width: `${width}px` }}>
{children}
</div>
</div>
<SidebarResizeHandle onPointerDown={onPointerDown} isDragging={isDragging} />
</div>
)
}

View File

@@ -0,0 +1,81 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
const STORAGE_KEY = 'memento-sidebar-width'
const MIN = 280
const MAX = 560
export function useSidebarResize() {
const [width, setWidth] = useState(320)
const [isDragging, setIsDragging] = useState(false)
const startXRef = useRef(0)
const startWidthRef = useRef(320)
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) {
const parsed = parseInt(stored, 10)
if (!isNaN(parsed) && parsed >= MIN && parsed <= MAX) {
setWidth(parsed)
}
}
}, [])
const onPointerDown = useCallback((e: React.PointerEvent) => {
e.preventDefault()
setIsDragging(true)
startXRef.current = e.clientX
startWidthRef.current = width
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
}, [width])
useEffect(() => {
if (!isDragging) return
const onPointerMove = (e: PointerEvent) => {
const delta = e.clientX - startXRef.current
const newWidth = Math.max(MIN, Math.min(MAX, startWidthRef.current + delta))
setWidth(newWidth)
}
const onPointerUp = () => {
setIsDragging(false)
document.body.style.cursor = ''
document.body.style.userSelect = ''
setWidth(prev => {
localStorage.setItem(STORAGE_KEY, String(prev))
return prev
})
}
document.addEventListener('pointermove', onPointerMove)
document.addEventListener('pointerup', onPointerUp)
return () => {
document.removeEventListener('pointermove', onPointerMove)
document.removeEventListener('pointerup', onPointerUp)
}
}, [isDragging])
return { width, isDragging, onPointerDown }
}
export function SidebarResizeHandle({ onPointerDown, isDragging }: { onPointerDown: (e: React.PointerEvent) => void; isDragging: boolean }) {
return (
<div
onPointerDown={onPointerDown}
className={`hidden md:flex items-center justify-center w-1 shrink-0 cursor-col-resize transition-colors z-50 ${
isDragging
? 'bg-brand-accent w-[3px]'
: 'bg-border/40 hover:bg-brand-accent/50 hover:w-[3px]'
}`}
style={{ touchAction: 'none' }}
aria-hidden="true"
>
<div className={`w-[2px] h-12 rounded-full transition-colors ${isDragging ? 'bg-brand-accent' : 'bg-border/60'}`} />
</div>
)
}
export { MIN as SIDEBAR_MIN_WIDTH, MAX as SIDEBAR_MAX_WIDTH }

View File

@@ -154,12 +154,14 @@ export function useConvertIdea(sessionId: string) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async (ideaId: string) => { mutationFn: async (args: string | { ideaId: string; notebookId?: string | null }) => {
const ideaId = typeof args === 'string' ? args : args.ideaId
const notebookId = typeof args === 'string' ? null : args.notebookId
const res = await fetch(`/api/brainstorm/${sessionId}/convert`, { const res = await fetch(`/api/brainstorm/${sessionId}/convert`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ ideaId }), body: JSON.stringify({ ideaId, notebookId }),
}) })
const data = await res.json() const data = await res.json()
if (!data.success) throw new Error(data.error || 'Failed to convert idea') if (!data.success) throw new Error(data.error || 'Failed to convert idea')

View File

@@ -2925,7 +2925,7 @@
"noSessions": "No brainstorms yet", "noSessions": "No brainstorms yet",
"startOne": "Start one", "startOne": "Start one",
"sessions": "Brainstorms", "sessions": "Brainstorms",
"seedLabel": "Seed Idea", "seedLabel": "Seed idea",
"ideaPromptDetailed": "Enter your idea, question, or topic to brainstorm...", "ideaPromptDetailed": "Enter your idea, question, or topic to brainstorm...",
"brainstormThisIdea": "Brainstorm this idea", "brainstormThisIdea": "Brainstorm this idea",
"startBrainstorm": "Start Brainstorm", "startBrainstorm": "Start Brainstorm",
@@ -3068,7 +3068,19 @@
"legendWave1": "Variations", "legendWave1": "Variations",
"legendWave2": "Analogies", "legendWave2": "Analogies",
"legendWave3": "Disruptions", "legendWave3": "Disruptions",
"legendConverted": "Converted" "legendConverted": "Converted",
"confirmTitle": "Brainstorm this idea",
"confirmSubtitle": "AI will expand your idea",
"destinationLabel": "Notes will be saved to",
"defaultNotebook": "Brainstorm",
"noNotebook": "No notebook",
"launching": "Launching…",
"launch": "Brainstorm",
"noteCreatedIn": "Note created in",
"openNotebook": "Open notebook",
"convertTitle": "Create note from idea",
"convertSubtitle": "Choose where to save this note",
"ideaLabel": "Idea"
}, },
"byokSettings": { "byokSettings": {
"title": "Your API keys (BYOK)", "title": "Your API keys (BYOK)",

View File

@@ -3072,7 +3072,19 @@
"legendWave1": "Variations", "legendWave1": "Variations",
"legendWave2": "Analogies", "legendWave2": "Analogies",
"legendWave3": "Disruptions", "legendWave3": "Disruptions",
"legendConverted": "Convertie" "legendConverted": "Convertie",
"confirmTitle": "Brainstormer cette idée",
"confirmSubtitle": "L'IA va développer votre idée",
"destinationLabel": "Les notes seront sauvegardées dans",
"defaultNotebook": "Brainstorm",
"noNotebook": "Aucun carnet",
"launching": "Lancement…",
"launch": "Brainstormer",
"noteCreatedIn": "Note créée dans",
"openNotebook": "Ouvrir le carnet",
"convertTitle": "Créer une note depuis cette idée",
"convertSubtitle": "Choisir où sauvegarder cette note",
"ideaLabel": "Idée"
}, },
"byokSettings": { "byokSettings": {
"title": "Vos clés API (BYOK)", "title": "Vos clés API (BYOK)",