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:
parent
674092f1b7
commit
a128ec7972
39 changed files with 2980 additions and 10 deletions
|
|
@ -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",
|
||||
|
|
|
|||
17
apps/desktop/src/renderer/main/hooks/useDebounce.ts
Normal file
17
apps/desktop/src/renderer/main/hooks/useDebounce.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
517
apps/desktop/src/renderer/main/pages/notes/components/note.tsx
Normal file
517
apps/desktop/src/renderer/main/pages/notes/components/note.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
13
apps/desktop/src/renderer/main/pages/notes/index.tsx
Normal file
13
apps/desktop/src/renderer/main/pages/notes/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/desktop/src/renderer/main/pages/notes/types.ts
Normal file
18
apps/desktop/src/renderer/main/pages/notes/types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import NotesPage from "../../pages/notes";
|
||||
|
||||
export const Route = createFileRoute("/settings/notes/")({
|
||||
component: NotesPage,
|
||||
});
|
||||
5
apps/desktop/src/renderer/main/routes/settings/notes.tsx
Normal file
5
apps/desktop/src/renderer/main/routes/settings/notes.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/settings/notes")({
|
||||
component: () => <Outlet />,
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
25
apps/desktop/src/renderer/main/utils/debounce.ts
Normal file
25
apps/desktop/src/renderer/main/utils/debounce.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue