All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m12s
Replace JSON-string embeddings with native pgvector(1536) storage and add PostgreSQL full-text search (tsvector/GIN) with Reciprocal Rank Fusion for hybrid keyword + semantic ranking. Changes: - NoteEmbedding.embedding: String → vector(1536) via pgvector - NoteEmbedding: added updatedAt for reindex tracking - Note: added tsv (tsvector) with auto-update trigger for FTS - semantic-search.service: hybrid FTS + vector search with RRF fusion - embedding.service: toVectorString() for pgvector SQL literals - Removed JS-side cosine similarity loops (now DB-side via <=>) - Added HNSW index on NoteEmbedding.embedding (cosine distance) - Added GIN index on Note.tsv for FTS queries Schema migration in: prisma/migrations/20260512120000_pgvector_and_fts_search/ Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
470 lines
20 KiB
TypeScript
470 lines
20 KiB
TypeScript
import React from 'react';
|
|
import {
|
|
Plus,
|
|
Search,
|
|
Share2,
|
|
Pin,
|
|
ChevronRight,
|
|
ArrowLeft,
|
|
MoreVertical,
|
|
Sparkles,
|
|
Tag as TagIcon,
|
|
X,
|
|
BookOpen,
|
|
Edit3,
|
|
Eye,
|
|
Trash2
|
|
} from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import { Note, Carnet, Tag } from '../types';
|
|
import { SlashMenu } from './SlashMenu';
|
|
|
|
interface NotebooksViewProps {
|
|
activeNoteId: string | null;
|
|
activeCarnet: Carnet | undefined;
|
|
filteredNotes: Note[];
|
|
activeNote: Note | undefined;
|
|
setActiveNoteId: (id: string | null) => void;
|
|
togglePin: (id: string) => void;
|
|
setShowNewNoteModal: (show: boolean) => void;
|
|
isAISidebarOpen: boolean;
|
|
setIsAISidebarOpen: (open: boolean) => void;
|
|
selectedTagIds: string[];
|
|
setSelectedTagIds: (ids: string[]) => void;
|
|
allNotes: Note[];
|
|
activeCarnetId: string;
|
|
setShowNewCarnetModal: (show: boolean, parentId?: string, isRenaming?: boolean, carnetId?: string) => void;
|
|
onDeleteNote: (id: string) => void;
|
|
}
|
|
|
|
export const NotebooksView: React.FC<NotebooksViewProps> = ({
|
|
activeNoteId,
|
|
activeCarnet,
|
|
filteredNotes,
|
|
activeNote,
|
|
setActiveNoteId,
|
|
togglePin,
|
|
setShowNewNoteModal,
|
|
isAISidebarOpen,
|
|
setIsAISidebarOpen,
|
|
selectedTagIds,
|
|
setSelectedTagIds,
|
|
allNotes,
|
|
activeCarnetId,
|
|
setShowNewCarnetModal,
|
|
onDeleteNote
|
|
}) => {
|
|
const [isTagsExpanded, setIsTagsExpanded] = React.useState(false);
|
|
const [tagSearchQuery, setTagSearchQuery] = React.useState('');
|
|
const [isEditing, setIsEditing] = React.useState(false);
|
|
const [slashMenu, setSlashMenu] = React.useState<{ isOpen: boolean; top: number; left: number } | null>(null);
|
|
|
|
const handleEditorKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === '/') {
|
|
const selection = window.getSelection();
|
|
if (selection && selection.rangeCount > 0) {
|
|
const range = selection.getRangeAt(0);
|
|
const rect = range.getBoundingClientRect();
|
|
setSlashMenu({
|
|
isOpen: true,
|
|
top: rect.bottom + window.scrollY,
|
|
left: rect.left + window.scrollX
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const insertCommand = (type: string) => {
|
|
console.log(`Command selected: ${type}`);
|
|
setSlashMenu(null);
|
|
};
|
|
|
|
const availableTags = React.useMemo(() => {
|
|
const carnetNotes = allNotes.filter(n => n.carnetId === activeCarnetId);
|
|
const tagsMap = new Map<string, Tag>();
|
|
carnetNotes.forEach(note => {
|
|
note.tags?.forEach(tag => {
|
|
tagsMap.set(tag.id, tag);
|
|
});
|
|
});
|
|
return Array.from(tagsMap.values()).sort((a, b) => {
|
|
// AI tags first, then alphabetical
|
|
if (a.type === 'ai' && b.type !== 'ai') return -1;
|
|
if (a.type !== 'ai' && b.type === 'ai') return 1;
|
|
return a.label.localeCompare(b.label);
|
|
});
|
|
}, [allNotes, activeCarnetId]);
|
|
|
|
const visibleTags = React.useMemo(() => {
|
|
let filtered = availableTags;
|
|
if (tagSearchQuery) {
|
|
filtered = availableTags.filter(t =>
|
|
t.label.toLowerCase().includes(tagSearchQuery.toLowerCase())
|
|
);
|
|
} else if (!isTagsExpanded) {
|
|
filtered = availableTags.slice(0, 10);
|
|
// Ensure selected tags are always visible even if not in the first 10
|
|
selectedTagIds.forEach(id => {
|
|
if (!filtered.find(t => t.id === id)) {
|
|
const tag = availableTags.find(t => t.id === id);
|
|
if (tag) filtered.push(tag);
|
|
}
|
|
});
|
|
}
|
|
return filtered;
|
|
}, [availableTags, isTagsExpanded, tagSearchQuery, selectedTagIds]);
|
|
|
|
const toggleTag = (tagId: string) => {
|
|
if (selectedTagIds.includes(tagId)) {
|
|
setSelectedTagIds(selectedTagIds.filter(id => id !== tagId));
|
|
} else {
|
|
setSelectedTagIds([...selectedTagIds, tagId]);
|
|
}
|
|
};
|
|
|
|
if (!activeNoteId) {
|
|
return (
|
|
<div className="h-full flex flex-col overflow-y-auto">
|
|
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-paper/80 backdrop-blur-md z-30">
|
|
<div className="flex justify-between items-start">
|
|
<h1 className="text-4xl font-serif font-medium tracking-tight text-ink leading-tight pr-12">
|
|
{activeCarnet?.name} — {filteredNotes[0]?.date || 'Oct 26'}
|
|
</h1>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between border-b border-ink/5 pb-4">
|
|
<div className="flex items-center gap-6">
|
|
<button
|
|
onClick={() => setShowNewNoteModal(true)}
|
|
className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity"
|
|
>
|
|
<Plus size={16} />
|
|
<span>Add Note</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setShowNewCarnetModal(true, activeCarnetId)}
|
|
className="flex items-center gap-2 text-[13px] text-concrete font-medium hover:text-ink transition-all"
|
|
>
|
|
<BookOpen size={16} />
|
|
<span>New Sub-Carnet</span>
|
|
</button>
|
|
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
|
|
<Search size={16} />
|
|
<span>Search</span>
|
|
</button>
|
|
</div>
|
|
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
|
|
<Share2 size={16} />
|
|
<span>Share</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3 text-[10px] font-bold uppercase tracking-[0.2em] text-concrete">
|
|
<TagIcon size={12} />
|
|
<span>Filter by Tags</span>
|
|
{selectedTagIds.length > 0 && (
|
|
<span className="bg-blueprint/10 text-blueprint px-2 py-0.5 rounded-full text-[9px] lowercase tracking-normal">
|
|
{selectedTagIds.length} active
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{availableTags.length > 10 && (
|
|
<div className="relative group">
|
|
<input
|
|
type="text"
|
|
placeholder="Search tags..."
|
|
className="bg-transparent border-b border-border/40 text-[10px] outline-none focus:border-blueprint/40 py-1 px-2 w-32 transition-all focus:w-48 placeholder:text-concrete/40"
|
|
onChange={(e) => setTagSearchQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-2 items-center min-h-[32px]">
|
|
<AnimatePresence mode="popLayout">
|
|
{visibleTags.map(tag => {
|
|
const isActive = selectedTagIds.includes(tag.id);
|
|
return (
|
|
<motion.button
|
|
layout
|
|
initial={{ opacity: 0, scale: 0.9 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
exit={{ opacity: 0, scale: 0.9 }}
|
|
key={tag.id}
|
|
onClick={() => toggleTag(tag.id)}
|
|
className={`px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all border flex items-center gap-2
|
|
${isActive
|
|
? 'bg-ink text-paper border-ink shadow-lg shadow-ink/10'
|
|
: 'bg-white/40 border-border text-concrete hover:border-concrete/40 hover:bg-white/60'}`}
|
|
>
|
|
{tag.type === 'ai' && (
|
|
<Sparkles
|
|
size={10}
|
|
className={isActive ? 'text-blueprint' : 'text-blueprint/60'}
|
|
/>
|
|
)}
|
|
{tag.label}
|
|
{isActive && <X size={10} />}
|
|
</motion.button>
|
|
);
|
|
})}
|
|
</AnimatePresence>
|
|
|
|
{availableTags.length > 10 && !tagSearchQuery && (
|
|
<button
|
|
onClick={() => setIsTagsExpanded(!isTagsExpanded)}
|
|
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-concrete/60 hover:text-ink transition-colors border border-dashed border-border rounded-full"
|
|
>
|
|
{isTagsExpanded ? 'Show less' : `+ ${availableTags.length - 10} more`}
|
|
</button>
|
|
)}
|
|
|
|
{selectedTagIds.length > 0 && (
|
|
<button
|
|
onClick={() => setSelectedTagIds([])}
|
|
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-rust hover:underline ml-auto"
|
|
>
|
|
Clear all
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<div className="px-12 flex-1 pb-20">
|
|
<div className="max-w-3xl space-y-16">
|
|
{filteredNotes.map((note, index) => (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.1 * index, duration: 0.8 }}
|
|
key={note.id}
|
|
className="space-y-4 group cursor-pointer relative"
|
|
onClick={() => setActiveNoteId(note.id)}
|
|
>
|
|
<h2 className="text-2xl font-serif font-medium text-ink flex items-center justify-between">
|
|
<span className="flex items-center gap-3">
|
|
{note.isPinned && <Pin size={18} className="text-amber-500 fill-amber-500" />}
|
|
{note.title}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
togglePin(note.id);
|
|
}}
|
|
className={`p-2 rounded-full transition-all ${note.isPinned ? 'text-amber-600 bg-amber-50' : 'opacity-0 group-hover:opacity-60 hover:bg-slate-100 text-ink'}`}
|
|
>
|
|
<Pin size={16} />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDeleteNote(note.id);
|
|
}}
|
|
className="p-2 rounded-full opacity-0 group-hover:opacity-60 hover:opacity-100 hover:bg-rose-50 text-rose-500 transition-all"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
<button className="opacity-0 group-hover:opacity-40 transition-opacity">
|
|
<ChevronRight size={20} />
|
|
</button>
|
|
</div>
|
|
</h2>
|
|
<div className="flex flex-col md:flex-row gap-8 items-start">
|
|
<div className="w-full md:w-56 aspect-[4/3] bg-white/50 dark:bg-white/5 border border-border overflow-hidden rounded shadow-sm flex-shrink-0">
|
|
<img
|
|
src={note.imageUrl}
|
|
alt={note.title}
|
|
className="w-full h-full object-cover mix-blend-multiply opacity-80 grayscale contrast-125 hover:grayscale-0 hover:opacity-100 transition-all duration-500"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
</div>
|
|
<div className="space-y-3">
|
|
<div className="flex flex-wrap gap-2 mb-2">
|
|
{note.tags?.map(tag => (
|
|
<div
|
|
key={tag.id}
|
|
className={`px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider border flex items-center gap-1.5
|
|
${tag.type === 'ai'
|
|
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
|
|
: 'bg-concrete/5 border-border text-concrete'}`}
|
|
>
|
|
{tag.type === 'ai' && <Sparkles size={8} />}
|
|
{tag.label}
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-[14px] leading-relaxed text-ink/80 font-light max-w-lg line-clamp-4">
|
|
{note.content}
|
|
</p>
|
|
<span className="text-[11px] text-muted-ink uppercase tracking-widest font-medium">Read more</span>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
{filteredNotes.length === 0 && (
|
|
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
|
|
<p className="font-serif text-xl italic text-muted-ink">This notebook is waiting for its first vision.</p>
|
|
<button
|
|
onClick={() => setShowNewNoteModal(true)}
|
|
className="px-6 py-2 border border-ink text-[13px] uppercase tracking-[0.2em] hover:bg-ink hover:text-paper transition-all"
|
|
>
|
|
Begin Drawing
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<footer className="px-12 py-6 border-t border-ink/5 text-center mt-auto">
|
|
<p className="text-[11px] text-muted-ink uppercase tracking-[0.2em] font-medium">
|
|
© 2024 Architectural Grid. All rights reserved.
|
|
</p>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full flex overflow-hidden transition-all duration-500">
|
|
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-paper">
|
|
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/90 dark:bg-paper/90 backdrop-blur-sm z-40 border-b border-border">
|
|
<button
|
|
onClick={() => setActiveNoteId(null)}
|
|
className="flex items-center gap-2 text-ink hover:opacity-60 transition-opacity"
|
|
>
|
|
<ArrowLeft size={18} />
|
|
<span className="text-sm font-medium">Back to collection</span>
|
|
</button>
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={() => setIsEditing(!isEditing)}
|
|
className={`flex items-center gap-2 px-4 py-1.5 rounded-lg border transition-all duration-300
|
|
${isEditing ? 'bg-blueprint text-white border-blueprint shadow-lg shadow-blueprint/20' : 'border-border text-ink hover:bg-slate-50'}`}
|
|
>
|
|
{isEditing ? <Eye size={16} /> : <Edit3 size={16} />}
|
|
<span className="text-xs font-bold uppercase tracking-widest">{isEditing ? 'Visualiser' : 'Modifier'}</span>
|
|
</button>
|
|
<button
|
|
onClick={() => togglePin(activeNoteId!)}
|
|
className={`p-2 rounded-full transition-all ${activeNote?.isPinned ? 'text-amber-600 bg-amber-50 dark:bg-ochre/10' : 'text-muted-ink hover:text-ink'}`}
|
|
title={activeNote?.isPinned ? "Unpin note" : "Pin note"}
|
|
>
|
|
<Pin size={18} className={activeNote?.isPinned ? 'fill-amber-600' : ''} />
|
|
</button>
|
|
<button
|
|
onClick={() => setIsAISidebarOpen(!isAISidebarOpen)}
|
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300
|
|
${isAISidebarOpen ? 'bg-ink text-paper border-ink' : 'border-border text-ink hover:bg-white/50 dark:hover:bg-white/5'}`}
|
|
>
|
|
<Sparkles size={16} />
|
|
<span className="text-xs font-medium">AI Assistant</span>
|
|
</button>
|
|
<button className="p-2 text-muted-ink hover:text-red-500 transition-colors">
|
|
<Trash2 size={18} />
|
|
</button>
|
|
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
|
|
<MoreVertical size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12 relative">
|
|
<AnimatePresence>
|
|
{slashMenu?.isOpen && (
|
|
<SlashMenu
|
|
position={{ top: slashMenu.top, left: slashMenu.left }}
|
|
onSelect={(type) => insertCommand(type)}
|
|
onClose={() => setSlashMenu(null)}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3 text-[12px] text-muted-ink uppercase tracking-[.25em] font-bold">
|
|
<span className="text-blueprint">{activeCarnet?.name}</span>
|
|
<ChevronRight size={10} className="text-concrete" />
|
|
<span className="text-concrete">{activeNote?.date}</span>
|
|
</div>
|
|
|
|
{isEditing ? (
|
|
<input
|
|
type="text"
|
|
defaultValue={activeNote?.title}
|
|
className="w-full text-5xl md:text-6xl font-serif font-bold text-ink leading-tight bg-transparent border-none outline-none focus:ring-0 placeholder:text-concrete/20"
|
|
placeholder="Titre de la note..."
|
|
/>
|
|
) : (
|
|
<h1 className="text-5xl md:text-6xl font-serif font-bold text-ink leading-tight">
|
|
{activeNote?.title}
|
|
</h1>
|
|
)}
|
|
|
|
<div className="flex flex-wrap gap-2 pt-2">
|
|
{activeNote?.tags?.map(tag => (
|
|
<div
|
|
key={tag.id}
|
|
className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border flex items-center gap-2
|
|
${tag.type === 'ai'
|
|
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
|
|
: 'bg-paper border-border text-concrete'}`}
|
|
>
|
|
{tag.type === 'ai' && <Sparkles size={12} />}
|
|
{tag.label}
|
|
{tag.type === 'ai' && (
|
|
<div className="w-1.5 h-1.5 rounded-full bg-blueprint animate-pulse" />
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-white/5 rounded-xl overflow-hidden shadow-2xl relative group/img">
|
|
<img
|
|
src={activeNote?.imageUrl}
|
|
alt={activeNote?.title}
|
|
className="w-full h-full object-cover transition-transform duration-700 group-hover/img:scale-105"
|
|
referrerPolicy="no-referrer"
|
|
/>
|
|
<div className="absolute inset-0 bg-gradient-to-t from-ink/20 to-transparent pointer-events-none" />
|
|
</div>
|
|
|
|
<div className="max-w-2xl mx-auto w-full space-y-8 pb-40">
|
|
{isEditing ? (
|
|
<textarea
|
|
defaultValue={activeNote?.content}
|
|
onKeyDown={handleEditorKeyDown}
|
|
className="w-full min-h-[500px] text-lg leading-relaxed text-ink/90 font-serif bg-transparent border-none outline-none focus:ring-0 resize-none placeholder:text-concrete/20"
|
|
placeholder="Commencez à écrire... Tapez '/' pour les commandes."
|
|
/>
|
|
) : (
|
|
<div className="space-y-8">
|
|
<p className="text-xl md:text-2xl font-serif leading-relaxed text-ink italic">
|
|
{activeNote?.content.split('.')[0]}.
|
|
</p>
|
|
<div className="h-px bg-border w-32" />
|
|
<div className="space-y-6">
|
|
{activeNote?.content.split('\n').map((line, i) => (
|
|
<p key={i} className="text-lg leading-relaxed text-ink/80 font-light text-justify selection:bg-blueprint/20">
|
|
{line}
|
|
</p>
|
|
))}
|
|
{activeNote?.id.startsWith('n-') && (
|
|
<p className="text-lg leading-relaxed text-ink/80 font-light text-justify border-l-2 border-blueprint/20 pl-6 italic">
|
|
Architectural grids serve as the invisible scaffolding upon which spatial experiences are constructed. Beyond mere structural repetition, they facilitate a rhythmic dialogue between materiality and void. In this exploration, we examine how light fractures these rigid boundaries, creating a dynamic interplay that evolves with the passage of time.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|