bigwolfeman
glow
da9637c
/**
* T078: Note viewer with rendered markdown, metadata, and backlinks
* T081-T082: Wikilink click handling and broken link styling
* T009: Font size buttons (A-, A, A+) for content adjustment
* T037-T052: Table of Contents panel integration
*/
import { useMemo, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Edit, Trash2, Calendar, Tag as TagIcon, ArrowLeft, Volume2, Pause, Play, Square, Type, List } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Slider } from '@/components/ui/slider';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from '@/components/ui/resizable';
import type { Note } from '@/types/note';
import type { BacklinkResult } from '@/services/api';
import { createWikilinkComponent, resetSlugCache } from '@/lib/markdown.tsx';
import { markdownToPlainText } from '@/lib/markdownToText';
import { useTableOfContents } from '@/hooks/useTableOfContents';
import { TableOfContents } from '@/components/TableOfContents';
type FontSizePreset = 'small' | 'medium' | 'large';
interface NoteViewerProps {
note: Note;
backlinks: BacklinkResult[];
onEdit?: () => void;
onDelete?: () => void;
onWikilinkClick: (linkText: string) => void;
ttsStatus?: 'idle' | 'loading' | 'playing' | 'paused' | 'error';
onTtsToggle?: () => void;
onTtsStop?: () => void;
ttsDisabledReason?: string;
ttsVolume?: number;
onTtsVolumeChange?: (volume: number) => void;
fontSize?: FontSizePreset;
onFontSizeChange?: (size: FontSizePreset) => void;
}
export function NoteViewer({
note,
backlinks,
onEdit,
onDelete,
onWikilinkClick,
ttsStatus = 'idle',
onTtsToggle,
onTtsStop,
ttsDisabledReason,
ttsVolume = 0.7,
onTtsVolumeChange,
fontSize = 'medium',
onFontSizeChange,
}: NoteViewerProps) {
// T042-T047: Table of Contents hook
const { headings, isOpen: isTocOpen, setIsOpen: setIsTocOpen, scrollToHeading } = useTableOfContents();
// Reset slug cache when note changes to ensure unique IDs
useEffect(() => {
resetSlugCache();
}, [note.note_path]);
// Create custom markdown components with wikilink handler
const markdownComponents = useMemo(
() => createWikilinkComponent(onWikilinkClick),
[onWikilinkClick]
);
// Pre-process markdown to convert wikilinks to standard links
// [[Link]] -> [Link](wikilink:Link)
// [[Link|Alias]] -> [Alias](wikilink:Link)
const processedBody = useMemo(() => {
if (!note.body) return '';
const processed = note.body.replace(/\[\[([^\]]+)\]\]/g, (_match, content) => {
const [link, alias] = content.split('|');
const displayText = alias || link;
const href = `wikilink:${encodeURIComponent(link)}`;
return `[${displayText}](${href})`;
});
// console.log('Processed Body:', processed);
return processed;
}, [note.body]);
// T031-T035: Calculate reading time from note body
const readingTime = useMemo(() => {
const plainText = markdownToPlainText(note.body);
// T032: Extract word count
const wordCount = plainText.trim().split(/\s+/).length;
// T033: Calculate minutes at 200 WPM
const minutes = Math.ceil(wordCount / 200);
// T034: Return null if <1 minute (200 words threshold)
return minutes >= 1 ? `${minutes} min read` : null;
}, [note.body]);
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b border-border p-4 animate-fade-in">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-1">
<h1 className="text-3xl font-bold truncate animate-slide-in-up">{note.title}</h1>
{/* T035: Render Badge with "X min read" near note title */}
{readingTime && (
<Badge variant="secondary" className="flex-shrink-0 animate-fade-in">
{readingTime}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mt-1 animate-fade-in">{note.note_path}</p>
</div>
<div className="flex gap-2 animate-fade-in">
{onTtsToggle && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onTtsToggle}
disabled={Boolean(ttsDisabledReason) || ttsStatus === 'loading'}
title={ttsDisabledReason || undefined}
>
{ttsStatus === 'playing' ? (
<Pause className="h-4 w-4 mr-2" />
) : ttsStatus === 'paused' ? (
<Play className="h-4 w-4 mr-2" />
) : (
<Volume2 className="h-4 w-4 mr-2" />
)}
{ttsStatus === 'playing'
? 'Pause TTS'
: ttsStatus === 'paused'
? 'Resume TTS'
: ttsStatus === 'loading'
? 'Loading...'
: 'TTS Mode'}
</Button>
{(ttsStatus === 'playing' || ttsStatus === 'paused' || ttsStatus === 'error') && onTtsStop && (
<Button variant="ghost" size="sm" onClick={onTtsStop} title="Stop TTS">
<Square className="h-4 w-4" />
</Button>
)}
{onTtsVolumeChange && (
<div className="flex items-center gap-2 ml-2 min-w-[120px]">
<Volume2 className="h-3 w-3 text-muted-foreground flex-shrink-0" />
<Slider
value={[ttsVolume * 100]}
onValueChange={(val: number[]) => onTtsVolumeChange(val[0] / 100)}
max={100}
step={1}
className="w-20"
title={`Volume: ${Math.round(ttsVolume * 100)}%`}
/>
<span className="text-xs text-muted-foreground w-8 flex-shrink-0">
{Math.round(ttsVolume * 100)}%
</span>
</div>
)}
</div>
)}
{onFontSizeChange && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" title="Adjust font size">
<Type className="h-4 w-4 mr-2" />
A
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => onFontSizeChange('small')}
className={fontSize === 'small' ? 'bg-accent' : ''}
>
<span className="text-xs">A-</span>
<span className="text-xs text-muted-foreground ml-2">Small (14px)</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onFontSizeChange('medium')}
className={fontSize === 'medium' ? 'bg-accent' : ''}
>
<span className="text-sm">A</span>
<span className="text-xs text-muted-foreground ml-2">Medium (16px)</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onFontSizeChange('large')}
className={fontSize === 'large' ? 'bg-accent' : ''}
>
<span className="text-lg">A+</span>
<span className="text-xs text-muted-foreground ml-2">Large (18px)</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
{/* T044: TOC toggle button */}
<Button
variant={isTocOpen ? 'default' : 'outline'}
size="sm"
onClick={() => setIsTocOpen(!isTocOpen)}
title="Toggle Table of Contents"
>
<List className="h-4 w-4 mr-2" />
TOC
</Button>
{onEdit && (
<Button variant="outline" size="sm" onClick={onEdit}>
<Edit className="h-4 w-4 mr-2" />
Edit
</Button>
)}
{onDelete && (
<Button variant="outline" size="sm" onClick={onDelete}>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</div>
{/* T045: Content with ResizablePanel for TOC */}
<ResizablePanelGroup direction="horizontal" className="flex-1">
{/* Main content panel */}
<ResizablePanel defaultSize={isTocOpen ? 75 : 100}>
<ScrollArea className="h-full p-6">
<div className="prose prose-slate dark:prose-invert max-w-none animate-fade-in-smooth">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
urlTransform={(url) => url} // Allow all protocols including wikilink:
>
{processedBody}
</ReactMarkdown>
</div>
<Separator className="my-8" />
{/* Metadata Footer */}
<div className="space-y-4 text-sm animate-fade-in">
{/* Tags */}
{note.metadata.tags && note.metadata.tags.length > 0 && (
<div className="flex items-start gap-2">
<TagIcon className="h-4 w-4 mt-0.5 text-muted-foreground shrink-0" />
<div className="flex flex-wrap gap-2">
{note.metadata.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
</div>
)}
{/* Timestamps */}
<div className="flex items-center gap-4 text-muted-foreground">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
<span>Created: {formatDate(note.created)}</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
<span>Updated: {formatDate(note.updated)}</span>
</div>
</div>
{/* Backlinks */}
{backlinks.length > 0 && (
<>
<Separator className="my-4" />
<div>
<div className="flex items-center gap-2 mb-3">
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
<h3 className="font-semibold">
Backlinks ({backlinks.length})
</h3>
</div>
<div className="space-y-2 ml-6">
{backlinks.map((backlink) => (
<button
key={backlink.note_path}
className="block text-left text-primary hover:underline"
onClick={() => onWikilinkClick(backlink.title)}
>
{'-> '}
{backlink.title}
</button>
))}
</div>
</div>
</>
)}
{/* Additional metadata */}
{note.metadata.project && (
<div className="text-muted-foreground">
Project: <span className="font-medium">{note.metadata.project}</span>
</div>
)}
<div className="text-xs text-muted-foreground">
Version: {note.version} | Size: {(note.size_bytes / 1024).toFixed(1)} KB
</div>
</div>
</ScrollArea>
</ResizablePanel>
{/* T045: TOC panel with slide-in animation */}
{isTocOpen && (
<>
<ResizableHandle withHandle />
<ResizablePanel defaultSize={25} minSize={15} maxSize={40} className="animate-slide-in">
<TableOfContents headings={headings} onHeadingClick={scrollToHeading} />
</ResizablePanel>
</>
)}
</ResizablePanelGroup>
</div>
);
}