refactor: migrate to monorepo structure with Docker, PocketBase, and e2e tests

- Reorganize into apps/, packages/, docs/, e2e/, pocketbase/ directories
- Add Dockerfiles for web, worker, and PocketBase services
- Add docker-compose.yml for local orchestration
- Add turbo.json for monorepo task management
- Add Playwright e2e test infrastructure
- Add PocketBase backend with migrations
- Remove Vite/Next.js/ESLint/PostCSS config files
- Update package.json with workspace dependencies
- Add .env.example and .dockerignore
This commit is contained in:
2026-07-16 06:19:58 -04:00
parent ec14645a4b
commit 8f55626e03
286 changed files with 31992 additions and 9245 deletions
+351
View File
@@ -0,0 +1,351 @@
'use client';
import { useEffect, useState, Suspense } from 'react';
import { Plus, FileText, Link2, GitBranch } from 'lucide-react';
import dynamic from 'next/dynamic';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Badge } from '@/components/ui/badge';
import { DailyNoteButton } from '@/components/notes/daily-note-button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
// Lazy load TipTap editor (~80KB TipTap + extensions)
const NoteEditor = dynamic(
() => import('@/components/notes/note-editor').then((m) => m.NoteEditor),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">Loading editor...</div>
</div>
),
}
);
// Lazy load react-force-graph-2d (~120KB + three.js)
const NoteGraph = dynamic(
() => import('@/components/notes/note-graph').then((m) => m.NoteGraph),
{
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">Loading graph...</div>
</div>
),
}
);
interface Note {
id: string;
title: string;
content: string;
domain: string;
created: string;
updated: string;
}
interface Backlink {
id: string;
title: string;
}
export default function NotesPage() {
const [notes, setNotes] = useState<Note[]>([]);
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchNotes();
}, []);
useEffect(() => {
if (selectedNote) {
fetchBacklinks(selectedNote.id);
}
}, [selectedNote]);
async function fetchNotes() {
try {
const response = await fetch('/api/notes?sort=-updated');
if (response.ok) {
const data = await response.json();
const notesList = data.items || [];
setNotes(notesList);
if (notesList.length > 0 && !selectedNote) {
setSelectedNote(notesList[0]);
}
}
} catch (error) {
console.error('Failed to fetch notes:', error);
} finally {
setLoading(false);
}
}
async function fetchBacklinks(noteId: string) {
try {
const response = await fetch(`/api/notes/${noteId}/backlinks`);
if (response.ok) {
const data = await response.json();
setBacklinks(data.items || []);
}
} catch (error) {
console.error('Failed to fetch backlinks:', error);
}
}
async function createNote() {
try {
const response = await fetch('/api/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: 'Untitled note',
content: '',
domain: 'personal',
}),
});
if (response.ok) {
const newNote = await response.json();
setNotes([newNote, ...notes]);
setSelectedNote(newNote);
}
} catch (error) {
console.error('Failed to create note:', error);
}
}
function handleDailyNoteReady(raw: Record<string, unknown>) {
const note = raw as unknown as Note;
// If the note already appears in the list, just select it
const exists = notes.find((n) => n.id === note.id);
if (exists) {
setSelectedNote(exists);
return;
}
// Otherwise prepend it and select
setNotes([note, ...notes]);
setSelectedNote(note);
}
async function updateNote(noteId: string, updates: Partial<Note>) {
try {
await fetch(`/api/notes/${noteId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
fetchNotes();
} catch (error) {
console.error('Failed to update note:', error);
}
}
async function deleteNote(noteId: string) {
if (!confirm('Are you sure you want to delete this note?')) return;
try {
await fetch(`/api/notes/${noteId}`, { method: 'DELETE' });
const updatedNotes = notes.filter((n) => n.id !== noteId);
setNotes(updatedNotes);
if (selectedNote?.id === noteId) {
setSelectedNote(updatedNotes[0] || null);
}
} catch (error) {
console.error('Failed to delete note:', error);
}
}
if (loading) {
return <p className="text-muted-foreground">Loading notes...</p>;
}
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Notes</h1>
<p className="mt-1 text-muted-foreground">
Connect ideas to the work they shape.
</p>
</div>
<div className="flex items-center gap-3">
<DailyNoteButton onNoteReady={handleDailyNoteReady} />
<Button onClick={createNote}>
<Plus className="mr-2 h-4 w-4" />
New note
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[250px_1fr_300px]">
{/* Notes list */}
<Card className="h-[calc(100vh-200px)]">
<ScrollArea className="h-full">
<div className="p-2">
{notes.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No notes yet
</p>
) : (
<div className="space-y-1">
{notes.map((note) => (
<button
key={note.id}
onClick={() => setSelectedNote(note)}
aria-label={`Open note: ${note.title}`}
aria-current={selectedNote?.id === note.id ? 'true' : undefined}
className={`w-full rounded-lg p-3 text-left transition-colors ${
selectedNote?.id === note.id
? 'bg-accent'
: 'hover:bg-accent/50'
}`}
>
<div className="flex items-start gap-2">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{note.title}
</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{new Date(note.updated).toLocaleDateString()}
</p>
<Badge variant="outline" className="mt-1 text-xs">
{note.domain}
</Badge>
</div>
</div>
</button>
))}
</div>
)}
</div>
</ScrollArea>
</Card>
{/* Note editor */}
<Card className="h-[calc(100vh-200px)]">
{selectedNote ? (
<div className="flex h-full flex-col">
<div className="border-b p-4">
<label htmlFor="note-title" className="sr-only">
Note title
</label>
<input
id="note-title"
type="text"
value={selectedNote.title}
onChange={(e) =>
setSelectedNote({
...selectedNote,
title: e.target.value,
})
}
onBlur={() =>
updateNote(selectedNote.id, {
title: selectedNote.title,
})
}
className="w-full text-xl font-semibold outline-none"
placeholder="Note title"
/>
</div>
<div className="flex-1 overflow-auto p-4">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">
Loading editor...
</div>
</div>
}
>
<NoteEditor
content={selectedNote.content}
onChange={(content) =>
setSelectedNote({ ...selectedNote, content })
}
onBlur={() =>
updateNote(selectedNote.id, {
content: selectedNote.content,
})
}
/>
</Suspense>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">
Select a note or create a new one
</p>
</div>
)}
</Card>
{/* Backlinks and graph */}
<Card className="h-[calc(100vh-200px)]">
<Tabs defaultValue="backlinks" className="h-full">
<div className="border-b p-2">
<TabsList className="w-full">
<TabsTrigger value="backlinks" className="flex-1 gap-2">
<Link2 className="h-3 w-3" aria-hidden="true" />
Backlinks
</TabsTrigger>
<TabsTrigger value="graph" className="flex-1 gap-2">
<GitBranch className="h-3 w-3" aria-hidden="true" />
Graph
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="backlinks" className="h-full overflow-auto p-4">
{backlinks.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No backlinks
</p>
) : (
<div className="space-y-2">
{backlinks.map((link) => (
<button
key={link.id}
onClick={() => {
const note = notes.find((n) => n.id === link.id);
if (note) setSelectedNote(note);
}}
aria-label={`Open linked note: ${link.title}`}
className="w-full rounded-lg border p-3 text-left transition-colors hover:bg-accent"
>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<span className="text-sm font-medium">
{link.title}
</span>
</div>
</button>
))}
</div>
)}
</TabsContent>
<TabsContent value="graph" className="h-full p-4">
<Suspense
fallback={
<div className="flex h-full items-center justify-center">
<div className="animate-pulse text-sm text-muted-foreground">
Loading graph...
</div>
</div>
}
>
<NoteGraph notes={notes} />
</Suspense>
</TabsContent>
</Tabs>
</Card>
</div>
</div>
);
}