T1/Phase 1: scaffold Vite SPA + Hono API + Bun worker

- apps/web: Vite + React 19 + TanStack Router/Query + shadcn/ui
   - apps/api: Hono + Bun on :3001 with /api/health, /api/auth/*, /mcp stubs
   - apps/worker: Bun worker stub, DB connection, graceful SIGTERM
   - apps/web-legacy/: old Next.js code moved aside (preserved for T2-T8 reference)
   - Dockerfiles: api (Bun), worker (Bun), spa (multi-stage Caddy)
   - Caddyfile: serves dist + reverse-proxies /api/* + /mcp to api
   - docker-compose.yml: 4-service target (api, spa, db, worker)
   - packages/db/src/client.ts: shared Drizzle client for api + worker
   - db/client.ts: root-level alias for convenience

   Parent: t_e1cbd87d -> t_24c9c3fd (T0)
This commit is contained in:
Hermes
2026-08-01 01:15:31 +00:00
parent 9203aee758
commit fca56ab77e
312 changed files with 3489 additions and 196 deletions
@@ -0,0 +1,104 @@
'use client';
import { useState, useCallback } from 'react';
import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { format, addDays, subDays } from 'date-fns';
interface DailyNoteButtonProps {
/** Called after the daily note is created/retrieved, with the raw PocketBase record. */
onNoteReady: (note: Record<string, unknown>) => void;
/** Optional: currently selected date (controls the displayed date). */
selectedDate?: Date;
/** Called when the user navigates to a different date. */
onDateChange?: (date: Date) => void;
}
/**
* Button row that creates / navigates daily notes.
*
* Layout: ◀ [CalendarDays · 2026-07-15] ▶
*
* Clicking the centre button POSTs to /api/notes/daily and opens the note.
* The arrow buttons shift the date by one day without fetching.
*/
export function DailyNoteButton({
onNoteReady,
selectedDate,
onDateChange,
}: DailyNoteButtonProps) {
const [loading, setLoading] = useState(false);
const [currentDate, setCurrentDate] = useState<Date>(
selectedDate ?? new Date()
);
const dateStr = format(currentDate, 'yyyy-MM-dd');
const displayDate = format(currentDate, 'MMM d, yyyy');
const navigate = useCallback(
(delta: number) => {
const next = delta > 0 ? addDays(currentDate, 1) : subDays(currentDate, 1);
setCurrentDate(next);
onDateChange?.(next);
},
[currentDate, onDateChange]
);
async function handleCreateDailyNote() {
setLoading(true);
try {
const res = await fetch('/api/notes/daily', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date: dateStr }),
});
if (!res.ok) {
console.error('Failed to create daily note', await res.text());
return;
}
const note = await res.json();
onNoteReady(note);
} catch (err) {
console.error('Failed to create daily note:', err);
} finally {
setLoading(false);
}
}
return (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => navigate(-1)}
aria-label="Previous day"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
onClick={handleCreateDailyNote}
disabled={loading}
>
<CalendarDays className="h-4 w-4" />
{loading ? 'Creating…' : `Daily Note — ${displayDate}`}
</Button>
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={() => navigate(1)}
aria-label="Next day"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
);
}
@@ -0,0 +1,163 @@
'use client';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Placeholder from '@tiptap/extension-placeholder';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import {
Bold,
Italic,
Strikethrough,
Code,
List,
ListOrdered,
Quote,
Undo,
Redo,
} from 'lucide-react';
import { cn } from '@/lib/utils';
interface NoteEditorProps {
content: string;
onChange: (content: string) => void;
onBlur?: () => void;
}
export function NoteEditor({ content, onChange, onBlur }: NoteEditorProps) {
const editor = useEditor({
extensions: [
StarterKit,
Link.configure({
openOnClick: false,
}),
Placeholder.configure({
placeholder: 'Start writing... Use [[Note Title]] to link to other notes',
}),
],
content,
onUpdate: ({ editor: e }) => {
onChange(e.getHTML());
},
onBlur: () => {
onBlur?.();
},
});
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content);
}
}, [content, editor]);
if (!editor) {
return null;
}
return (
<div className="flex h-full flex-col">
{/* Toolbar */}
<div className="mb-4 flex flex-wrap gap-1 border-b pb-2">
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
label="Bold"
>
<Bold className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
label="Italic"
>
<Italic className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
active={editor.isActive('strike')}
label="Strikethrough"
>
<Strikethrough className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive('code')}
label="Code"
>
<Code className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 w-px bg-border" />
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
label="Bullet list"
>
<List className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
label="Ordered list"
>
<ListOrdered className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
label="Blockquote"
>
<Quote className="h-4 w-4" />
</ToolbarButton>
<div className="mx-1 w-px bg-border" />
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
label="Undo"
>
<Undo className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
label="Redo"
>
<Redo className="h-4 w-4" />
</ToolbarButton>
</div>
{/* Editor content */}
<div className="flex-1 overflow-auto">
<EditorContent editor={editor} className="tiptap-editor min-h-[400px]" tabIndex={0} />
</div>
</div>
);
}
function ToolbarButton({
onClick,
active,
disabled,
label,
children,
}: {
onClick: () => void;
active?: boolean;
disabled?: boolean;
label: string;
children: React.ReactNode;
}) {
return (
<Button
variant="ghost"
size="icon"
className={cn('h-8 w-8', active && 'bg-accent text-accent-foreground')}
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={active}
>
{children}
</Button>
);
}
@@ -0,0 +1,110 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import dynamic from 'next/dynamic';
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
ssr: false,
loading: () => (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Loading graph...</p>
</div>
),
});
interface Note {
id: string;
title: string;
domain: string;
}
interface GraphNode {
id: string;
title: string;
domain: string;
val: number;
}
interface GraphLink {
source: string;
target: string;
}
interface NoteGraphProps {
notes: Note[];
}
export function NoteGraph({ notes }: NoteGraphProps) {
const [graphData, setGraphData] = useState<{
nodes: GraphNode[];
links: GraphLink[];
}>({ nodes: [], links: [] });
const graphRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 300, height: 500 });
useEffect(() => {
fetchGraphData();
}, []);
useEffect(() => {
if (graphRef.current) {
const { width, height } = graphRef.current.getBoundingClientRect();
setDimensions({ width: Math.floor(width) || 300, height: Math.floor(height) || 500 });
}
}, []);
async function fetchGraphData() {
try {
const response = await fetch('/api/notes/graph');
if (response.ok) {
const data = await response.json();
const nodes: GraphNode[] = (data.nodes || []).map(
(node: { id: string; title: string; domain: string; connectionCount?: number }) => ({
id: node.id,
title: node.title,
domain: node.domain,
val: (node.connectionCount || 0) + 1,
})
);
const links: GraphLink[] = (data.edges || []).map(
(edge: { source: string; target: string }) => ({
source: edge.source,
target: edge.target,
})
);
setGraphData({ nodes, links });
}
} catch (error) {
console.error('Failed to fetch graph data:', error);
}
}
if (graphData.nodes.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">No graph data available</p>
</div>
);
}
return (
<div ref={graphRef} className="h-[500px] w-full">
<ForceGraph2D
graphData={graphData}
nodeLabel="title"
nodeAutoColorBy="domain"
nodeRelSize={6}
linkDirectionalArrowLength={6}
linkDirectionalArrowRelPos={0.99}
onNodeClick={(node: Record<string, unknown>) => {
console.log('Clicked node:', node);
}}
width={dimensions.width}
height={dimensions.height}
/>
</div>
);
}
@@ -0,0 +1,124 @@
'use client';
import { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from 'sonner';
import { FileText, NotebookText, BrainCircuit } from 'lucide-react';
const TEMPLATES_KEY = 'pe_note_templates';
interface NoteTemplate {
name: string;
icon: string;
content: string;
}
const DEFAULT_TEMPLATES: NoteTemplate[] = [
{
name: 'Meeting notes',
icon: 'FileText',
content: '<h2>Meeting: [Title]</h2><p><strong>Date:</strong> [Date]</p><p><strong>Attendees:</strong></p><ul><li></li></ul><h3>Agenda</h3><ol><li></li></ol><h3>Notes</h3><p></p><h3>Action Items</h3><ul><li></li></ul>',
},
{
name: 'Daily journal',
icon: 'NotebookText',
content: '<h2>[Date]</h2><h3>What I did today</h3><p></p><h3>What I learned</h3><p></p><h3>What I\'m grateful for</h3><ul><li></li></ul>',
},
{
name: 'Brain dump',
icon: 'BrainCircuit',
content: '<h2>Brain Dump — [Date]</h2><p>Everything on my mind right now:</p><ul><li></li></ul><h3>Priorities</h3><ol><li></li></ol>',
},
];
function getTemplates(): NoteTemplate[] {
if (typeof window === 'undefined') return DEFAULT_TEMPLATES;
try {
const stored = localStorage.getItem(TEMPLATES_KEY);
if (stored) return JSON.parse(stored);
} catch {}
return DEFAULT_TEMPLATES;
}
interface NoteTemplatesProps {
onCreateFromTemplate: (content: string) => void;
}
export function NoteTemplates({ onCreateFromTemplate }: NoteTemplatesProps) {
const [open, setOpen] = useState(false);
const [templates, setTemplates] = useState<NoteTemplate[]>(getTemplates);
const handleSelect = useCallback(
(template: NoteTemplate) => {
const content = template.content
.replace(/\[Date\]/g, new Date().toLocaleDateString())
.replace(/\[Title\]/g, template.name);
onCreateFromTemplate(content);
setOpen(false);
toast.success('Note created from template');
},
[onCreateFromTemplate]
);
const iconMap: Record<string, React.ReactNode> = {
FileText: <FileText className="h-5 w-5" />,
NotebookText: <NotebookText className="h-5 w-5" />,
BrainCircuit: <BrainCircuit className="h-5 w-5" />,
};
return (
<>
<Button variant="outline" onClick={() => setOpen(true)}>
<FileText className="mr-2 h-4 w-4" />
+ From template
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Choose a template</DialogTitle>
<DialogDescription>
Start with a pre-formatted note template.
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{templates.map((template) => (
<button
key={template.name}
onClick={() => handleSelect(template)}
className="flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-accent"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
{iconMap[template.icon] || <FileText className="h-5 w-5" />}
</span>
<div>
<p className="text-sm font-medium">{template.name}</p>
<p className="text-xs text-muted-foreground">
{template.name === 'Meeting notes'
? 'Structured meeting notes with agenda and action items'
: template.name === 'Daily journal'
? 'Daily reflection with accomplishments and learnings'
: 'Free-form thought capture'}
</p>
</div>
</button>
))}
</div>
<DialogFooter className="text-xs text-muted-foreground">
Templates are stored locally in your browser.
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}