Feat/notes init (#40)

* feat: notes init

* Feat/notes page (#39)

* wip: notes page ui added

* notes page basic ui done

* wip: page UI

* minor cleanup

* todo comments added for easy ref for backend wiring

---------

Co-authored-by: amadeus-x1 <45001978+amadeus-x1@users.noreply.github.com>

* feat: wire up notes

---------

Co-authored-by: amadeus-x1 <45001978+amadeus-x1@users.noreply.github.com>
This commit is contained in:
Haritabh 2025-09-10 16:13:33 +05:30 committed by GitHub
parent 674092f1b7
commit a128ec7972
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 2980 additions and 10 deletions

View file

@ -9,6 +9,7 @@ import {
IconBookFilled,
IconKeyboard,
IconAdjustments,
IconNotes,
} from "@tabler/icons-react";
import { NavMain } from "@/components/nav-main";
@ -49,6 +50,11 @@ const data = {
url: "/settings/shortcuts",
icon: IconKeyboard,
},
{
title: "Notes",
url: "/settings/notes",
icon: IconNotes,
},
{
title: "Vocabulary",
url: "/settings/vocabulary",

View file

@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}

View file

@ -0,0 +1,81 @@
"use client";
import { FileText, Calendar } from "lucide-react";
import { cn } from "@/lib/utils";
import { Note } from "../types";
interface RecentNoteCardProps {
note: Note;
onNoteClick: (noteId: number) => void;
}
function formatDate(date: Date): string {
const now = new Date();
const diffInDays = Math.floor(
(now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24),
);
if (diffInDays === 0) return "Today";
if (diffInDays === 1) return "Yesterday";
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
});
}
export function NoteCard({ note, onNoteClick }: RecentNoteCardProps) {
return (
<div
onClick={() => onNoteClick(note.id)}
className={cn(
"flex items-start gap-3 py-2 px-3 rounded-lg transition-colors group",
"hover:bg-accent/50 hover:text-accent-foreground",
)}
tabIndex={0}
role="button"
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onNoteClick(note.id);
}
}}
>
{/* Note Icon */}
<div className="flex-shrink-0 mt-0.5">
{note.icon ? (
<span className="text-lg">{note.icon}</span>
) : (
<FileText className="w-5 h-5 text-muted-foreground" />
)}
</div>
{/* Note Content */}
<div className="flex-1 min-w-0">
{/* Note Name */}
<div className="font-medium text-foreground text-sm leading-tight">
{note.title}
</div>
{/* Date and Meeting Info */}
<div className="flex items-center gap-1.5 mt-1 text-xs text-muted-foreground">
<span>{formatDate(note.updatedAt)}</span>
{note.meetingEvent && (
<>
<span className="w-1 h-1 bg-muted-foreground rounded-full"></span>
<div className="flex items-center gap-1">
<Calendar
className="w-3 h-3"
style={{ color: note.meetingEvent.calendarColor }}
/>
<span className="">{note.meetingEvent.title}</span>
</div>
</>
)}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,274 @@
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { useNavigate } from "@tanstack/react-router";
import * as Y from "yjs";
import { api } from "@/trpc/react";
import { toast } from "sonner";
import { debounce } from "../../../utils/debounce";
import Note from "./note";
import { FileTextIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
type NotePageProps = {
noteId: string;
onBack?: () => void;
};
export default function NotePage({ noteId, onBack }: NotePageProps) {
const navigate = useNavigate();
const utils = api.useUtils();
// State
const [noteTitle, setNoteTitle] = useState("");
const [noteBody, setNoteBody] = useState("");
const [isSyncing, setIsSyncing] = useState(true);
const [noteIcon, setNoteIcon] = useState<string | null>(null);
// Refs
const ydocRef = useRef<Y.Doc | null>(null);
const textRef = useRef<Y.Text | null>(null);
const noteRef = useRef<typeof note>(null);
// Fetch note data
const { data: note, isLoading } = api.notes.getNoteById.useQuery(
{ id: parseInt(noteId) },
{
enabled: !!noteId,
},
);
// Update title mutation
const updateTitleMutation = api.notes.updateNoteTitle.useMutation({
onSuccess: () => {
utils.notes.getNotes.invalidate();
utils.notes.getNoteById.invalidate({ id: parseInt(noteId) });
},
});
// Update emoji mutation
const updateNoteIconMutation = api.notes.updateNoteIcon.useMutation({
onSuccess: () => {
utils.notes.getNotes.invalidate();
utils.notes.getNoteById.invalidate({ id: parseInt(noteId) });
toast.success("Emoji updated");
},
onError: (error) => {
toast.error("Failed to update emoji: " + error.message);
},
});
// Delete mutation
const deleteMutation = api.notes.deleteNote.useMutation({
onSuccess: () => {
utils.notes.getNotes.invalidate();
// Use onBack if provided, otherwise navigate
if (onBack) {
onBack();
} else {
navigate({ to: "/settings/notes" });
}
toast.success("Note deleted");
},
onError: (error) => {
toast.error("Failed to delete note: " + error.message);
},
});
// Debounced title update
const debouncedUpdateTitle = useMemo(
() =>
debounce((title: string) => {
const currentNote = noteRef.current;
if (currentNote && title !== currentNote.title) {
updateTitleMutation.mutate({ id: currentNote.id, title });
}
}, 500),
[], // No dependencies - function should remain stable
);
// Debounced YJS update
const debouncedYjsUpdate = useMemo(
() =>
debounce((newContent: string) => {
if (textRef.current && ydocRef.current) {
ydocRef.current.transact(() => {
const oldLength = textRef.current!.length;
textRef.current!.delete(0, oldLength);
textRef.current!.insert(0, newContent);
}, "user-input-debounced");
}
}, 500),
[],
);
// Initialize YJS document
useEffect(() => {
if (!note) return;
// Cancel any pending updates
debouncedYjsUpdate.cancel();
let mounted = true;
const initializeYjs = async () => {
try {
// Create YJS document
const ydoc = new Y.Doc();
const text = ydoc.getText("content");
// Store references
ydocRef.current = ydoc;
textRef.current = text;
// Load existing updates from backend
try {
const updates = await window.electronAPI.notes.loadYjsUpdates(
note.id,
);
if (updates.length > 0) {
// Apply all updates to reconstruct the document
updates.forEach((update: ArrayBuffer) => {
Y.applyUpdate(ydoc, new Uint8Array(update));
});
// Set content from the reconstructed document
const reconstructedContent = text.toString();
setNoteBody(reconstructedContent);
}
} catch (error) {
console.error("Failed to load yjs updates:", error);
}
setIsSyncing(false);
// Listen for changes from YJS
const observer = () => {
if (!mounted) return;
const newContent = text.toString();
setNoteBody(newContent);
};
text.observe(observer);
// Save YJS updates to backend
ydoc.on("update", async (update: Uint8Array) => {
try {
// Convert Uint8Array to ArrayBuffer for IPC
const buffer = update.buffer.slice(
update.byteOffset,
update.byteOffset + update.byteLength,
);
await window.electronAPI.notes.saveYjsUpdate(
note.id,
buffer as ArrayBuffer,
);
} catch (error) {
console.error("Failed to save yjs update:", error);
toast.error("Failed to save changes");
}
});
// Cleanup
return () => {
text.unobserve(observer);
};
} catch (error) {
console.error("Failed to initialize yjs:", error);
setIsSyncing(false);
toast.error("Failed to load note");
}
};
initializeYjs();
return () => {
mounted = false;
debouncedYjsUpdate.cancel();
};
}, [note, debouncedYjsUpdate]);
// Update note ref and set initial title and emoji
useEffect(() => {
noteRef.current = note;
if (note) {
setNoteTitle(note.title);
setNoteIcon(note.icon || null);
}
}, [note]);
// Handle content changes
const handleContentChange = useCallback(
(newContent: string) => {
setNoteBody(newContent);
debouncedYjsUpdate(newContent);
},
[debouncedYjsUpdate],
);
// Handle title change
const handleTitleChange = useCallback(
(newTitle: string) => {
setNoteTitle(newTitle);
debouncedUpdateTitle(newTitle);
},
[debouncedUpdateTitle],
);
// Handle delete
const handleDelete = useCallback(() => {
deleteMutation.mutate({ id: parseInt(noteId) });
}, [noteId, deleteMutation]);
// Handle emoji change
const handleEmojiChange = useCallback(
(emoji: string | null) => {
setNoteIcon(emoji);
updateNoteIconMutation.mutate({ id: parseInt(noteId), icon: emoji });
},
[noteId, updateNoteIconMutation],
);
// Note not found state
if (!isLoading && !note) {
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<FileTextIcon className="h-12 w-12 text-muted-foreground" />
<p className="text-muted-foreground">Note not found</p>
<Button
variant="outline"
onClick={() => {
if (onBack) {
onBack();
} else {
navigate({ to: "/settings/notes" });
}
}}
>
Back to notes
</Button>
</div>
);
}
const lastEditDate = note ? new Date(note.updatedAt) : new Date();
// Use the presentational component
return (
<Note
noteId={noteId}
noteTitle={noteTitle}
noteBody={noteBody}
noteEmoji={noteIcon}
isLoading={isLoading}
isSyncing={isSyncing}
lastEditDate={lastEditDate}
onTitleChange={handleTitleChange}
onBodyChange={handleContentChange}
onDelete={handleDelete}
onEmojiChange={handleEmojiChange}
onBack={onBack}
isDeleting={deleteMutation.isPending}
/>
);
}

View file

@ -0,0 +1,517 @@
import { useState } from "react";
import {
Sparkles,
Share,
Mic,
MoreHorizontal,
Copy,
FileText,
FolderOpen,
Trash2,
Check,
Star,
FileTextIcon,
Loader2,
} from "lucide-react";
import EmojiPicker, { Theme } from "emoji-picker-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
type InvitedUser = {
id: number;
name: string;
avatar?: string;
email: string;
access: string;
status: "active" | "invited";
};
export type NotePageUIProps = {
noteId: string;
noteTitle: string;
noteBody: string;
noteEmoji: string | null;
isLoading: boolean;
isSyncing: boolean;
lastEditDate: Date;
onTitleChange: (value: string) => void;
onBodyChange: (value: string) => void;
onDelete: () => void;
onEmojiChange: (emoji: string | null) => void;
onBack?: () => void;
isDeleting?: boolean;
};
export default function Note({
noteTitle,
noteBody,
noteEmoji,
isLoading,
isSyncing,
lastEditDate,
onTitleChange,
onBodyChange,
onDelete,
onEmojiChange,
isDeleting = false,
}: NotePageUIProps) {
// Local UI state
const [shareEmail, setShareEmail] = useState("");
const [accessLevel, setAccessLevel] = useState("can-read");
const [showConfirmation, setShowConfirmation] = useState(false);
const [invitedUsers, setInvitedUsers] = useState<Array<InvitedUser>>([]);
const [starred, setStarred] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
// Mock shared users data
/* const sharedUsers = [
{
id: 1,
name: "Alice Johnson",
avatar:
"https://images.unsplash.com/photo-1588516903720-8ceb67f9ef84?q=80&w=1344&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
email: "alice@example.com",
access: "can-write",
status: "active" as const,
},
{
id: 2,
name: "Bob Smith",
avatar:
"https://images.unsplash.com/photo-1564564321837-a57b7070ac4f?q=80&w=2676&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
email: "bob@example.com",
access: "can-write",
status: "active" as const,
},
{
id: 3,
name: "Carol Davis",
avatar:
"https://images.unsplash.com/photo-1560087637-bf797bc7796a?q=80&w=1287&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
email: "carol@example.com",
access: "can-read",
status: "active" as const,
},
]; */
// TODO: implement actual share functionality
/* const handleShare = () => {
if (shareEmail) {
const newInvite = {
id: Date.now(),
name: shareEmail.split("@")[0],
email: shareEmail,
access: accessLevel,
status: "invited" as const,
};
setInvitedUsers((prev) => [...prev, newInvite]);
setShowConfirmation(true);
setShareEmail("");
// Hide confirmation after 3 seconds
setTimeout(() => setShowConfirmation(false), 3000);
}
}; */
const handleDeleteClick = () => {
setShowDeleteDialog(false);
onDelete();
};
const handleEmojiSelect = (emojiData: { emoji: string }) => {
onEmojiChange(emojiData.emoji);
setShowEmojiPicker(false);
};
const handleEmojiRemove = () => {
onEmojiChange(null);
};
/* const allUsers = [...sharedUsers, ...invitedUsers]; */
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<TooltipProvider>
<div className="">
{/* Note Content */}
<div className="mt-0 space-y-2">
{/* Note Title with Emoji Picker */}
<div className="flex items-center">
{/* Emoji Picker */}
<Popover open={showEmojiPicker} onOpenChange={setShowEmojiPicker}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-12 w-12 p-0 hover:bg-muted/50"
>
{noteEmoji ? (
<span className="text-2xl">{noteEmoji}</span>
) : (
<FileTextIcon className="!h-6 !w-6 text-muted-foreground" />
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div>
{noteEmoji && (
<div className="flex justify-end p-2 border-b">
<Button
variant="ghost"
size="sm"
onClick={handleEmojiRemove}
className="text-xs"
>
Remove emoji
</Button>
</div>
)}
<EmojiPicker
onEmojiClick={handleEmojiSelect}
autoFocusSearch={false}
theme={Theme.DARK}
lazyLoadEmojis={false}
height={400}
width={400}
/>
</div>
</PopoverContent>
</Popover>
{/* Note Title Input */}
<Input
value={noteTitle}
onChange={(e) => onTitleChange(e.target.value)}
className="!text-4xl font-semibold border-none px-4 py-2 focus-visible:ring-0 placeholder:text-muted-foreground flex-1"
style={{ backgroundColor: "transparent" }}
placeholder="Note title..."
disabled={isSyncing}
/>
</div>
{/* Top Bar */}
<div className="flex items-center justify-start pl-4 pb-0.5 bg-card">
{/* Right side - Actions */}
<div className="flex items-center ">
{/* Last edited date */}
<span className="text-sm text-muted-foreground">
Edited{" "}
{lastEditDate.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year:
lastEditDate.getFullYear() !== new Date().getFullYear()
? "numeric"
: undefined,
})}
</span>
{/* {console.log("[v0] Note ID:", noteId)} */}
{/* <Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled
className="gap-1 mx-1.5"
>
<Sparkles className="h-4 w-4" />
AI Notes
</Button>
</TooltipTrigger>
<TooltipContent>AI Notes coming soon</TooltipContent>
</Tooltip> */}
{/* Shared users avatars */}
{/* <div className="flex -space-x-2">
{sharedUsers.map((user) => (
<Tooltip key={user.id}>
<TooltipTrigger asChild>
<Avatar className="h-8 w-8 border-2 border-background cursor-pointer">
<AvatarImage
className="object-cover"
src={user.avatar || "/placeholder.svg"}
alt={user.name}
/>
<AvatarFallback className="text-xs">
{user.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>
<div className="text-center">
<p className="font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">
{user.email}
</p>
</div>
</TooltipContent>
</Tooltip>
))}
</div> */}
{/* Star the note */}
{/* <Button
size="sm"
variant="ghost"
className="gap-2"
onClick={() => setStarred(!starred)}
>
<Star
fill={starred ? "orange" : "none"}
stroke={starred ? "orange" : "currentColor"}
className="h-4 w-4"
/>
</Button> */}
{/* Share button with popover */}
{/* <Popover>
<PopoverTrigger asChild>
<Button size="sm" variant="ghost">
<Share className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-80" align="end">
<div className="space-y-4">
{showConfirmation ? (
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-950/20 rounded-lg border border-green-200 dark:border-green-800">
<Check className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-700 dark:text-green-300">
Invitation sent successfully!
</span>
</div>
) : (
<>
<div>
<h4 className="font-medium text-sm mb-2">
Share this note
</h4>
<p className="text-xs text-muted-foreground mb-3">
Invite others to collaborate on this note
</p>
</div>
<div className="space-y-3">
<Input
placeholder="Enter email address"
value={shareEmail}
onChange={(e) => setShareEmail(e.target.value)}
/>
<Select
value={accessLevel}
onValueChange={setAccessLevel}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="full-access">
Full access
</SelectItem>
<SelectItem value="can-write">
Can write
</SelectItem>
<SelectItem value="can-read">Can read</SelectItem>
</SelectContent>
</Select>
<Button
onClick={handleShare}
className="w-full"
size="sm"
>
Send invitation
</Button>
</div>
</>
)}
{allUsers.length > 0 && (
<div className="pt-3 border-t border-border">
<h5 className="text-xs font-medium mb-2 text-muted-foreground">
People with access
</h5>
<div className="space-y-2">
{allUsers.map((user) => (
<div
key={user.id}
className="flex items-center gap-2"
>
<Avatar className="h-6 w-6">
<AvatarImage
src={user.avatar || "/placeholder.svg"}
alt={user.name}
/>
<AvatarFallback className="text-xs">
{user.name
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div className="flex-1">
<span className="text-xs">{user.name}</span>
{user.status === "invited" && (
<span className="text-xs text-muted-foreground ml-1">
(invited)
</span>
)}
</div>
<span className="text-xs text-muted-foreground capitalize">
{user.access.replace("-", " ")}
</span>
</div>
))}
</div>
</div>
)}
</div>
</PopoverContent>
</Popover> */}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" disabled>
<Mic className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Transcription coming soon</TooltipContent>
</Tooltip>
{/* More actions dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{/* <DropdownMenuItem className="gap-2">
<Copy className="h-4 w-4" />
Copy
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<FileText className="h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem className="gap-2">
<FolderOpen className="h-4 w-4" />
Move to
</DropdownMenuItem> */}
<AlertDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="gap-2"
onSelect={(e) => e.preventDefault()}
variant="destructive"
>
<Trash2 className="h-4 w-4 text-destructive" />
Delete
</DropdownMenuItem>
</AlertDialogTrigger>
</AlertDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Note Body */}
<Textarea
value={noteBody}
onChange={(e) => onBodyChange(e.target.value)}
placeholder="Start writing your note..."
className="min-h-[500px] resize-none border-none bg-transparent px-4 py-2 focus-visible:ring-0 text-base leading-relaxed placeholder:text-muted-foreground"
style={{
backgroundColor: "transparent",
}}
disabled={isSyncing}
/>
</div>
{/* Delete Confirmation Dialog */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Note</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{noteTitle}"? This action
cannot be undone and the note will be permanently removed.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteClick}
className="bg-destructive text-foreground hover:bg-destructive/90"
disabled={isDeleting}
>
{isDeleting ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Deleting...
</>
) : (
"Delete Note"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TooltipProvider>
);
}

View file

@ -0,0 +1,112 @@
import { NotebookText, Plus } from "lucide-react";
import { NoteCard } from "./note-card";
import { Button } from "@/components/ui/button";
import { api } from "@/trpc/react";
import { Skeleton } from "@/components/ui/skeleton";
import { useNavigate } from "@tanstack/react-router";
import { toast } from "sonner";
export function NotesList() {
const navigate = useNavigate();
const utils = api.useUtils();
const { data: notes, isLoading } = api.notes.getNotes.useQuery({
sortBy: "updatedAt",
sortOrder: "desc",
});
// Create note mutation
const createNoteMutation = api.notes.createNote.useMutation({
onSuccess: (newNote) => {
// Invalidate notes list to refetch
utils.notes.getNotes.invalidate();
// Navigate to the new note
navigate({
to: "/settings/notes/$noteId",
params: { noteId: String(newNote.id) },
});
toast.success("Note created");
},
onError: (error) => {
toast.error("Failed to create note: " + error.message);
},
});
const onCreateNote = () => {
createNoteMutation.mutate({
title: "Untitled Note",
initialContent: "",
});
};
const onNoteClick = (noteId: number) => {
navigate({
to: "/settings/notes/$noteId",
params: { noteId: String(noteId) },
});
};
// Convert database notes to UI format
const formattedNotes = notes || [];
// Loading state
if (isLoading) {
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-muted-foreground">
<NotebookText className="w-4 h-4" />
<h2 className="text-sm font-medium">Notes</h2>
</div>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-start gap-3 py-2 px-3">
<Skeleton className="w-5 h-5 mt-0.5" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
))}
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-muted-foreground">
<NotebookText className="w-4 h-4" />
<h2 className="text-sm font-medium">Notes</h2>
</div>
{formattedNotes.length > 0 && (
<div>
{formattedNotes.map((note) => (
<NoteCard key={note.id} note={note} onNoteClick={onNoteClick} />
))}
</div>
)}
{formattedNotes.length === 0 && (
<div className="border border-dashed rounded-lg p-6 text-center space-y-4">
<NotebookText className="w-8 h-8 text-muted-foreground mx-auto" />
<div className="space-y-2">
<p className="text-sm text-muted-foreground">No notes yet</p>
<p className="text-xs text-muted-foreground">
Create your first note to get started
</p>
<Button
className="mt-4"
size={"sm"}
variant={"outline"}
onClick={onCreateNote}
>
<Plus className="w-4 h-4" />
Create note
</Button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,90 @@
import { NotebookPen } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { getMeetingIcon } from "@/utils/meeting-icons";
interface UpcomingEvent {
title: string;
time: string;
url: string;
date?: string;
}
interface UpcomingEventCardProps {
event: UpcomingEvent;
onTakeNotes?: (event: UpcomingEvent) => void;
}
const UpcomingEventCard = ({ event, onTakeNotes }: UpcomingEventCardProps) => {
const handleLinkClick = () => {
if (event.url) {
// Open external link - adjust this based on your Electron setup
window.electronAPI.openExternal(event.url);
}
};
const handleTakeNotes = () => {
onTakeNotes?.(event);
};
return (
<div className="bg-transparent border-none group hover:bg-accent/60 transition-colors relative py-3 px-4">
<div className="flex items-start gap-4">
{/* Leading icon */}
<div className="flex-shrink-0 mt-0.5">
{getMeetingIcon(event.url, {
className: "w-5 h-5 text-muted-foreground",
})}
</div>
<div className="flex-1 space-y-2">
{/* Event title */}
<h3 className="text-foreground text-sm font-medium leading-tight line-clamp-1">
{event.title}
</h3>
{/* Time and meeting url */}
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<span className="whitespace-nowrap">
{event.date} {event.time}
</span>
|{" "}
{
<a
onClick={handleLinkClick}
className="text-muted-foreground text-xs line-clamp-1 hover:text-foreground cursor-pointer transition-colors"
>
{event.url}
</a>
}
</div>
</div>
{/* take notes button - visible only on hover */}
<div className="absolute top-6 right-6 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex-shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="secondary"
onClick={handleTakeNotes}
className="h-8 w-8 p-0"
>
<NotebookPen className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Take notes</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
);
};
export default UpcomingEventCard;

View file

@ -0,0 +1,94 @@
import { Calendar, CalendarX, CalendarPlus } from "lucide-react";
import UpcomingEventCard from "./upcoming-event-card";
import { UpcomingEvent } from "../types";
import { Button } from "@/components/ui/button";
type CalendarState = "with-events" | "no-events" | "no-calendar";
// TOOD: add calendar connection and sync logic
export function UpcomingEvents() {
// Switch this variable to test different states:
// "with-events" - shows upcoming events
// "no-events" - calendar connected but no events
// "no-calendar" - no calendar connected
const calendarState: CalendarState = "with-events";
// TODO: replace mock data with actual data from backend
// Mock events data
const mockEvents: UpcomingEvent[] = [
{
title: "Product Review: Q3 Feature Planning",
date: "Tomorrow",
time: "2:00 3:00 PM",
url: "https://zoom.us/j/123456789",
},
{
title: "1:1 with Sarah - Engineering Sync",
date: "Sep 8th",
time: "10:00 10:30 AM",
url: "https://meet.google.com/abc-defg-hij",
},
];
// Determine events based on state
const upcomingEvents = calendarState === "with-events" ? mockEvents : [];
const handleTakeNotes = (event: UpcomingEvent) => {
// Handle taking notes for the event
console.log("Taking notes for:", event.title);
// TODO: navigate to a notes editor, open a modal, etc.
// You can implement your note-taking logic here
// For example: navigate to a notes editor, open a modal, etc.
};
const handleConnectCalendar = () => {
// Handle connecting calendar
console.log("Connecting calendar...");
// TODO: implement your calendar connection logic here
// You can implement your calendar connection logic here
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="w-4 h-4" />
<h2 className="text-sm font-medium">Upcoming events</h2>
</div>
{calendarState === "with-events" ? (
<div className="bg-accent/40 rounded-xl overflow-clip">
{upcomingEvents.map((event, index) => (
<div key={index}>
<UpcomingEventCard event={event} onTakeNotes={handleTakeNotes} />
</div>
))}
</div>
) : calendarState === "no-events" ? (
<div className="border border-dashed rounded-lg p-6 text-center">
<CalendarX className="w-8 h-8 text-muted-foreground mx-auto mb-3" />
<p className="text-sm text-muted-foreground">No upcoming events</p>
</div>
) : (
<div className="border border-dashed rounded-lg p-6 text-center space-y-4">
<CalendarPlus className="w-8 h-8 text-muted-foreground mx-auto" />
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Connect your calendar
<br />
Connect your calendar to get started
</p>
<Button
onClick={handleConnectCalendar}
className="mt-2"
size={"sm"}
variant={"outline"}
>
<CalendarPlus className="w-4 h-4" />
Connect calendar
</Button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,13 @@
import { NotesList } from "./components/notes-list";
export default function Notes() {
return (
<div className="space-y-6 p-2">
{/* Upcoming events section */}
{/* <UpcomingEvents /> */}
{/* Recent notes section */}
<NotesList />
</div>
);
}

View file

@ -0,0 +1,18 @@
export type UpcomingEvent = {
title: string;
time: string;
url: string;
date?: string;
calendarColor?: string;
};
export interface Note {
id: number;
title: string;
icon?: string | null;
updatedAt: Date;
meetingEvent?: {
title: string;
calendarColor: string;
};
}

View file

@ -15,11 +15,14 @@ import { Route as SettingsIndexRouteImport } from './routes/settings/index'
import { Route as SettingsVocabularyRouteImport } from './routes/settings/vocabulary'
import { Route as SettingsShortcutsRouteImport } from './routes/settings/shortcuts'
import { Route as SettingsPreferencesRouteImport } from './routes/settings/preferences'
import { Route as SettingsNotesRouteImport } from './routes/settings/notes'
import { Route as SettingsHistoryRouteImport } from './routes/settings/history'
import { Route as SettingsDictationRouteImport } from './routes/settings/dictation'
import { Route as SettingsAiModelsRouteImport } from './routes/settings/ai-models'
import { Route as SettingsAdvancedRouteImport } from './routes/settings/advanced'
import { Route as SettingsAboutRouteImport } from './routes/settings/about'
import { Route as SettingsNotesIndexRouteImport } from './routes/settings/notes.index'
import { Route as SettingsNotesNoteIdRouteImport } from './routes/settings/notes.$noteId'
const SettingsRouteRoute = SettingsRouteRouteImport.update({
id: '/settings',
@ -51,6 +54,11 @@ const SettingsPreferencesRoute = SettingsPreferencesRouteImport.update({
path: '/preferences',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsNotesRoute = SettingsNotesRouteImport.update({
id: '/notes',
path: '/notes',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsHistoryRoute = SettingsHistoryRouteImport.update({
id: '/history',
path: '/history',
@ -76,6 +84,16 @@ const SettingsAboutRoute = SettingsAboutRouteImport.update({
path: '/about',
getParentRoute: () => SettingsRouteRoute,
} as any)
const SettingsNotesIndexRoute = SettingsNotesIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => SettingsNotesRoute,
} as any)
const SettingsNotesNoteIdRoute = SettingsNotesNoteIdRouteImport.update({
id: '/$noteId',
path: '/$noteId',
getParentRoute: () => SettingsNotesRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@ -85,10 +103,13 @@ export interface FileRoutesByFullPath {
'/settings/ai-models': typeof SettingsAiModelsRoute
'/settings/dictation': typeof SettingsDictationRoute
'/settings/history': typeof SettingsHistoryRoute
'/settings/notes': typeof SettingsNotesRouteWithChildren
'/settings/preferences': typeof SettingsPreferencesRoute
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/settings/vocabulary': typeof SettingsVocabularyRoute
'/settings/': typeof SettingsIndexRoute
'/settings/notes/$noteId': typeof SettingsNotesNoteIdRoute
'/settings/notes/': typeof SettingsNotesIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
@ -101,6 +122,8 @@ export interface FileRoutesByTo {
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/settings/vocabulary': typeof SettingsVocabularyRoute
'/settings': typeof SettingsIndexRoute
'/settings/notes/$noteId': typeof SettingsNotesNoteIdRoute
'/settings/notes': typeof SettingsNotesIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@ -111,10 +134,13 @@ export interface FileRoutesById {
'/settings/ai-models': typeof SettingsAiModelsRoute
'/settings/dictation': typeof SettingsDictationRoute
'/settings/history': typeof SettingsHistoryRoute
'/settings/notes': typeof SettingsNotesRouteWithChildren
'/settings/preferences': typeof SettingsPreferencesRoute
'/settings/shortcuts': typeof SettingsShortcutsRoute
'/settings/vocabulary': typeof SettingsVocabularyRoute
'/settings/': typeof SettingsIndexRoute
'/settings/notes/$noteId': typeof SettingsNotesNoteIdRoute
'/settings/notes/': typeof SettingsNotesIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@ -126,10 +152,13 @@ export interface FileRouteTypes {
| '/settings/ai-models'
| '/settings/dictation'
| '/settings/history'
| '/settings/notes'
| '/settings/preferences'
| '/settings/shortcuts'
| '/settings/vocabulary'
| '/settings/'
| '/settings/notes/$noteId'
| '/settings/notes/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@ -142,6 +171,8 @@ export interface FileRouteTypes {
| '/settings/shortcuts'
| '/settings/vocabulary'
| '/settings'
| '/settings/notes/$noteId'
| '/settings/notes'
id:
| '__root__'
| '/'
@ -151,10 +182,13 @@ export interface FileRouteTypes {
| '/settings/ai-models'
| '/settings/dictation'
| '/settings/history'
| '/settings/notes'
| '/settings/preferences'
| '/settings/shortcuts'
| '/settings/vocabulary'
| '/settings/'
| '/settings/notes/$noteId'
| '/settings/notes/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@ -206,6 +240,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsPreferencesRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/notes': {
id: '/settings/notes'
path: '/notes'
fullPath: '/settings/notes'
preLoaderRoute: typeof SettingsNotesRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/history': {
id: '/settings/history'
path: '/history'
@ -241,15 +282,44 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SettingsAboutRouteImport
parentRoute: typeof SettingsRouteRoute
}
'/settings/notes/': {
id: '/settings/notes/'
path: '/'
fullPath: '/settings/notes/'
preLoaderRoute: typeof SettingsNotesIndexRouteImport
parentRoute: typeof SettingsNotesRoute
}
'/settings/notes/$noteId': {
id: '/settings/notes/$noteId'
path: '/$noteId'
fullPath: '/settings/notes/$noteId'
preLoaderRoute: typeof SettingsNotesNoteIdRouteImport
parentRoute: typeof SettingsNotesRoute
}
}
}
interface SettingsNotesRouteChildren {
SettingsNotesNoteIdRoute: typeof SettingsNotesNoteIdRoute
SettingsNotesIndexRoute: typeof SettingsNotesIndexRoute
}
const SettingsNotesRouteChildren: SettingsNotesRouteChildren = {
SettingsNotesNoteIdRoute: SettingsNotesNoteIdRoute,
SettingsNotesIndexRoute: SettingsNotesIndexRoute,
}
const SettingsNotesRouteWithChildren = SettingsNotesRoute._addFileChildren(
SettingsNotesRouteChildren,
)
interface SettingsRouteRouteChildren {
SettingsAboutRoute: typeof SettingsAboutRoute
SettingsAdvancedRoute: typeof SettingsAdvancedRoute
SettingsAiModelsRoute: typeof SettingsAiModelsRoute
SettingsDictationRoute: typeof SettingsDictationRoute
SettingsHistoryRoute: typeof SettingsHistoryRoute
SettingsNotesRoute: typeof SettingsNotesRouteWithChildren
SettingsPreferencesRoute: typeof SettingsPreferencesRoute
SettingsShortcutsRoute: typeof SettingsShortcutsRoute
SettingsVocabularyRoute: typeof SettingsVocabularyRoute
@ -262,6 +332,7 @@ const SettingsRouteRouteChildren: SettingsRouteRouteChildren = {
SettingsAiModelsRoute: SettingsAiModelsRoute,
SettingsDictationRoute: SettingsDictationRoute,
SettingsHistoryRoute: SettingsHistoryRoute,
SettingsNotesRoute: SettingsNotesRouteWithChildren,
SettingsPreferencesRoute: SettingsPreferencesRoute,
SettingsShortcutsRoute: SettingsShortcutsRoute,
SettingsVocabularyRoute: SettingsVocabularyRoute,

View file

@ -0,0 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
import NotePage from "../../pages/notes/components/note-wrapper";
export const Route = createFileRoute("/settings/notes/$noteId")({
component: NotePageWrapper,
});
function NotePageWrapper() {
const { noteId } = Route.useParams();
return <NotePage noteId={noteId} />;
}

View file

@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import NotesPage from "../../pages/notes";
export const Route = createFileRoute("/settings/notes/")({
component: NotesPage,
});

View file

@ -0,0 +1,5 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
export const Route = createFileRoute("/settings/notes")({
component: () => <Outlet />,
});

View file

@ -12,6 +12,11 @@ function SettingsLayout() {
const location = useLocation();
const getSettingsPageTitle = (pathname: string): string => {
// Check for dynamic routes first
if (pathname.startsWith("/settings/notes")) {
return "Notes";
}
const routes: Record<string, string> = {
"/settings/preferences": "Preferences",
"/settings/dictation": "Dictation",

View file

@ -0,0 +1,25 @@
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number,
): ((...args: Parameters<T>) => void) & { cancel: () => void } {
let timeout: ReturnType<typeof setTimeout> | null = null;
const debounced = function (this: any, ...args: Parameters<T>) {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func.apply(this, args);
timeout = null;
}, wait);
};
debounced.cancel = () => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
};
return debounced;
}

View file

@ -117,11 +117,7 @@ export function UnifiedPermissionsStep({
<div className="max-w-lg w-full space-y-6">
{/* Header with logo */}
<div className="text-center space-y-4">
<img
src="assets/logo.svg"
alt="Amical"
className="w-20 h-20 mx-auto"
/>
<img src="assets/logo.svg" alt="Amical" className="w-20 h-20 mx-auto" />
<div>
<h1 className="text-2xl font-bold">Permissions Required</h1>
<p className="text-muted-foreground mt-2">