Compare commits
11 Commits
eb6cda9d24
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea326a10b4 | ||
|
|
f97c5e8ec4 | ||
|
|
4d029d3ebe | ||
|
|
5eba211061 | ||
|
|
f44a0f9c68 | ||
|
|
a99d1dafb2 | ||
|
|
5aa7202abe | ||
|
|
02b835fb13 | ||
|
|
d1336b1fea | ||
|
|
6e13b6d207 | ||
|
|
4d95234e32 |
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
49
.github/workflows/ci.yaml
vendored
49
.github/workflows/ci.yaml
vendored
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
181
memento-note/components/brainstorm/brainstorm-confirm-dialog.tsx
Normal file
181
memento-note/components/brainstorm/brainstorm-confirm-dialog.tsx
Normal 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)
|
||||||
@@ -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,16 +901,28 @@ 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>
|
||||||
<button
|
{selectedIdea.status === 'converted' && selectedIdea.convertedToNoteId ? (
|
||||||
onClick={() => handleConvert(selectedIdea)}
|
<button
|
||||||
disabled={selectedIdea.status === 'converted' || convertIdea.isPending}
|
onClick={() => selectedIdea.convertedToNoteId && peek.open(selectedIdea.convertedToNoteId)}
|
||||||
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-emerald-400/40 bg-emerald-500/5 rounded-2xl hover:bg-emerald-500/10 transition-all group"
|
||||||
>
|
>
|
||||||
<FileText size={24} className="text-muted-foreground group-hover:text-emerald-500 mb-2" />
|
<FileText size={24} className="text-emerald-500 mb-2" />
|
||||||
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground group-hover:text-foreground">
|
<span className="text-[11px] font-bold uppercase tracking-widest text-emerald-600 dark:text-emerald-400">
|
||||||
{convertIdea.isPending ? (t('brainstorm.converting') || 'Converting...') : (t('brainstorm.extract') || 'Create Note')}
|
{t('brainstorm.openNote') || 'Open Note'} →
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</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"
|
||||||
|
>
|
||||||
|
<FileText size={24} className="text-muted-foreground group-hover:text-emerald-500 mb-2" />
|
||||||
|
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground group-hover:text-foreground">
|
||||||
|
{convertIdea.isPending ? (t('brainstorm.converting') || 'Converting...') : (t('brainstorm.extract') || 'Create Note')}
|
||||||
|
</span>
|
||||||
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
157
memento-note/components/brainstorm/convert-notebook-dialog.tsx
Normal file
157
memento-note/components/brainstorm/convert-notebook-dialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -619,6 +619,11 @@ export function HomeClient({
|
|||||||
if (detail.type === 'updated') {
|
if (detail.type === 'updated') {
|
||||||
patchNoteInList(detail.note.id, detail.note)
|
patchNoteInList(detail.note.id, detail.note)
|
||||||
setPinnedNotes(prev => prev.map(n => n.id === detail.note.id ? { ...n, ...detail.note } : n))
|
setPinnedNotes(prev => prev.map(n => n.id === detail.note.id ? { ...n, ...detail.note } : n))
|
||||||
|
} else if (detail.type === 'deleted') {
|
||||||
|
removeNoteFromList(detail.noteId)
|
||||||
|
setPinnedNotes(prev => prev.filter(n => n.id !== detail.noteId))
|
||||||
|
} else if (detail.type === 'created' && detail.note) {
|
||||||
|
setNotes(prev => [detail.note, ...prev])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
|
window.addEventListener(NOTE_CHANGE_EVENT, onNoteChange)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
348
memento-note/components/sidebar-panels.tsx
Normal file
348
memento-note/components/sidebar-panels.tsx
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useLanguage } from '@/lib/i18n'
|
||||||
|
import React, { useEffect, useState } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'motion/react'
|
||||||
|
import { getAllNotes, getNotesWithReminders, toggleReminderDone } from '@/app/actions/notes'
|
||||||
|
import { useBrainstormSessions, useDeleteBrainstorm } from '@/hooks/use-brainstorm'
|
||||||
|
import {
|
||||||
|
ChevronRight, Lock, Plus, Pencil, Trash2, Sparkles, Clock,
|
||||||
|
Pin, PinOff, GripVertical, FileText, Folder, FolderOpen,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
export function NoteLink({
|
||||||
|
title, isActive, isPinned, onClick,
|
||||||
|
}: {
|
||||||
|
title: string; isActive: boolean; isPinned?: boolean; onClick: () => void
|
||||||
|
}) {
|
||||||
|
const { language } = useLanguage()
|
||||||
|
const slideX = language === 'fa' || language === 'ar' ? 10 : -10
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
initial={{ opacity: 0, x: slideX }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-center gap-2 ps-6 pe-3 py-1.5 text-[11px] transition-all rounded-lg text-start',
|
||||||
|
isActive
|
||||||
|
? 'bg-white dark:bg-white/10 shadow-sm border border-border/50 text-foreground font-semibold'
|
||||||
|
: 'text-muted-foreground hover:text-foreground hover:bg-white/30 dark:hover:bg-white/5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FileText size={12} className={cn('shrink-0', isActive ? 'text-brand-accent' : 'text-muted-foreground/70')} />
|
||||||
|
<span className="truncate flex-1">{title}</span>
|
||||||
|
{isPinned && <Pin size={10} className="text-amber-500 fill-amber-500 shrink-0" />}
|
||||||
|
</motion.button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarBrainstorms() {
|
||||||
|
const { data: sessions, isLoading } = useBrainstormSessions()
|
||||||
|
const deleteBrainstorm = useDeleteBrainstorm()
|
||||||
|
const router = useRouter()
|
||||||
|
const { t } = useLanguage()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 space-y-2">
|
||||||
|
{[1, 2, 3].map(i => <div key={i} className="h-12 rounded-xl bg-paper/50 animate-pulse" />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessions || sessions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-6 text-center">
|
||||||
|
<Sparkles size={20} className="mx-auto text-brand-accent/40 mb-2" />
|
||||||
|
<p className="text-[11px] text-concrete">{t('brainstorm.noSessions')}</p>
|
||||||
|
<button onClick={() => router.push('/brainstorm')} className="mt-2 text-[11px] text-brand-accent hover:text-brand-accent/80 font-medium">
|
||||||
|
{t('brainstorm.startOne')} →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{sessions.slice(0, 10).map(s => (
|
||||||
|
<div key={s.id} className="relative group/item">
|
||||||
|
<button
|
||||||
|
onClick={() => router.replace(`/brainstorm?session=${s.id}`)}
|
||||||
|
className={`w-full flex items-center gap-3 px-4 py-2.5 rounded-xl transition-all duration-200 text-start hover:bg-brand-accent/5 group ${
|
||||||
|
(s as any)._owned === false ? 'border-s-2 border-brand-accent/30 dark:border-brand-accent/70' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`w-7 h-7 rounded-full flex items-center justify-center shrink-0 ${
|
||||||
|
(s as any)._owned === false
|
||||||
|
? 'border border-brand-accent/20 dark:border-brand-accent/80 bg-brand-accent/5 dark:bg-brand-accent/20'
|
||||||
|
: 'border border-brand-accent/20 dark:border-brand-accent/40 bg-brand-accent/5 dark:bg-brand-accent/20'
|
||||||
|
}`}>
|
||||||
|
<Sparkles size={12} className="text-brand-accent" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[12px] font-medium truncate">
|
||||||
|
{s.seedIdea}
|
||||||
|
{(s as any)._owned === false && (
|
||||||
|
<span className="text-[9px] ms-1.5 text-brand-accent/70 font-normal">{t('sidebar.sharedNotebookBadge')}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{s.activeIdeas} {t('brainstorm.ideas')} · {new Date(s.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{(s as any)._owned !== false && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); deleteBrainstorm.mutate(s.id) }}
|
||||||
|
className="absolute end-2 top-1/2 -translate-y-1/2 p-1.5 rounded-lg opacity-0 group-hover/item:opacity-100 hover:bg-rose-500/10 text-muted-foreground hover:text-rose-500 transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarReminders({ onOpenNote }: { onOpenNote: (noteId: string, notebookId: string | null) => void }) {
|
||||||
|
const { t } = useLanguage()
|
||||||
|
const [reminders, setReminders] = useState<
|
||||||
|
{ id: string; title: string | null; reminder: Date | string | null; isReminderDone: boolean; notebookId: string | null }[]
|
||||||
|
>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [togglingId, setTogglingId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const reload = () => { getNotesWithReminders().then((rows) => setReminders(rows as typeof reminders)) }
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
getNotesWithReminders().then((rows) => { if (!cancelled) setReminders(rows as typeof reminders) }).finally(() => { if (!cancelled) setLoading(false) })
|
||||||
|
return () => { cancelled = true }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleComplete = async (e: React.MouseEvent, noteId: string) => {
|
||||||
|
e.stopPropagation(); setTogglingId(noteId)
|
||||||
|
await toggleReminderDone(noteId, true)
|
||||||
|
setReminders(prev => prev.map(r => r.id === noteId ? { ...r, isReminderDone: true } : r))
|
||||||
|
setTogglingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSnooze = async (e: React.MouseEvent, noteId: string) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
const snoozeDate = new Date(Date.now() + 60 * 60 * 1000)
|
||||||
|
setTogglingId(noteId)
|
||||||
|
try {
|
||||||
|
await fetch(`/api/notes/${noteId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reminder: snoozeDate.toISOString(), isReminderDone: false }) })
|
||||||
|
setReminders(prev => prev.map(r => r.id === noteId ? { ...r, reminder: snoozeDate, isReminderDone: false } : r))
|
||||||
|
} catch (e) { console.error('[silent-catch]', e); reload() }
|
||||||
|
setTogglingId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 space-y-2">
|
||||||
|
{[1, 2, 3].map((i) => <div key={i} className="h-10 rounded-xl bg-paper/50 animate-pulse" />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const active = reminders.filter((r) => !r.isReminderDone && r.reminder)
|
||||||
|
const overdue = active.filter((r) => new Date(r.reminder!) < now)
|
||||||
|
const upcoming = active.filter((r) => new Date(r.reminder!) >= now)
|
||||||
|
|
||||||
|
if (active.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30 mx-4">
|
||||||
|
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
|
||||||
|
<p className="text-[11px] text-concrete italic">{t('reminders.emptyDescription')}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderItem = (note: (typeof active)[0], overdueItem?: boolean) => (
|
||||||
|
<div key={note.id} className="group flex items-center gap-1 px-2 py-1.5 rounded-xl hover:bg-brand-accent/5 transition-colors">
|
||||||
|
<button type="button" onClick={(e) => { void handleComplete(e, note.id) }} disabled={togglingId === note.id}
|
||||||
|
className="flex-shrink-0 w-5 h-5 rounded-full border-2 border-concrete/30 hover:border-emerald-500 hover:bg-emerald-50 transition-all flex items-center justify-center"
|
||||||
|
title={t('reminders.markDone') || 'Marquer comme fait'}>
|
||||||
|
{togglingId === note.id ? <div className="w-2 h-2 rounded-full bg-concrete animate-pulse" /> : null}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => onOpenNote(note.id, note.notebookId)} className="flex-1 text-start min-w-0">
|
||||||
|
<p className="text-[12px] font-medium truncate group-hover:text-brand-accent transition-colors">{note.title || t('notes.untitled')}</p>
|
||||||
|
<p className={cn('text-[10px] mt-0.5', overdueItem ? 'text-red-500' : 'text-muted-foreground')}>
|
||||||
|
{note.reminder && new Date(note.reminder).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<div className="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex gap-0.5">
|
||||||
|
<button type="button" onClick={(e) => { void handleSnooze(e, note.id) }} disabled={togglingId === note.id}
|
||||||
|
className="p-1 rounded-md hover:bg-amber-100 text-amber-600 transition-colors" title={t('reminders.snooze1h') || 'Reporter de 1h'}>
|
||||||
|
<span className="text-[9px] font-bold">+1h</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 pb-2">
|
||||||
|
{overdue.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[9px] font-bold uppercase tracking-widest text-red-500 px-4 mb-1">{t('reminders.overdue')}</p>
|
||||||
|
<div className="space-y-0.5">{overdue.map((n) => renderItem(n, true))}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{upcoming.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground px-4 mb-1">{t('reminders.upcoming')}</p>
|
||||||
|
<div className="space-y-0.5">{upcoming.map((n) => renderItem(n))}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarCarnetItem({
|
||||||
|
carnet, isActive, notes, activeNoteId, onCarnetClick, onNoteClick, onAddSubNotebook, onRename, onDelete, onTogglePin, isPinned,
|
||||||
|
children, isDragging, dragHandleProps, level, isExpanded, toggleExpand, hasChildNotebooks, hasActiveDescendant,
|
||||||
|
}: {
|
||||||
|
carnet: { id: string; name: string; initial: string; isPrivate?: boolean }
|
||||||
|
isActive: boolean
|
||||||
|
notes: { id: string; title: string; isPinned?: boolean }[]
|
||||||
|
activeNoteId: string | null
|
||||||
|
onCarnetClick: () => void
|
||||||
|
onNoteClick: (noteId: string, carnetId: string) => void
|
||||||
|
onAddSubNotebook: () => void
|
||||||
|
onRename: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
onTogglePin: () => void
|
||||||
|
isPinned: boolean
|
||||||
|
children?: React.ReactNode
|
||||||
|
isDragging?: boolean
|
||||||
|
dragHandleProps?: React.HTMLAttributes<HTMLDivElement>
|
||||||
|
level: number
|
||||||
|
isExpanded: boolean
|
||||||
|
toggleExpand: () => void
|
||||||
|
hasChildNotebooks?: boolean
|
||||||
|
hasActiveDescendant?: boolean
|
||||||
|
}) {
|
||||||
|
const { t, language } = useLanguage()
|
||||||
|
const isRtl = language === 'fa' || language === 'ar'
|
||||||
|
const hasChildren = hasChildNotebooks || React.Children.count(children) > 0 || notes.length > 0
|
||||||
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contextMenu) return
|
||||||
|
const handler = () => setContextMenu(null)
|
||||||
|
window.addEventListener('click', handler)
|
||||||
|
return () => window.removeEventListener('click', handler)
|
||||||
|
}, [contextMenu])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('transition-opacity', isDragging && 'opacity-40')}>
|
||||||
|
<div
|
||||||
|
className="flex items-center group relative h-10"
|
||||||
|
style={{ paddingInlineStart: `${level * 24 + 8}px` }}
|
||||||
|
onContextMenu={(e) => { e.preventDefault(); e.stopPropagation(); setContextMenu({ x: e.clientX, y: e.clientY }) }}
|
||||||
|
>
|
||||||
|
{level > 0 && <div className="absolute start-[8px] top-[-10px] bottom-1/2 w-px bg-border/40" />}
|
||||||
|
{level > 0 && <div className="absolute start-[8px] top-1/2 w-[8px] h-px bg-border/40" />}
|
||||||
|
<div className="flex-1 flex items-center gap-0.5">
|
||||||
|
<div {...dragHandleProps}
|
||||||
|
className="shrink-0 w-5 h-5 flex items-center justify-center rounded text-muted-foreground/0 group-hover:text-muted-foreground/50 hover:!text-muted-foreground cursor-grab active:cursor-grabbing transition-colors"
|
||||||
|
onClick={(e) => e.stopPropagation()}>
|
||||||
|
<GripVertical size={11} />
|
||||||
|
</div>
|
||||||
|
{hasChildren ? (
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); toggleExpand() }}
|
||||||
|
className="shrink-0 w-5 h-5 flex items-center justify-center hover:bg-foreground/5 rounded-md transition-colors text-muted-foreground">
|
||||||
|
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
|
||||||
|
<ChevronRight size={13} className="rtl:scale-x-[-1]" />
|
||||||
|
</motion.div>
|
||||||
|
</button>
|
||||||
|
) : <div className="w-5" />}
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ x: isRtl ? -2 : 2 }}
|
||||||
|
onClick={onCarnetClick}
|
||||||
|
onDoubleClick={(e) => { e.stopPropagation(); onRename() }}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative overflow-hidden',
|
||||||
|
isActive ? 'bg-white dark:bg-white/10 shadow-sm border border-border/40' : 'hover:bg-white/40 dark:hover:bg-white/5'
|
||||||
|
)}>
|
||||||
|
{isActive && <motion.div layoutId="active-indicator" className="absolute -start-1 w-1 h-4 bg-brand-accent rounded-full" transition={{ type: 'spring', stiffness: 300, damping: 30 }} />}
|
||||||
|
<div className={cn('w-5 h-5 flex items-center justify-center shrink-0 transition-colors', isActive ? 'text-brand-accent' : 'text-muted-foreground/80')}>
|
||||||
|
{isExpanded ? <FolderOpen size={13} /> : <Folder size={13} />}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-start flex items-center gap-2 min-w-0">
|
||||||
|
<span className={cn('text-[12px] font-medium transition-colors truncate', isActive ? 'text-ink' : 'text-muted-ink group-hover/item:text-ink')}>
|
||||||
|
{carnet.name}
|
||||||
|
</span>
|
||||||
|
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
|
||||||
|
{!isActive && hasActiveDescendant && <span className="w-1.5 h-1.5 rounded-full bg-brand-accent/70 shrink-0" />}
|
||||||
|
</div>
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<span className="text-[9px] font-bold text-concrete/40 px-1.5 border border-border/40 rounded-full group-hover/item:text-concrete transition-colors shrink-0 ms-auto">
|
||||||
|
{notes.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="absolute end-0 top-0 bottom-0 flex items-center gap-1 pe-1 ps-6 opacity-0 group-hover/item:opacity-100 transition-opacity bg-gradient-to-l from-[var(--color-paper,white)] dark:from-[var(--color-paper,#1a1a1a)] from-60% to-transparent">
|
||||||
|
{isPinned && <span className="text-brand-accent" title={t('notebook.pinnedFrozenTooltip')}><Pin size={9} className="opacity-70" /></span>}
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); onAddSubNotebook() }} className="p-1 hover:bg-ink/10 rounded-md transition-all text-concrete hover:text-ink" title={t('notebook.createSubNotebook')}><Plus size={10} /></button>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); onRename() }} className="p-1 hover:bg-ink/10 rounded-md transition-all text-concrete hover:text-ink" title={t('notebook.rename')}><Pencil size={10} /></button>
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); onDelete() }} className="p-1 hover:bg-rose-50 rounded-md transition-all text-concrete hover:text-rose-500" title={t('notebook.delete')}><Trash2 size={10} /></button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AnimatePresence>
|
||||||
|
{contextMenu && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.1 }}
|
||||||
|
style={{ position: 'fixed', top: contextMenu.y, left: contextMenu.x, zIndex: 9999 }}
|
||||||
|
className="bg-card border border-border rounded-xl shadow-xl py-1.5 min-w-[180px] overflow-hidden"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button onClick={() => { onTogglePin(); setContextMenu(null) }} className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors">
|
||||||
|
{isPinned ? <><PinOff size={13} className="text-brand-accent" /><span>{t('sidebar.unfreezePinnedNotebook')}</span></> : <><Pin size={13} className="text-brand-accent" /><span>{t('sidebar.freezePinnedNotebook')}</span></>}
|
||||||
|
</button>
|
||||||
|
<div className="mx-3 my-1 border-t border-border/50" />
|
||||||
|
<button onClick={() => { onAddSubNotebook(); setContextMenu(null) }} className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors">
|
||||||
|
<Plus size={13} className="text-concrete" /><span>{t('sidebar.newSubNotebook')}</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { onRename(); setContextMenu(null) }} className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-ink hover:bg-foreground/5 transition-colors">
|
||||||
|
<Pencil size={13} className="text-concrete" /><span>{t('sidebar.renameNotebook')}</span>
|
||||||
|
</button>
|
||||||
|
<div className="mx-3 my-1 border-t border-border/50" />
|
||||||
|
<button onClick={() => { onDelete(); setContextMenu(null) }} className="w-full flex items-center gap-2.5 px-3 py-2 text-[12px] text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/30 transition-colors">
|
||||||
|
<Trash2 size={13} /><span>{t('common.delete')}</span>
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{isExpanded && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }} animate={{ height: 'auto', opacity: 1 }} exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }} className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="relative" style={{ marginInlineStart: `${(level + 1) * 16 + 10}px` }}>
|
||||||
|
<div className="absolute start-[-6px] top-0 bottom-4 w-px bg-border/30" />
|
||||||
|
<div className="space-y-0.5 py-1">
|
||||||
|
{children}
|
||||||
|
{isExpanded && notes.map(note => (
|
||||||
|
<NoteLink key={note.id} title={note.title} isPinned={note.isPinned} isActive={activeNoteId === note.id} onClick={() => onNoteClick(note.id, carnet.id)} />
|
||||||
|
))}
|
||||||
|
{isExpanded && notes.length === 0 && !hasChildren && (
|
||||||
|
<p className="ps-6 py-1 text-[9px] italic text-muted-foreground/40 font-light">{t('sidebar.notebookEmpty')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
19
memento-note/components/sidebar-resize-client.tsx
Normal file
19
memento-note/components/sidebar-resize-client.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
81
memento-note/components/sidebar-resize.tsx
Normal file
81
memento-note/components/sidebar-resize.tsx
Normal 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 }
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|||||||
Reference in New Issue
Block a user