Port everything from v2 except for things that require a server

This commit is contained in:
kalle 2025-03-19 17:27:27 +01:00
parent cfff9199b6
commit 82e329023f
77 changed files with 8505 additions and 52 deletions

View file

@ -6,11 +6,6 @@ Yes, another rewrite was needed. Again.
- Yes all of it
- Client
- Overview
- Search
- Graph
- Calendar
- Todo
- Collection pages
- Note pages
- Lexical
- Excalidraw

View file

@ -9,25 +9,47 @@
"test": "vitest run"
},
"dependencies": {
"@excalidraw/excalidraw": "^0.18.0",
"@lexical/react": "^0.28.0",
"@lexical/utils": "^0.28.0",
"@lexical/yjs": "^0.28.0",
"@mdi/js": "^7.4.47",
"@mdi/react": "^1.6.1",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.0.6",
"@tanstack/react-router": "^1.114.3",
"@tanstack/react-router-devtools": "^1.114.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.0",
"lexical": "^0.28.0",
"lucide-react": "^0.483.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.6",
"tw-animate-css": "^1.2.4"
"tw-animate-css": "^1.2.4",
"y-excalidraw": "^2.0.12",
"y-indexeddb": "^9.0.12",
"y-websocket": "^2.1.0",
"yjs": "^13.6.24",
"zod": "^3.24.2"
},
"devDependencies": {
"@serwist/vite": "^9.0.12",
"@serwist/window": "^9.0.12",
"@tanstack/router-plugin": "^1.114.25",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.2.0",
"@types/react": "^19.0.8",

3059
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,253 @@
import Icon from "@mdi/react";
import { BookPlus, Calendar, ChevronsUpDown, Home, Inbox, ListTodo, LogOut, Monitor, Moon, Palette, PanelLeft, Plus, Search, Sun, Waypoints } from "lucide-react";
import { Button } from "~/components/ui/button";
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, useSidebar } from "~/components/ui/sidebar";
import { type ColorName, colors } from "~/lib/color";
import { type IconName, icons } from "~/lib/icon";
import { NewNoteDialog } from "~/components/note/new_note_dialog";
import { NewCollectionDialog } from "~/components/collection/new_collection_dialog";
import { cn } from "~/lib/utils";
import { useCollectionNotesMetadata, useCollections } from "~/hooks/use-metadata";
import { useIsMobile } from "~/hooks/use-mobile";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "~/components/ui/dropdown-menu";
import { UserAvatar } from "~/components/user/user_avatar";
import { Link } from "@tanstack/react-router";
import { useTheme } from "~/hooks/use-theme";
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
export function AppSidebar() {
const collections = useCollections();
return (
<Sidebar variant="inset" collapsible="icon">
<SidebarContent>
<HeaderButtons>
<NewNoteDialog>
<Button variant="ghost"><Plus /></Button>
</NewNoteDialog>
<NewCollectionDialog>
<Button variant="ghost"><BookPlus /></Button>
</NewCollectionDialog>
</HeaderButtons>
<SidebarGroup>
<SidebarMenu>
<NavButton href="/app/" label="Overview" icon={<Home />} />
<NavButton href="/app/search" label="Search" icon={<Search />} />
<InboxButton />
<NavButton href="/app/graph" label="Graph" icon={<Waypoints />} />
<NavButton href="/app/calendar" label="Calendar" icon={<Calendar />} />
<NavButton href="/app/todo" label="Todo" icon={<ListTodo />} />
</SidebarMenu>
</SidebarGroup>
<SidebarGroup>
<SidebarGroupLabel>Collections</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{collections.map((collection) => (<CollectionButton
key={collection.get("id")}
id={collection.get("id")}
name={collection.get("name")}
icon={collection.get("icon")}
color={collection.get("color")}
/>))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
<SidebarMenu>
<SidebarToggle />
<NavUser />
</SidebarMenu>
</SidebarFooter>
</Sidebar>
);
}
type NavButtonProps = {
href: string;
label: string;
icon?: React.ReactNode;
children?: React.ReactNode;
}
function NavButton(props: NavButtonProps) {
const { setOpenMobile } = useSidebar();
return (
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link to={props.href} onClick={() => setOpenMobile(false)}>
{props.icon}
<span>{props.label}</span>
</Link>
</SidebarMenuButton>
{props.children}
</SidebarMenuItem>
);
}
function HeaderButtons(props: { children: React.ReactNode }) {
return (
<div className={cn(
"flex flex-row justify-between",
"transition-[marign,opa] duration-200 ease-linear mt-0",
"group-data-[collapsible=icon]:opacity-0 group-data-[collapsible=icon]:-mt-9")}>
{props.children}
</div>
);
}
type CollectionButtonProps = {
id: string;
name: string;
icon: IconName | "";
color: ColorName | "";
}
function CollectionButton(props: CollectionButtonProps) {
const icon = props.icon && icons[props.icon];
const color = props.color ? colors[props.color] : colors.white;
return (
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Link
to="/app/collection/$id"
params={{ id: props.id }}
className="w-full flex items-center gap-2 group/item"
>
<div className="flex items-center justify-center h-4 w-4">
{icon && <Icon path={icon.path} size={0.75} color={color.base} />}
</div>
<span
className="group-hover/item:text-[var(--hover-color)]"
style={{ "--hover-color": color.hover, }}
>
{props.name}
</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
function SidebarToggle() {
const isMobile = useIsMobile();
const { toggleSidebar } = useSidebar()
if (isMobile) {
return null;
}
return (
<SidebarMenuItem>
<SidebarMenuButton asChild>
<Button
data-sidebar="trigger"
variant="ghost"
size="icon"
onClick={toggleSidebar}
className="justify-start"
>
<PanelLeft />
<span>Toggle Sidebar</span>
</Button>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
function InboxButton() {
const notes = useCollectionNotesMetadata("");
const count = notes.length;
return (
<NavButton href="/app/inbox" label="Inbox" icon={<Inbox />}>
<SidebarMenuBadge>{count}</SidebarMenuBadge>
</NavButton>
);
}
function NavUser() {
const isMobile = useIsMobile();
const [theme, setTheme] = useTheme();
// const { data: session } = useSession();
// const user = session?.user;
const user = {
name: "Kalle Struik",
email: "kalle@kallestruik.nl"
};
const email = user?.email ?? undefined;
const name = user?.name ?? undefined
function handleSignOut() {
// // End the session
// signOut();
// // Clear all locally stored data
// localStorage.clear();
// indexedDB.databases().then((dbs) => {
// dbs.forEach((db) => {
// db.name && indexedDB.deleteDatabase(db.name);
// });
// });
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<UserAvatar user={user} className="h-8 w-8 rounded-lg" />
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs">{email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
side={isMobile ? "top" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<UserAvatar user={user} className="h-8 w-8 rounded-lg" />
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">{name}</span>
<span className="truncate text-xs">{email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuLabel className="py-0">
<div className="flex flex-row place-content-between w-full items-center">
<div className="flex flex-row items-center gap-2">
<Palette className="size-4 text-muted-foreground" />
Theme
</div>
<ToggleGroup type="single" size="sm" defaultValue={theme}>
<ToggleGroupItem value="system" onClick={() => setTheme("system")}><Monitor /></ToggleGroupItem>
<ToggleGroupItem value="light" onClick={() => setTheme("light")}><Sun /></ToggleGroupItem>
<ToggleGroupItem value="dark" onClick={() => setTheme("dark")}><Moon /></ToggleGroupItem>
</ToggleGroup>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleSignOut}>
<LogOut />
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
);
}

View file

@ -0,0 +1,46 @@
import { mdiCog } from "@mdi/js";
import Icon from "@mdi/react";
import { colors } from "~/lib/color";
import { icons } from "~/lib/icon";
import { CollectionSettingsDialog } from "~/components/collection/collection_settings_dialog";
import { Skeleton } from "~/components/ui/skeleton";
import { useCollection } from "~/hooks/use-metadata";
import type { CollectionId } from "~/lib/metadata";
type CollectionHeaderProps = {
collectionId: CollectionId;
};
export function CollectionHeader(props: CollectionHeaderProps) {
const collection = useCollection(props.collectionId);
const color = collection ? colors[collection.get("color")] : undefined;
const icon = collection ? icons[collection.get("icon")] : undefined;
return (
<>
<div className="w-full bg-sidebar flex flex-row items-center">
<div className="p-2 flex justify-center items-center">
{collection
? (icon && <Icon path={icon.path} size={1.5} color={color!.base} />)
: <Skeleton className="w-9 h-9" />
}
</div>
{collection
? <div className="p-2 flex-grow font-bold text-xl">{collection.get("name")}</div>
: <div className="p-2 flex-grow"><Skeleton className="h-7 max-w-full w-[200px]" /></div>
}
<CollectionSettingsDialog
name={collection?.get("name")}
collectionId={props.collectionId}
>
<button
className="p-4 flex justify-center items-center hover:bg-sidebar-accent"
>
<Icon path={mdiCog} size={1} />
</button>
</CollectionSettingsDialog>
</div>
</>
);
}

View file

@ -0,0 +1,211 @@
import * as Y from "yjs";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Button } from "~/components/ui/button";
import { ColorPicker } from "../form/color_picker";
import { Label } from "~/components/ui/label";
import { IconPicker } from "../form/icon_picker";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "~/components/ui/alert-dialog";
import { useCollection, useCollections } from "~/hooks/use-metadata";
import { type CollectionId, type CollectionProperty, deleteCollection } from "~/lib/metadata";
import Icon from "@mdi/react";
import { mdiPin, mdiPinOff, mdiPlus, mdiTrashCan } from "@mdi/js";
import { createCollectionProperty, deleteCollectionProperty } from "~/lib/property";
import { useEffect } from "react";
import { PropertyTypeCombobox } from "../form/property_type_combobox";
type CollectionSettingsDialogProps = {
name?: string;
collectionId: CollectionId;
children: React.ReactNode;
};
export function CollectionSettingsDialog(props: CollectionSettingsDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>
{props.children}
</DialogTrigger>
<DialogContent className="max-w-[800px]">
<DialogHeader>
<DialogTitle>{props.name} settings</DialogTitle>
<DialogDescription>
Settings for the {props.name} collection.
</DialogDescription>
</DialogHeader>
<div className="h-[600px] overflow-scroll flex flex-col gap-4">
<SettingsSection collectionId={props.collectionId} />
<PropertiesSection collectionId={props.collectionId} />
<DangerSection collectionId={props.collectionId} />
</div>
</DialogContent>
</Dialog>
);
}
function SettingsSection(props: { collectionId: CollectionId }) {
const collection = useCollection(props.collectionId)
if (!collection) {
return null;
}
return (
<div className="flex flex-col gap-2 p-2">
<div className="flex flex-row gap-2">
<IconPicker
initialValue={collection.get("icon")}
onChange={(icon) => collection.set("icon", icon ?? "")}
/>
<div className="grid flex-grow items-center gap-1.5">
<Label htmlFor="name">Name</Label>
<Input
id="name"
className="flex-grow"
defaultValue={collection.get("name")}
placeholder="Name..."
onChange={(name) => collection.set("name", name.currentTarget.value)}
/>
</div>
</div>
<ColorPicker
initialValue={collection.get("color")}
onChange={(color) => collection.set("color", color)}
/>
</div>
);
}
function PropertiesSection(props: { collectionId: CollectionId }) {
const collection = useCollection(props.collectionId)
if (!collection) {
return null;
}
// Ensure that the properties array exists on the collection
useEffect(() => {
if (!collection.has("properties")) {
collection.set("properties", new Y.Array() as any)
}
}, [collection])
const properties = collection.get("properties");
return (
<div>
<Header label="Properties">
<Button
variant="ghost"
onClick={() => properties && createCollectionProperty(properties)}
>
<Icon path={mdiPlus} size={.75} />
</Button>
</Header>
<div className="flex flex-col gap-2 p-2">
{properties?.map((property) =>
<PropertyItem key={property.get("id")}
property={property}
onDelete={() => deleteCollectionProperty(properties, property.get("id"))}
/>
)}
</div>
</div>
);
}
type PropertyItemProps = {
property: CollectionProperty,
onDelete: () => void,
}
function PropertyItem(props: PropertyItemProps) {
const { property: prop } = props;
return (
<div className="flex flex-row gap-2">
<Input
className="flex-grow"
defaultValue={prop.get("name")}
placeholder="Name..."
onChange={(event) => prop.set("name", event.target.value)}
/>
<PropertyTypeCombobox
initialValue={prop.get("type")}
onSelect={(type) => prop.set("type", type)}
/>
<Button
variant="outline"
onClick={() => prop.set("pinned", !prop.get("pinned"))}
>
<Icon path={prop.get("pinned") ? mdiPinOff : mdiPin} size={.75} />
</Button>
<Button
variant="destructive"
onClick={props.onDelete}
>
<Icon path={mdiTrashCan} size={.75} />
</Button>
</div>
);
}
function DangerSection(props: { collectionId: CollectionId }) {
const collections = useCollections();
return (
<div>
<Header label="Danger zone">
</Header>
<div className="flex flex-row gap-2 p-2 items-center">
<div className="flex-grow flex flex-col gap-1">
<span className="text-sm font-bold">Delete collection</span>
<span className="text-xs text-zinc-400">Delete the collection and all items contained within. WARNING: This action is irreversible!</span>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
>
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the collection and ALL items contained in it.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
variant="destructive"
// TODO: Navigate away from the page when clicked
onClick={() => deleteCollection(collections, props.collectionId)}
>
Delete
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
);
}
type HeaderProps = {
label: string,
children?: React.ReactNode,
}
function Header(props: HeaderProps) {
return (
<div className="flex flex-row gap-2 pr-2 items-center">
<h1 className="font-bold flex-grow">{props.label}</h1>
{props.children}
</div>
);
}

View file

@ -0,0 +1,76 @@
import { useState } from "react";
import { type ColorName } from "~/lib/color";
import { type IconName } from "~/lib/icon";
import { ColorPicker } from "../form/color_picker";
import { IconPicker } from "../form/icon_picker";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Label } from "~/components/ui/label";
import { Input } from "~/components/ui/input";
import { createCollection } from "~/lib/metadata";
import { useCollections } from "~/hooks/use-metadata";
import { useNavigate } from "@tanstack/react-router";
type NewCollectionDialogProps = {
children: React.ReactNode;
}
export function NewCollectionDialog(props: NewCollectionDialogProps) {
const collections = useCollections();
const [name, setName] = useState("");
const [icon, setIcon] = useState<IconName>();
const [color, setColor] = useState<ColorName>("white");
const navigate = useNavigate();
function submit() {
const newCollection = createCollection(collections, {
name,
icon: icon ?? "",
color,
})
setName("");
setIcon(undefined);
setColor("white");
navigate({ to: "/app/collection/$id", params: { id: newCollection.get("id") } });
}
return (
<Dialog>
<DialogTrigger asChild>
{props.children}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New collection</DialogTitle>
<DialogDescription>
Settings for the new collection
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 p-2">
<div className="flex flex-row gap-2">
<IconPicker onChange={setIcon} />
<div className="grid flex-grow items-center gap-1.5">
<Label htmlFor="name">Name</Label>
<Input
id="name"
className="flex-grow"
placeholder="Name..."
onChange={(name) => setName(name.target.value)}
/>
</div>
</div>
<ColorPicker onChange={setColor} />
</div>
<DialogFooter>
<DialogClose asChild>
<Button
onClick={submit}
>Create</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,17 @@
import { mdiOpenInNew } from "@mdi/js";
import Icon from "@mdi/react";
import { cn } from "~/lib/utils";
export function LinkIcon(props: { url: string }) {
return (
<a className={cn(
"link-icon",
"inline-flex align-text-bottom items-center justify-center ml-0.5",
)}
href={props.url}
target="_blank"
>
<Icon path={mdiOpenInNew} size={.75} />
</a>
);
}

View file

@ -0,0 +1,13 @@
import Icon from "@mdi/react";
import { cn } from "~/lib/utils";
export function TaskIcon(props: { icon?: string }) {
return (
<div className={cn(
"task-icon",
"rounded border border-white align-text-bottom mr-2 select-none w-5 h-5 inline-flex justify-center items-center"
)}>
{props.icon && <Icon path={props.icon} size={1} />}
</div>
);
}

View file

@ -0,0 +1,18 @@
import { mdiHelp } from "@mdi/js";
import Icon from "@mdi/react";
import { Link } from "@tanstack/react-router";
import { cn } from "~/lib/utils";
export function TermIcon(props: { term: string }) {
return (
<Link className={cn(
"term-icon",
"align-text-bottom select-none inline-flex justify-center items-center"
)}
to="/app/term/$term"
params={{ term: props.term }}
>
<Icon path={mdiHelp} size={.75} />
</Link>
);
}

View file

@ -0,0 +1,85 @@
import Icon from "@mdi/react";
import { ChevronsUpDown } from "lucide-react";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
import { useCollection, useCollections } from "~/hooks/use-metadata";
import { colors } from "~/lib/color";
import { icons } from "~/lib/icon";
import { type CollectionId } from "~/lib/metadata";
import { cn } from "~/lib/utils";
type CollectionPickerProps = {
onChange?: (collectionId?: CollectionId) => void;
className?: string;
}
export function CollectionPicker(props: CollectionPickerProps) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState<CollectionId | undefined>();
const collections = useCollections();
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-[200px] justify-between", props.className)}
>
<Entry collectionId={value} />
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search collections..." />
<CommandList>
<CommandEmpty>No collection found.</CommandEmpty>
<CommandGroup>
<CommandItem
value="none"
onSelect={() => {
setValue(undefined);
setOpen(false);
props.onChange && props.onChange(undefined);
}}
>
<Entry collectionId={undefined} />
</CommandItem>
{collections.map((collection) => (
<CommandItem
key={collection.get("id")}
value={collection.get("name")}
onSelect={() => {
setValue(collection.get("id"));
setOpen(false);
props.onChange && props.onChange(collection.get("id"));
}}
>
<Entry collectionId={collection.get("id")} />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function Entry(props: { collectionId?: CollectionId }) {
const collection = useCollection(props.collectionId)
return (
<div className="flex flex-row w-full gap-3 items-center">
<div className="h-4 w-4 flex items-center justify-center">
{collection?.get("icon") && <Icon color={colors[collection.get("color")].base} path={icons[collection.get("icon")]!.path} size={1} />}
</div>
<span style={{ color: collection ? colors[collection.get("color")].hover : undefined }}>{collection?.get("name") ?? "None"}</span>
</div>
);
}

View file

@ -0,0 +1,84 @@
import { ChevronsUpDown } from "lucide-react";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
import { type Color, type ColorName, colors } from "~/lib/color";
import { cn } from "~/lib/utils";
type ColorPickerProps = ({
onChange?: (color: ColorName) => void;
withNone?: false;
} | {
onChange?: (color?: ColorName) => void;
withNone: true;
}) & {
initialValue?: ColorName;
className?: string;
}
export function ColorPicker(props: ColorPickerProps) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState<ColorName | undefined>(props.initialValue ?? (props.withNone ? undefined : "white"));
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-[200px] justify-between", props.className)}
>
<Entry color={value ? colors[value] : undefined} />
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search colors..." />
<CommandList>
<CommandEmpty>No color found.</CommandEmpty>
<CommandGroup>
{props.withNone && <CommandItem
value={"none"}
onSelect={() => {
setValue(undefined)
setOpen(false)
props.onChange && props.onChange(undefined)
}}
>
<Entry color={undefined} />
</CommandItem>}
{Object.entries(colors).map(([key, value]) => (
<CommandItem
key={key}
value={key}
onSelect={(currentValue) => {
setValue(currentValue as ColorName)
setOpen(false)
props.onChange && props.onChange(currentValue as ColorName)
}}
>
<Entry color={value} />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function Entry(props: { color?: Color }) {
return (
<div className="flex flex-row w-full gap-2 items-center">
<div
className="h-4 w-4"
style={{ backgroundColor: props.color?.base }}
/>
<span style={{ color: props.color?.hover }}>{props.color?.label ?? "None"}</span>
</div>
);
}

View file

@ -0,0 +1,82 @@
import Icon from "@mdi/react";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
import { type Icon as IconType, type IconName, icons } from "~/lib/icon";
type IconPickerProps = ({
onChange?: (icon: IconName) => void;
withNone?: false;
} | {
onChange?: (icon?: IconName) => void;
withNone: true;
}) & {
initialValue?: IconName;
className?: string;
}
// TODO: Make this faster by not showing all icons at once
export function IconPicker(props: IconPickerProps) {
const [value, setValue] = useState<IconName | undefined>(props.initialValue);
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="h-full aspect-square"
>
{value && <Entry icon={icons[value] as IconType} />}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search types..." />
<CommandList>
<CommandEmpty>No icon found.</CommandEmpty>
<CommandGroup>
{props.withNone && <CommandItem
value={"none"}
onSelect={() => {
setValue(undefined)
setOpen(false)
props.onChange && props.onChange(undefined)
}}>
<Entry withLabel />
</CommandItem>}
{Object.entries(icons).map(([key, value]) => (
<CommandItem
key={key}
value={key}
onSelect={(currentValue) => {
setValue(currentValue as IconName)
setOpen(false)
props.onChange && props.onChange(currentValue as IconName)
}}
>
<Entry icon={value} withLabel />
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function Entry(props: { icon?: IconType, withLabel?: boolean }) {
const withLabel = props.withLabel ?? false;
return (
<div className="flex flex-row w-full gap-2 items-center">
<div className="h-6 w-6">
{props.icon && <Icon path={props.icon.path} size={1} />}
</div>
{withLabel && <span>{props.icon?.name ?? "None"}</span>}
</div>
);
}

View file

@ -0,0 +1,57 @@
import { useState } from "react"
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"
import { ChevronsUpDown } from "lucide-react"
import { properties, type PropertyType } from "~/lib/property"
import { Button } from "~/components/ui/button"
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command"
type PropertyTypeComboboxProps = {
initialValue: PropertyType
onSelect: (value: PropertyType) => void
}
export function PropertyTypeCombobox(props: PropertyTypeComboboxProps) {
const [open, setOpen] = useState(false)
const [value, setValue] = useState(props.initialValue)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-[200px] justify-between"
>
{value in properties
? properties[value as PropertyType]
: "Select type..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="Search types..." />
<CommandList>
<CommandEmpty>No type found.</CommandEmpty>
<CommandGroup>
{Object.entries(properties).map(([key, value]) => (
<CommandItem
key={key}
value={key}
onSelect={(currentValue) => {
setValue(currentValue as PropertyType)
setOpen(false)
props.onSelect(currentValue as PropertyType)
}}
>
{value}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View file

@ -0,0 +1,120 @@
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { ColorPicker } from "~/components/form/color_picker";
import type { ColorName } from "~/lib/color";
import { IconPicker } from "~/components/form/icon_picker";
import type { IconName } from "~/lib/icon";
import { CollectionPicker } from "~/components/form/collection_picker";
import { type CollectionId, createNote } from "~/lib/metadata";
import { useNotesMetadata } from "~/hooks/use-metadata";
import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";
import { useNavigate } from "@tanstack/react-router";
type NewNoteDialogProps = {
children: React.ReactNode;
}
export function NewNoteDialog(props: NewNoteDialogProps) {
const noteMetadata = useNotesMetadata();
const [name, setName] = useState("");
const [icon, setIcon] = useState<IconName>();
const [collection, setCollection] = useState<CollectionId | undefined>();
const [primaryColor, setPrimaryColor] = useState<ColorName | undefined>();
const [secondaryColor, setSecondaryColor] = useState<ColorName | undefined>();
const [type, setType] = useState<"text" | "canvas">("text");
const navigate = useNavigate();
function submit() {
const newNote = createNote(noteMetadata, {
name,
icon,
collectionId: collection,
primaryColor,
secondaryColor,
type,
});
setName("");
setIcon(undefined);
setCollection(undefined);
setPrimaryColor(undefined);
setSecondaryColor(undefined);
setType("text");
navigate({ to: "/app/note/$id", params: { id: newNote.get("id") } });
}
return (
<Dialog>
<DialogTrigger asChild>
{props.children}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>New note</DialogTitle>
<DialogDescription>
Settings for the new collection
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 p-2">
<div className="flex flex-row gap-2">
<IconPicker onChange={setIcon} />
<div className="grid flex-grow items-center gap-1.5">
<Label htmlFor="name">Name</Label>
<Input
id="name"
className="flex-grow"
placeholder="Name..."
onChange={(name) => setName(name.target.value)}
/>
</div>
</div>
<div className="grid flex-grow items-center gap-1.5">
<Label>Collection</Label>
<CollectionPicker onChange={setCollection} className="w-full flex-grow" />
</div>
<div className="flex flex-row gap-2">
<div className="grid flex-grow items-center gap-1.5">
<Label>Primary color</Label>
<ColorPicker withNone onChange={setPrimaryColor} className="w-full flex-grow" />
</div>
<div className="grid flex-grow items-center gap-1.5">
<Label>Secondary color</Label>
<ColorPicker withNone onChange={setSecondaryColor} className="w-full flex-grow" />
</div>
</div>
<div className="grid flex-grow items-center gap-1.5">
<Label>Type</Label>
<RadioGroup
onValueChange={setType as any}
defaultValue={type}
className="flex flex-row space-y-1 items-center"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="text" id="type-text" />
<Label htmlFor="type-text">Text</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="canvas" id="type-canvas" />
<Label htmlFor="type-canvas">Canvas</Label>
</div>
</RadioGroup>
</div>
</div>
<DialogFooter>
<DialogClose asChild>
<Button
onClick={() => submit()}
>
Create
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,76 @@
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
import { ExcalidrawBinding, yjsToExcalidraw } from "y-excalidraw"
import { Button, Excalidraw } from "@excalidraw/excalidraw";
import * as Y from "yjs";
import { useEffect, useMemo, useRef, useState } from "react";
import "@excalidraw/excalidraw/index.css";
import { useNoteMetadata } from "~/hooks/use-metadata";
import { Settings } from "lucide-react";
import { NoteSettingsDialog } from "./note_settings_dialog";
import { useNoteArray, useNoteAwareness, useNoteMap } from "~/hooks/use-note";
import { useEffectiveTheme } from "~/hooks/use-theme";
export default function NoteCanvas(props: { noteId: string }) {
const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null);
const [binding, setBindings] = useState<ExcalidrawBinding | null>(null);
const excalidrawRef = useRef(null);
const metadata = useNoteMetadata(props.noteId);
const theme = useEffectiveTheme();
const yElements = useNoteArray("elements") as Y.Array<any>;
const yAssets = useNoteMap("assets");
const awareness = useNoteAwareness();
useEffect(() => {
if (!api) return;
const binding = new ExcalidrawBinding(
yElements,
yAssets,
api,
awareness,
// excalidraw dom is needed to override the undo/redo buttons in the UI as there is no way to override it via props in excalidraw
// You might need to pass {trackedOrigins: new Set()} to undomanager depending on whether your provider sets an origin or not
{ excalidrawDom: excalidrawRef.current!, undoManager: new Y.UndoManager(yElements) },
);
setBindings(binding);
return () => {
setBindings(null);
binding.destroy();
};
}, [api]);
const initData = {
elements: yjsToExcalidraw(yElements)
}
const excalidraw = useMemo(() => <Excalidraw
initialData={initData} // Need to set the initial data
excalidrawAPI={setApi}
onPointerUpdate={binding?.onPointerUpdate}
theme={theme}
UIOptions={{
canvasActions: {
toggleTheme: false,
},
}}
renderTopRightUI={() => (<>
{metadata && <NoteSettingsDialog noteMetadata={metadata}>
<Button
style={{ width: "2.25rem", height: "2.25rem" }}
onSelect={() => { }}
>
<Settings />
</Button>
</NoteSettingsDialog>}
</>)}
/>, []);
return (
<div className="w-full h-full" ref={excalidrawRef}>
{excalidraw}
</div>
);
}

View file

@ -0,0 +1,96 @@
import Icon from "@mdi/react";
import { colors } from "~/lib/color";
import { icons } from "~/lib/icon";
import { NotePropertiesDialog } from "~/components/note/note_properties_dialog";
import { mdiCircle, mdiCog } from "@mdi/js";
import { NoteSettingsDialog } from "~/components/note/note_settings_dialog";
import { useCollection, useNoteMetadata } from "~/hooks/use-metadata";
import type { NoteId, NoteMetadata } from "~/lib/metadata";
export function NoteHeader(props: { id: NoteId }) {
const noteMetadata = useNoteMetadata(props.id);
if (!noteMetadata) {
return null;
}
const primaryColorName = noteMetadata.get("primaryColor");
const secondaryColorName = noteMetadata.get("secondaryColor");
const primaryColor = primaryColorName ? colors[primaryColorName] : colors.white;
const secondaryColor = secondaryColorName ? colors[secondaryColorName] : primaryColor;
return (
<>
<div className="flex-shrink-0 w-full h-[300px] relative shadow mb-[42px]" style={{
background: `linear-gradient(135deg, ${primaryColor.base}, ${secondaryColor.base})`
}}>
<div className="flex flex-row justify-end">
<NoteSettingsDialog noteMetadata={noteMetadata}>
<button
className="m-2 p-2 flex justify-center items-center bg-secondary bg-opacity-50 hover:bg-opacity-90 rounded"
>
<Icon path={mdiCog} size={1} />
</button>
</NoteSettingsDialog>
</div>
<div className="absolute left-8 bottom-[-42px] bg-sidebar rounded-xl flex items-center justify-center h-32 w-32 border-4 border-background">
{noteMetadata.get("icon") && <Icon path={icons[noteMetadata.get("icon")]!.path} color={primaryColor.base} size={4} />}
</div>
</div>
<div className="ml-8 flex flex-col gap-1">
<div className="flex flex-row gap-1">
{<input
className="bg-transparent outline-none font-bold text-3xl flex-grow"
defaultValue={noteMetadata.get("title")}
onChange={(e) => noteMetadata.set("title", e.target.value)}
/>}
</div>
<Properties noteMetadata={noteMetadata} />
</div>
</>
);
}
function Properties(props: { noteMetadata: NoteMetadata }) {
const { noteMetadata } = props;
const collection = useCollection(noteMetadata.get("collectionId"));
if (!collection) {
return <span className="text-muted">No properties</span>;
}
const collectionProps = collection.get("properties")?.toArray();
if (!collectionProps) {
return <span className="text-muted">No properties</span>;
}
const pinnedProperties = collectionProps.filter(property => property.get("pinned"));
return (
<NotePropertiesDialog noteMetadata={noteMetadata}>
<button className="flex flex-row w-fit rounded gap-2 py-1 px-2 hover:bg-accent items-center">
{pinnedProperties.length === 0
? <span className="text-zinc-400">No pinned properties</span>
: pinnedProperties.flatMap(property => {
const propertyId = property.get("id");
const value = noteMetadata.get("properties")
?.toArray()
.find(it => it.get("propertyId") == propertyId)
?.get("value");
const seperator = <Icon key={`${propertyId}-sep`} className="text-muted text-2xl" path={mdiCircle} size={.25} />;
if (!value) {
return [seperator, <span key={propertyId} className="text-muted">
{property.get("name")}
</span>]
} else {
return [seperator, <span key={propertyId} className="text-muted">
{value}
</span>]
}
}).slice(1)}
</button>
</NotePropertiesDialog>
);
}

View file

@ -0,0 +1,114 @@
import * as Y from "yjs";
import { useCallback, useEffect } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import type { NoteMetadata, NoteProperty } from "~/lib/metadata";
import { createNoteProperty, type PropertyType } from "~/lib/property";
import { useCollection } from "~/hooks/use-metadata";
type NotePropertiesDialogProps = {
noteMetadata: NoteMetadata;
children: React.ReactNode;
};
export function NotePropertiesDialog(props: NotePropertiesDialogProps) {
const { noteMetadata } = props;
if (!noteMetadata) {
return props.children;
}
return (
<Dialog>
<DialogTrigger asChild>
{props.children}
</DialogTrigger>
<DialogContent className="max-w-[800px]">
<DialogHeader>
<DialogTitle>{noteMetadata.get("title")} properties</DialogTitle>
<DialogDescription>
Properties for the {noteMetadata.get("title")} note
</DialogDescription>
</DialogHeader>
<div className="h-[600px] overflow-scroll flex flex-col gap-4">
{noteMetadata.has("collectionId")
? <PropertiesSection noteMetadata={noteMetadata} />
: <span>Only notes that belong to a collection can have properties</span>
}
</div>
</DialogContent>
</Dialog>
);
}
function PropertiesSection(props: { noteMetadata: NoteMetadata }) {
const { noteMetadata } = props;
const collection = useCollection(noteMetadata.get("collectionId"));
useEffect(() => {
if (!noteMetadata.has("properties")) {
noteMetadata.set("properties", new Y.Array() as any)
}
}, [noteMetadata])
const noteProperties = noteMetadata.get("properties");
if (!noteProperties) {
return null;
}
if (!collection) {
return null;
}
const collectionProps = collection.get("properties")?.toArray();
if (!collectionProps) {
return null;
}
return (
<div className="flex flex-col gap-2 p-2">
{collectionProps.map((property) => {
const propertyId = property.get("id");
const noteProperty = noteProperties
?.toArray()
.find(it => it.get("propertyId") == propertyId)
return <PropertyItem key={propertyId}
noteProperty={noteProperty}
name={property.get("name")}
type={property.get("type")}
create={() => createNoteProperty(noteProperties, { propertyId })}
/>
})}
</div>
);
}
type PropertyItemProps = {
noteProperty?: NoteProperty;
name: string;
type: PropertyType;
create: () => NoteProperty;
}
function PropertyItem(props: PropertyItemProps) {
const handleChange = useCallback((value: string) => {
if (props.noteProperty) {
props.noteProperty.set("value", value);
} else {
props.create().set("value", value);
}
}, [props.noteProperty, props.create]);
return (
<div className="flex flex-row gap-2 items-center">
<span className="w-[200px]">{props.name}</span>
<Input
className="flex-grow"
defaultValue={props.noteProperty?.get("value")}
placeholder={`${props.name}...`}
onChange={(value) => handleChange(value.currentTarget.value)}
/>
</div>
);
}

View file

@ -0,0 +1,75 @@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
import { IconPicker } from "../form/icon_picker";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { ColorPicker } from "../form/color_picker";
import type { NoteMetadata } from "~/lib/metadata";
type NoteSettingsDialogProps = {
noteMetadata: NoteMetadata;
children: React.ReactNode;
};
export function NoteSettingsDialog(props: NoteSettingsDialogProps) {
const { noteMetadata } = props;
if (!noteMetadata) {
return props.children;
}
const primaryColor = noteMetadata.get("primaryColor");
const secondaryColor = noteMetadata.get("secondaryColor");
return (
<Dialog>
<DialogTrigger asChild>
{props.children}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>{noteMetadata.get("title")} settings</DialogTitle>
<DialogDescription>
Settings for {noteMetadata.get("title")}.
</DialogDescription>
</DialogHeader>
{<div className="flex flex-col gap-2 p-2">
<div className="flex flex-row gap-2">
<IconPicker withNone
onChange={(newIcon) => noteMetadata.set("icon", newIcon ?? "")}
initialValue={noteMetadata.get("icon") == "" ? undefined : noteMetadata.get("icon")}
/>
<div className="grid flex-grow items-center gap-1.5">
<Label htmlFor="name">Name</Label>
<Input
id="name"
className="flex-grow"
placeholder="Name..."
defaultValue={noteMetadata.get("title")}
onChange={(name) => noteMetadata.set("title", name.target.value)}
/>
</div>
</div>
<div className="flex flex-row gap-2">
<div className="grid flex-grow items-center gap-1.5">
<Label>Primary color</Label>
<ColorPicker
withNone
onChange={(color) => noteMetadata.set("primaryColor", color ?? "")}
initialValue={primaryColor == "" ? undefined : primaryColor}
className="w-full flex-grow"
/>
</div>
<div className="grid flex-grow items-center gap-1.5">
<Label>Secondary color</Label>
<ColorPicker
withNone
onChange={(color) => noteMetadata.set("secondaryColor", color ?? "")}
initialValue={secondaryColor == "" ? undefined : secondaryColor}
className="w-full flex-grow"
/>
</div>
</div>
</div>}
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,105 @@
import { mdiMagnify } from "@mdi/js";
import Icon from "@mdi/react";
import { Link } from "@tanstack/react-router";
import { Command } from "cmdk";
import { colors } from "~/lib/color";
import { icons } from "~/lib/icon";
import type { NoteMetadata } from "~/lib/metadata";
type NotesGridProps = {
notes?: NoteMetadata[];
allUnpinned?: boolean;
};
export function NotesGrid(props: NotesGridProps) {
const allUnpinned = props.allUnpinned ?? false;
const pinnedNotes = props.notes?.filter(it => !allUnpinned && it.get("pinned"))?.map((note) => <DetailedNote key={note.get("id")} note={note} />);
const normalNotes = props.notes?.filter(it => allUnpinned || !it.get("pinned"))?.map((note) => <NormalNote key={note.get("id")} note={note} />);
return (
<Command className="p-4 flex flex-col gap-4 items-stretch">
<div className="bg-card rounded shadow flex flex-row items-center w-full max-w-[768px] mx-auto">
<div className="p-2 flex justify-center items-center">
<Icon path={mdiMagnify} size={1.5} />
</div>
<Command.Input asChild>
<input type="text" placeholder="Search..." className="flex-grow bg-transparent py-4 px-2 outline-none" />
</Command.Input>
</div>
<Command.List className="[&>div]:flex [&>div]:flex-col [&>div]:gap-4">
<Command.Empty>No notes found</Command.Empty>
<Command.Group
value="pinned"
className="[&>div]:grid [&>div]:w-full [&>div]:gap-4 [&>div]:grid-cols-[repeat(auto-fill,minmax(500px,1fr))]"
>
{pinnedNotes}
</Command.Group>
<Command.Group
value="unpinned"
className="[&>div]:grid [&>div]:w-full [&>div]:gap-4 [&>div]:grid-cols-[repeat(auto-fill,minmax(300px,1fr))]"
>
{normalNotes}
</Command.Group>
</Command.List>
</Command>
);
}
type DetailedNoteProps = {
note: NoteMetadata;
}
function DetailedNote(props: DetailedNoteProps) {
const { note } = props;
const primaryColorName = note.get("primaryColor")
const secondaryColorName = note.get("secondaryColor")
const primaryColor = primaryColorName ? colors[primaryColorName] : colors.white;
const secondaryColor = secondaryColorName ? colors[secondaryColorName] : primaryColor;
const icon = note.get("icon") ? icons[note.get("icon")] : undefined;
return (
<Command.Item asChild value={note.get("title")}>
<Link to="/app/note/$id" params={{ id: note.get("id") }} className="bg-card shadow shadow-black rounded overflow-hidden relative">
<div className="w-full h-[100px]" style={{
background: `linear-gradient(135deg, ${primaryColor.base}, ${secondaryColor.base})`
}} />
{icon && <div className="inline-block bg-zinc-900 absolute top-[58px] left-4 rounded-xl p-2">
<Icon path={icon.path} size={2} color={primaryColor.base} />
</div>}
<div className="px-4 pt-2 pb-4 mt-[22px]">
<h1 className="ml-2 font-bold text-xl">{note.get("title")}</h1>
</div>
</Link>
</Command.Item>
);
}
type NormalNoteProps = {
note: NoteMetadata;
};
function NormalNote(props: NormalNoteProps) {
const { note } = props;
const colorName = note.get("primaryColor");
const color = colorName ? colors[colorName] : colors.white;
const icon = note.get("icon") ? icons[note.get("icon")] : undefined;
return (
<Command.Item asChild value={note.get("title")}>
<Link to="/app/note/$id" params={{ id: note.get("id") }} className="bg-card shadow rounded overflow-hidden flex flex-row">
<div className="w-1 h-full" style={{ backgroundColor: color.base }} />
<div className="flex flex-col gap-2 px-4 py-2 justify-center">
<div className="flex flex-row gap-2 items-center">
{icon && <Icon path={icon.path} size={1.5} color={color.base} />}
<h1 className="font-bold text-xl">{note.get("title")}</h1>
</div>
</div>
</Link>
</Command.Item>
);
}

View file

@ -0,0 +1,155 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "~/lib/utils"
import { buttonVariants } from "~/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View file

@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "~/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }

View file

@ -0,0 +1,175 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "~/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View file

@ -0,0 +1,133 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View file

@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View file

@ -0,0 +1,22 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "~/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

View file

@ -0,0 +1,46 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "~/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View file

@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "~/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }

View file

@ -308,7 +308,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
"md:group-peer-data-[variant=inset]:m-2 md:group-peer-data-[variant=inset]:ml-0 md:group-peer-data-[variant=inset]:rounded-xl md:group-peer-data-[variant=inset]:shadow-sm",
className
)}
{...props}

View file

@ -0,0 +1,71 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
import { toggleVariants } from "~/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
})
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }

View file

@ -0,0 +1,47 @@
"use client"
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }

View file

@ -0,0 +1,51 @@
import { useEffect, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
type User = {
name?: string;
email?: string;
image?: string;
};
type UserAvatarProps = {
className?: string;
user?: User;
};
export function UserAvatar(props: UserAvatarProps) {
const email = props.user?.email ?? undefined;
const gravatar = useGravatarUrl(email);
const avatar = props.user?.image ?? gravatar;
const name = props.user?.name ?? undefined
const initials = getInitials(name);
return (
<Avatar className={props.className}>
<AvatarImage src={avatar} alt={name} />
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
);
}
function useGravatarUrl(email?: string, size = 80) {
const [url, setUrl] = useState<string>();
useEffect(() => {
if (!email) return;
const trimmedEmail = email.trim().toLowerCase();
crypto.subtle.digest('SHA-256', new TextEncoder().encode(trimmedEmail)).then(hashBuffer => {
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
setUrl(`https://www.gravatar.com/avatar/${hashHex}?s=${size}&d=identicon`);
});
}, [email, size]);
return url;
}
function getInitials(name?: string) {
if (!name) return "";
const parts = name.split(" ");
return parts.map(part => part[0]).join("");
}

View file

@ -0,0 +1,64 @@
import * as Y from "yjs";
import { useCollections, useNotesMetadata } from "~/hooks/use-metadata";
export function MetadataInspector() {
const collections = useCollections()
const notes = useNotesMetadata()
return <div>
<span>Collections: <YjsInspector toRender={collections} /></span>
<span>Notes: <YjsInspector toRender={notes} /></span>
</div>
}
export function YjsInspector(props: { toRender: any }) {
return <YjsNode node={props.toRender} depth={0} />
}
type YjsNodeProps<T> = {
node: T
depth: number
label?: string
}
function YjsNode(props: YjsNodeProps<any>) {
if (props.node instanceof Y.Map) return <YjsMap {...props} />
if (props.node instanceof Y.Array) return <YjsArray {...props} />
if (typeof props.node == "string") return <String {...props} />
return <div>
{props.label}: {props.node.toString()}
</div>
}
function YjsMap(props: YjsNodeProps<Y.Map<any>>) {
const entries = Array.from(props.node.entries())
return <div>
{props.label ? `${props.label}: ` : ""}<span>{"{"}<Badge>YMap</Badge></span>
<div style={{ paddingLeft: "2ch" }}>
{entries.map(([key, item]) => <YjsNode key={key} node={item} depth={props.depth + 1} label={key} />)}
</div>
<span>{"}"}</span>
</div>
}
function YjsArray(props: YjsNodeProps<Y.Array<any>>) {
return <div>
{props.label ? `${props.label}: ` : ""}<span>{"["}<Badge>YArray</Badge></span>
<div style={{ paddingLeft: "2ch" }}>
{props.node.map((item, index) => <YjsNode key={index} node={item} depth={props.depth + 1} />)}
</div>
<span>{"]"}</span>
</div>
}
function Badge(props: { children: React.ReactNode }) {
return <span className="px-1 py-.5 rounded bg-accent ml-1">
{props.children}
</span>
}
function String(props: YjsNodeProps<string>) {
return <div>{props.label}: "{props.node}"</div>
}

90
src/editor/Editor.tsx Normal file
View file

@ -0,0 +1,90 @@
"use client";
import * as Y from "yjs";
import { type InitialConfigType, LexicalComposer } from "@lexical/react/LexicalComposer";
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
import { CollaborationPlugin } from '@lexical/react/LexicalCollaborationPlugin';
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
import { HeaderMarkerNode, HeaderNode } from "./nodes/header_node";
import { ParagraphPlugin } from "./plugins/paragraph_plugin";
import { HeaderPlugin } from "./plugins/header_plugin";
import { SelectionPlugin } from "./plugins/selection_plugin";
import { FormattedTextMarkerNode, FormattedTextNode } from "./nodes/formatted_text";
import { FormattedTextPlugin } from "./plugins/formatted_text_plugin";
import { TaskIconNode, TaskMarkerNode, TaskNode } from "./nodes/task_node";
import { TaskPlugin } from "./plugins/task_plugin";
import { LinkIconNode, LinkMarkerNode, LinkNode, LinkUrlNode } from "./nodes/link_node";
import { LinkPlugin } from "./plugins/link_plugin";
import { TermIconNode, TermMarkerNode, TermNode } from "./nodes/term_node";
import { TermPlugin } from "./plugins/term_plugin";
import { useCallback } from "react";
import type { Provider } from "@lexical/yjs";
import type { NoteId } from "~/lib/metadata";
import type { Klass, LexicalNode } from "lexical";
import { useNoteDoc, useNoteProviders } from "~/hooks/use-note";
export function Editor(props: { noteId: NoteId }) {
const provider = useNoteProviders().websocket;
const doc = useNoteDoc();
const providerFactory = useCallback((id: string, yjsDocMap: Map<string, Y.Doc>): Provider => {
// Just overwrite it, because we have the doc managed externally
yjsDocMap.set(id, doc);
return provider as any as Provider;
}, [provider, doc]);
return (
<LexicalComposer initialConfig={initialEditorConfig}>
<div className="w-full relative editor-content">
<PlainTextPlugin
contentEditable={<ContentEditable className="outline-none" />}
placeholder={<div className="absolute top-0 left-0 pointer-events-none text-zinc-300">Enter some text...</div>}
ErrorBoundary={LexicalErrorBoundary}
/>
</div>
<CollaborationPlugin
id={`note-${props.noteId}`}
providerFactory={providerFactory}
shouldBootstrap={false}
excludedProperties={excludedProperties}
/>
<ParagraphPlugin />
<SelectionPlugin />
<HeaderPlugin />
<FormattedTextPlugin />
<TaskPlugin />
<LinkPlugin />
<TermPlugin />
</LexicalComposer>
);
}
const excludedProperties = new Map<Klass<LexicalNode>, Set<string>>([
[HeaderNode, new Set(["__hasFocus"])],
[TermNode, new Set(["__hasFocus"])],
[TaskNode, new Set(["__hasFocus"])],
[LinkNode, new Set(["__hasFocus"])],
[FormattedTextNode, new Set(["__hasFocus"])],
])
const theme = {
};
const initialEditorConfig: InitialConfigType = {
namespace: "NoteEditor",
theme,
onError: console.error,
editorState: null,
nodes: [
HeaderNode, HeaderMarkerNode,
FormattedTextNode, FormattedTextMarkerNode,
TaskNode, TaskMarkerNode, TaskIconNode,
LinkNode, LinkMarkerNode, LinkUrlNode, LinkIconNode,
TermNode, TermMarkerNode, TermIconNode,
],
};

View file

@ -0,0 +1,26 @@
import { $isRootOrShadowRoot, ElementNode, LexicalNode } from "lexical";
import { $isLinkUrlNode } from "./nodes/link_node";
import { $isTermNode } from "./nodes/term_node";
export function findBlockNode(node: LexicalNode): LexicalNode {
let toCheck: LexicalNode | null = node
while (!isBlockNode(toCheck)) {
if ($isRootOrShadowRoot(node)) {
console.error("findBlockNode encountered the root node. This should not be possible!");
throw new Error("Could not find a block node in the parent chain");
}
if (toCheck === null) {
throw new Error("Could not find a block node in the parent chain");
}
toCheck = toCheck.getParent();
}
return toCheck!;
}
export function isBlockNode(node?: LexicalNode | null): boolean {
return node instanceof ElementNode && !node.isInline();
}

View file

@ -0,0 +1,32 @@
import { ElementNode, type LexicalNode } from "lexical";
export class FocusableNode extends ElementNode {
__hasFocus: boolean = false;
setFocus(hasFocus: boolean) {
const self = this.getWritable();
self.__hasFocus = hasFocus;
}
constructor(key?: string, hasFocus?: boolean) {
super(key);
hasFocus && (this.__hasFocus = hasFocus);
}
createDOM(): HTMLElement {
const element = document.createElement("div");
element.classList.toggle("focus", this.__hasFocus);
return element;
}
updateDOM(old: FocusableNode, dom: HTMLElement): boolean {
if (this.__hasFocus != old.__hasFocus) {
dom.classList.toggle("focus", this.__hasFocus);
}
return false;
}
}
export function $isFocusableNode(node: LexicalNode): node is FocusableNode {
return node instanceof FocusableNode;
}

View file

@ -0,0 +1,109 @@
import { ElementNode, type LexicalNode, type SerializedElementNode, type Spread } from "lexical";
import { FocusableNode } from "./focusable_node";
import type { TextFormat } from "../serialized_editor_content";
type SerializedTextFormatNode = Spread<SerializedElementNode, {
style: TextFormat;
}>;
export class FormattedTextNode extends FocusableNode {
__internalStyle: TextFormat;
getStyle(): TextFormat {
const self = this.getLatest();
return self.__internalStyle;
}
static getType(): string {
return "formatted-text";
}
static clone(node: FormattedTextNode): FormattedTextNode {
return new FormattedTextNode(node.__internalStyle, node.__key, node.__hasFocus);
}
constructor(style: TextFormat, key?: string, hasFocus?: boolean) {
super(key, hasFocus);
this.__internalStyle = style;
}
isInline(): boolean {
return true;
}
createDOM(): HTMLElement {
const element = super.createDOM();
element.classList.add("formatted-text");
element.classList.add(`format-${this.__internalStyle}`);
return element;
}
updateDOM(old: FormattedTextNode, dom: HTMLElement): boolean {
return super.updateDOM(old, dom);
}
exportJSON(): SerializedTextFormatNode {
return {
...super.exportJSON(),
type: "formatted-text",
style: this.__internalStyle,
};
}
static importJSON(serializedNode: SerializedTextFormatNode): FormattedTextNode {
return $createFormattedTextNode(serializedNode.style);
}
}
export function $createFormattedTextNode(style: TextFormat) {
return new FormattedTextNode(style);
}
export function $isFormattedTextNode(node?: LexicalNode | null): node is FormattedTextNode {
return node instanceof FormattedTextNode;
}
export class FormattedTextMarkerNode extends ElementNode {
static getType(): string {
return "formatted-text-marker";
}
static clone(node: FormattedTextMarkerNode): FormattedTextMarkerNode {
return new FormattedTextMarkerNode(node.__key);
}
isInline(): boolean {
return true;
}
createDOM(): HTMLElement {
const element = document.createElement("span");
element.classList.add("formatted-text-marker");
return element;
}
updateDOM(): boolean {
return false;
}
exportJSON(): SerializedElementNode {
return {
...super.exportJSON(),
type: "formatted-text-marker",
};
}
static importJSON() {
return $createFormattedTextMarkerNode();
}
}
export function $createFormattedTextMarkerNode() {
return new FormattedTextMarkerNode();
}
export function $isFormattedTextMarkerNode(node?: LexicalNode | null): node is FormattedTextMarkerNode {
return node instanceof FormattedTextMarkerNode;
}

View file

@ -0,0 +1,108 @@
import { $createParagraphNode, ElementNode, type LexicalNode, ParagraphNode, type RangeSelection, type SerializedElementNode, type Spread } from "lexical";
import { FocusableNode } from "./focusable_node";
type SerializedHeaderNode = Spread<SerializedElementNode, {
level: number;
}>;
export class HeaderNode extends FocusableNode {
__level: number;
getLevel() {
const self = this.getLatest();
return self.__level;
}
static getType() {
return "header";
}
static clone(node: HeaderNode) {
return new HeaderNode(node.__level, node.__key, node.__hasFocus);
}
constructor(level: number, key?: string, hasFocus?: boolean) {
super(key, hasFocus);
this.__level = level;
}
createDOM(): HTMLElement {
const element = super.createDOM();
element.classList.add("header", `header-${this.__level}`);
return element;
}
updateDOM(old: HeaderNode, dom: HTMLElement): boolean {
return super.updateDOM(old, dom);
}
insertNewAfter(_selection: RangeSelection, restoreSelection?: boolean): ParagraphNode {
const newElement = $createParagraphNode();
this.insertAfter(newElement, restoreSelection);
return newElement;
}
exportJSON(): SerializedHeaderNode {
return {
...super.exportJSON(),
type: "header",
level: this.__level,
};
}
static importJSON(serializedHeaderNode: SerializedHeaderNode) {
return $createHeaderNode(serializedHeaderNode.level);
}
}
export function $createHeaderNode(level: number) {
return new HeaderNode(level);
}
export function $isHeaderNode(node?: LexicalNode | null): node is HeaderNode {
return node instanceof HeaderNode;
}
export class HeaderMarkerNode extends ElementNode {
static getType() {
return "header-marker";
}
static clone(node: HeaderMarkerNode) {
return new HeaderMarkerNode(node.__key);
}
isInline(): boolean {
return true;
}
createDOM(): HTMLElement {
const element = document.createElement("span");
element.classList.add("header-marker");
return element;
}
updateDOM(): boolean {
return false;
}
exportJSON(): SerializedElementNode {
return {
...super.exportJSON(),
type: "header-marker",
};
}
static importJSON() {
return $createHeaderMarkerNode();
}
}
export function $createHeaderMarkerNode() {
return new HeaderMarkerNode();
}
export function $isHeaderMarkerNode(node?: LexicalNode | null): node is HeaderMarkerNode {
return node instanceof HeaderMarkerNode;
}

View file

@ -0,0 +1,184 @@
import { DecoratorNode, ElementNode, type LexicalNode, type SerializedElementNode, type SerializedLexicalNode, type Spread } from "lexical";
import { FocusableNode } from "./focusable_node";
import type { ReactNode } from "react";
import { LinkIcon } from "~/components/editor/link_icon";
type SerializedLinkNode = Spread<SerializedElementNode, {
url: string;
}>;
export class LinkNode extends FocusableNode {
__url: string;
getUrl(): string {
const self = this.getLatest();
return self.__url;
}
static getType() {
return "link";
}
static clone(node: LinkNode) {
return new LinkNode(node.__url, node.__key, node.__hasFocus);
}
constructor(url: string, key?: string, hasFocus?: boolean) {
super(key, hasFocus);
this.__url = url;
}
isInline(): boolean {
return true;
}
createDOM(): HTMLElement {
const element = super.createDOM();
element.classList.add("link");
return element;
}
updateDOM(old: LinkNode, dom: HTMLElement): boolean {
return super.updateDOM(old, dom);
}
exportJSON(): SerializedLinkNode {
return {
...super.exportJSON(),
type: "link",
url: this.__url,
};
}
static importJSON(serializedNode: SerializedLinkNode) {
return $createLinkNode(serializedNode.url);
}
}
export function $createLinkNode(url: string) {
return new LinkNode(url);
}
export function $isLinkNode(node?: LexicalNode | null): node is LinkNode {
return node instanceof LinkNode;
}
export class LinkMarkerNode extends ElementNode {
static getType() {
return "link-marker";
}
static clone(node: LinkMarkerNode) {
return new LinkMarkerNode(node.__key);
}
isInline(): boolean {
return true;
}
createDOM(): HTMLElement {
const element = document.createElement("span");
element.classList.add("link-marker");
return element;
}
updateDOM(): boolean {
return false;
}
exportJSON(): SerializedElementNode {
return {
...super.exportJSON(),
type: "link-marker",
};
}
static importJSON() {
return $createLinkMarkerNode();
}
}
export function $createLinkMarkerNode() {
return new LinkMarkerNode();
}
export function $isLinkMarkerNode(node?: LexicalNode | null): node is LinkMarkerNode {
return node instanceof LinkMarkerNode;
}
export class LinkUrlNode extends ElementNode {
static getType() {
return "link-url";
}
static clone(node: LinkUrlNode) {
return new LinkUrlNode(node.__key);
}
isInline(): boolean {
return true;
}
createDOM(): HTMLElement {
const element = document.createElement("span");
element.classList.add("link-url");
return element;
}
updateDOM(): boolean {
return false;
}
exportJSON(): SerializedElementNode {
return {
...super.exportJSON(),
type: "link-url",
};
}
static importJSON() {
return $createLinkUrlNode();
}
}
export function $createLinkUrlNode() {
return new LinkUrlNode();
}
export function $isLinkUrlNode(node?: LexicalNode | null): node is LinkUrlNode {
return node instanceof LinkUrlNode;
}
type SerializedLinkIconNode = Spread<SerializedLexicalNode, {
url: string;
}>;
export class LinkIconNode extends DecoratorNode<ReactNode> {
__url: string;
getUrl() {
const self = this.getLatest();
return self.__url;
}
static getType() {
return "link-icon";
}
static clone(node: LinkIconNode) {
return new LinkIconNode(node.__url, node.__key);
}
constructor(url: string, key?: string) {
super(key);
this.__url = url;
}
createDOM(): HTMLElement {
return document.createElement("span");
}
updateDOM(): boolean {
return false;
}
decorate(): ReactNode {
return (<LinkIcon url={this.__url} />);
}
exportJSON(): SerializedLinkIconNode {
return {
...super.exportJSON(),
type: "link-icon",
url: this.__url,
}
}
static importJSON(serializedNode: SerializedLinkIconNode) {
return $createLinkIconNode(serializedNode.url);
}
}
export function $createLinkIconNode(url: string) {
return new LinkIconNode(url);
}
export function $isLinkIconNode(node?: LexicalNode | null): node is LinkIconNode {
return node instanceof LinkIconNode;
}

View file

@ -0,0 +1,159 @@
import { $createParagraphNode, DecoratorNode, ElementNode, type LexicalNode, ParagraphNode, type RangeSelection, type SerializedElementNode, type SerializedLexicalNode, type Spread } from "lexical";
import { FocusableNode } from "./focusable_node";
import type { ReactNode } from "react";
import { type TaskType, taskTypes } from "../plugins/task_plugin";
import { TaskIcon } from "~/components/editor/task_icon";
import type { TaskLabel } from "../serialized_editor_content";
type SerializedTaskNode = Spread<SerializedElementNode, {
taskType: TaskLabel;
}>;
export class TaskNode extends FocusableNode {
__taskType: TaskType;
getTaskType() {
const self = this.getLatest();
return self.__taskType;
}
static getType() {
return "task";
}
static clone(node: TaskNode) {
return new TaskNode(node.__taskType, node.__key, node.__hasFocus);
}
constructor(taskType: TaskType, key?: string, hasFocus?: boolean) {
super(key, hasFocus);
this.__taskType = taskType;
}
createDOM(): HTMLElement {
const element = super.createDOM();
element.classList.add("task");
element.setAttribute("data-task-type", this.__taskType.label);
return element;
}
updateDOM(old: TaskNode, dom: HTMLElement): boolean {
return super.updateDOM(old, dom);
}
insertNewAfter(_selection: RangeSelection, restoreSelection?: boolean): ParagraphNode {
const newElement = $createParagraphNode();
this.insertAfter(newElement, restoreSelection);
return newElement;
}
exportJSON(): SerializedTaskNode {
return {
...super.exportJSON(),
type: "task",
taskType: this.__taskType.label,
};
}
static importJSON(serializedTaskNode: SerializedTaskNode) {
const taskType = taskTypes.find(it => it.label == serializedTaskNode.taskType);
// FIXME: Deal with task type not existing gracefully. Uncreate the node?
return $createTaskNode(taskType!);
}
}
export function $createTaskNode(taskType: TaskType) {
return new TaskNode(taskType);
}
export function $isTaskNode(node?: LexicalNode | null): node is TaskNode {
return node instanceof TaskNode;
}
export class TaskMarkerNode extends ElementNode {
static getType() {
return "task-marker";
}
static clone(node: TaskMarkerNode) {
return new TaskMarkerNode(node.__key);
}
isInline(): boolean {
return true;
}
createDOM(): HTMLElement {
const element = document.createElement("span");
element.classList.add("task-marker");
return element;
}
updateDOM(): boolean {
return false;
}
exportJSON() {
return {
...super.exportJSON(),
type: "task-marker",
};
}
static importJSON() {
return $createTaskMarkerNode();
}
}
export function $createTaskMarkerNode() {
return new TaskMarkerNode();
}
export function $isTaskMarkerNode(node?: LexicalNode | null): node is TaskMarkerNode {
return node instanceof TaskMarkerNode;
}
type SerializedTaskIconNode = Spread<SerializedLexicalNode, {
taskType: TaskLabel;
}>;
export class TaskIconNode extends DecoratorNode<ReactNode> {
__taskType: TaskType;
getTaskType() {
const self = this.getLatest();
return self.__taskType;
}
static getType() {
return "task-icon";
}
static clone(node: TaskIconNode) {
return new TaskIconNode(node.__taskType, node.__key);
}
constructor(taskType: TaskType, key?: string) {
super(key);
this.__taskType = taskType;
}
createDOM(): HTMLElement {
return document.createElement("span");
}
updateDOM(): boolean {
return false;
}
decorate(): ReactNode {
return (<TaskIcon icon={this.__taskType.icon?.path} />);
}
exportJSON(): SerializedTaskIconNode {
return {
...super.exportJSON(),
type: "task-icon",
taskType: this.__taskType.label,
};
}
static importJSON(serializedTaskIconNode: SerializedTaskIconNode) {
const taskType = taskTypes.find(it => it.label == serializedTaskIconNode.taskType);
// FIXME: Deal with task type not existing gracefully. Uncreate the node?
return $createTaskIconNode(taskType!);
}
}
export function $createTaskIconNode(taskType: TaskType) {
return new TaskIconNode(taskType);
}
export function $isTaskIconNode(node?: LexicalNode | null): node is TaskIconNode {
return node instanceof TaskIconNode;
}

View file

@ -0,0 +1,142 @@
import { DecoratorNode, ElementNode, type LexicalNode, type SerializedElementNode, type SerializedLexicalNode, type Spread } from "lexical";
import { FocusableNode } from "./focusable_node";
import type { ReactNode } from "react";
import { TermIcon } from "~/components/editor/term_icon";
type SerializedTermNode = Spread<SerializedElementNode, {
term: string;
}>;
export class TermNode extends FocusableNode {
__term: string;
getTerm() {
const self = this.getLatest();
return self.__term;
}
static getType() {
return "term";
}
static clone(node: TermNode) {
return new TermNode(node.__term, node.__key, node.__hasFocus);
}
constructor(term: string, key?: string, hasFocus?: boolean) {
super(key, hasFocus);
this.__term = term;
}
isInline() {
return true;
}
createDOM() {
const element = super.createDOM();
element.classList.add("term");
return element;
}
updateDOM(old: TermNode, dom: HTMLElement) {
return super.updateDOM(old, dom);
}
exportJSON(): SerializedTermNode {
return {
...super.exportJSON(),
type: "term",
term: this.__term,
};
}
static importJSON(serializedNode: SerializedTermNode) {
return $createTermNode(serializedNode.term);
}
}
export function $createTermNode(term: string) {
return new TermNode(term);
}
export function $isTermNode(node?: LexicalNode | null): node is TermNode {
return node instanceof TermNode;
}
export class TermMarkerNode extends ElementNode {
static getType() {
return "term-marker";
}
static clone(node: TermMarkerNode) {
return new TermMarkerNode(node.__key);
}
isInline() {
return true;
}
createDOM() {
const element = document.createElement("span");
element.classList.add("term-marker");
return element;
}
updateDOM() {
return false;
}
exportJSON() {
return {
...super.exportJSON(),
type: "term-marker",
};
}
static importJSON() {
return $createTermMarkerNode();
}
}
export function $createTermMarkerNode() {
return new TermMarkerNode();
}
export function $isTermMarkerNode(node?: LexicalNode | null): node is TermMarkerNode {
return node instanceof TermMarkerNode;
}
type SerializedTermIconNode = Spread<SerializedLexicalNode, {
term: string;
}>;
export class TermIconNode extends DecoratorNode<ReactNode> {
__term: string;
getTerm() {
const self = this.getLatest();
return self.__term;
}
static getType() {
return "term-icon";
}
static clone(node: TermIconNode) {
return new TermIconNode(node.__term, node.__key);
}
constructor(term: string, key?: string) {
super(key);
this.__term = term;
}
createDOM() {
return document.createElement("span");
}
updateDOM() {
return false;
}
decorate(): ReactNode {
return (<TermIcon term={this.__term} />);
}
exportJSON(): SerializedTermIconNode {
return {
...super.exportJSON(),
type: "term-icon",
term: this.__term,
};
}
static importJSON(serializedNode: SerializedTermIconNode) {
return $createTermIconNode(serializedNode.term);
}
}
export function $createTermIconNode(term: string) {
return new TermIconNode(term);
}
export function $isTermIconNode(node?: LexicalNode | null): node is TermIconNode {
return node instanceof TermIconNode;
}

View file

@ -0,0 +1,102 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { mergeRegister } from "@lexical/utils";
import { type LexicalEditor, SELECTION_CHANGE_COMMAND, TextNode } from "lexical";
import { useEffect } from "react";
import { $createFormattedTextMarkerNode, $createFormattedTextNode, $isFormattedTextMarkerNode, $isFormattedTextNode, FormattedTextMarkerNode, FormattedTextNode } from "../nodes/formatted_text";
import { type TextFormat, textFormats } from "../serialized_editor_content";
export function FormattedTextPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => mergeRegister(
// Create formatted text
...Object.keys(textFormats).map((format) =>
editor.registerNodeTransform(TextNode, createFormattedTextNode(format as TextFormat, editor))
),
// Remove formatted text marker nodes when not inside formatted text node
editor.registerNodeTransform(FormattedTextMarkerNode, (node) => {
const parent = node.getParent();
if (!parent) return;
if ($isFormattedTextNode(parent)) {
const format = parent.getStyle();
const markerChars = textFormats[format];
if (node.getTextContent() === markerChars) return;
}
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
node.remove();
}),
// Remove formatted text nodes without matching markers
editor.registerNodeTransform(FormattedTextNode, (node) => {
const format = node.getStyle();
const markerChars = textFormats[format];
const firstMarker = node.getFirstChild();
const lastMarker = node.getLastChild();
if (
!$isFormattedTextMarkerNode(firstMarker) ||
!$isFormattedTextMarkerNode(lastMarker)
) {
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
node.remove();
return;
}
const firstMarkerContent = firstMarker.getTextContent();
const lastMarkerContent = lastMarker.getTextContent();
if (firstMarkerContent !== markerChars || lastMarkerContent !== markerChars) {
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
node.remove();
return;
}
}),
// Remove formatted text nodes without content
editor.registerNodeTransform(FormattedTextNode, (node) => {
const format = node.getStyle();
const formattedChars = textFormats[format];
const content = node.getTextContent();
if (content !== formattedChars + formattedChars) return;
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
node.remove();
}),
));
return null;
}
const createFormattedTextNode = (format: TextFormat, editor: LexicalEditor) => (node: TextNode) => {
const markerChars = textFormats[format];
const markerCharsLen = markerChars.length;
const content = node.getTextContent();
// TODO: search in all text nodes within the parent block node
const start = content.indexOf(markerChars);
if (start === -1) return;
const end = content.indexOf(markerChars, start + markerCharsLen);
if (end === -1) return;
if (start === end - markerCharsLen) return;
const startIndex = start > 0 ? 1 : 0;
const contentIndex = startIndex + 1;
const endIndex = contentIndex + 1;
const textNodes = node.splitText(start, start + markerCharsLen, end, end + markerCharsLen);
const formattedTextNode = $createFormattedTextNode(format);
textNodes[startIndex]!.insertBefore(formattedTextNode);
const startMarker = $createFormattedTextMarkerNode();
startMarker.append(textNodes[startIndex]!);
const endMarker = $createFormattedTextMarkerNode();
endMarker.append(textNodes[endIndex]!);
formattedTextNode.append(startMarker, textNodes[contentIndex]!, endMarker);
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
}

View file

@ -0,0 +1,78 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { $createParagraphNode, $isParagraphNode, $isTextNode, SELECTION_CHANGE_COMMAND, TextNode } from "lexical";
import { useEffect } from "react";
import { $createHeaderMarkerNode, $createHeaderNode, $isHeaderMarkerNode, $isHeaderNode, HeaderMarkerNode, HeaderNode } from "../nodes/header_node";
import { mergeRegister } from "@lexical/utils";
const HEADER_REGEX = /^#+ /;
export function HeaderPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => mergeRegister(
// Create a header node if the text node matches the HEADER_REGEX
editor.registerNodeTransform(TextNode, (textNode) => {
const prevNode = textNode.getPreviousSibling();
if (prevNode) return;
const paragraphNode = textNode.getParent();
if (!$isParagraphNode(paragraphNode)) return;
const content = textNode.getTextContent();
const regexMatch = content.match(HEADER_REGEX);
if (!regexMatch) return;
const children = paragraphNode.getChildren();
const firstTextNode = children[0];
if (!$isTextNode(firstTextNode)) return;
const markerLength = regexMatch[0].length;
const textNodes = firstTextNode.splitText(markerLength);
const headerMarkerContent = textNodes[0];
if (!headerMarkerContent) return;
const headerNode = $createHeaderNode(markerLength - 1);
const headerMarkerNode = $createHeaderMarkerNode();
headerMarkerNode.append(headerMarkerContent);
headerNode.append(headerMarkerNode);
headerNode.append(...textNodes.slice(1));
headerNode.append(...children.slice(1));
paragraphNode.replace(headerNode, true);
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
}),
// Remove headers if they don't match the HEADER_REGEX
editor.registerNodeTransform(HeaderNode, (headerNode) => {
const content = headerNode.getTextContent();
if (content.match(HEADER_REGEX)) return;
headerNode.replace($createParagraphNode(), true);
}),
// Remove header markers if they don't match the HEADER_REGEX
editor.registerNodeTransform(HeaderMarkerNode, (node) => {
const headerNode = node.getParent();
const content = node.getTextContent();
if ($isHeaderNode(headerNode) &&
content.match(HEADER_REGEX) &&
content.length - 1 == headerNode.getLevel()) {
return;
}
node.getChildren().reverse().forEach(child => node.insertAfter(child));
node.remove();
}),
// Remove header nodes without a header marker
editor.registerNodeTransform(HeaderNode, (node) => {
const children = node.getChildren();
const headerMarker = children[0];
if ($isHeaderMarkerNode(headerMarker)) return;
node.replace($createParagraphNode(), true);
}),
));
return null;
}

View file

@ -0,0 +1,136 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { mergeRegister } from "@lexical/utils";
import { SELECTION_CHANGE_COMMAND, TextNode } from "lexical";
import { useEffect } from "react";
import { $createLinkIconNode, $createLinkMarkerNode, $createLinkNode, $createLinkUrlNode, $isLinkIconNode, $isLinkMarkerNode, $isLinkNode, $isLinkUrlNode, LinkIconNode, LinkMarkerNode, LinkNode, LinkUrlNode } from "../nodes/link_node";
const LINK_REGEX = /\[(.+)\]\((.*)\)/;
export function LinkPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => mergeRegister(
// Create link node
editor.registerNodeTransform(TextNode, (node) => {
const content = node.getTextContent();
const matches = content.match(LINK_REGEX);
if (!matches) return;
const labelLength = matches[1]!.length;
const urlLength = matches[2]!.length;
// start
// | labelStart
// | | labelEnd
// | | | middle
// | | | | urlStart
// | | | | | urlEnd
// | | | | | | end Index of the segment
// | | | | | | | in the textNodes array
// 0|1|2 |3|4|5 |6|7 <--|
// |[|label|]|(|url|)|
const start = content.indexOf(matches[0]);
const labelStart = start + 1;
const labelEnd = labelStart + labelLength;
const middle = labelEnd + 1;
const urlStart = middle + 1;
const urlEnd = urlStart + urlLength;
const end = urlEnd + 1;
const textNodes = node.splitText(start, labelStart, labelEnd, middle, urlStart, urlEnd, end);
const labelStartIndex = start > 0 ? 1 : 0;
const labelIndex = labelStartIndex + 1;
const labelEndIndex = labelIndex + 1;
const urlStartIndex = labelEndIndex + 1;
const urlIndex = urlStartIndex + 1;
const urlEndIndex = urlIndex + 1;
const url = matches[2]!;
const linkNode = $createLinkNode(url);
textNodes[labelStartIndex]!.insertBefore(linkNode);
const labelStartNode = $createLinkMarkerNode();
labelStartNode.append(textNodes[labelStartIndex]!);
const labelEndNode = $createLinkMarkerNode();
labelEndNode.append(textNodes[labelEndIndex]!);
const urlStartNode = $createLinkMarkerNode();
urlStartNode.append(textNodes[urlStartIndex]!);
const urlNode = $createLinkUrlNode();
urlNode.append(textNodes[urlIndex]!);
const urlEndNode = $createLinkMarkerNode();
urlEndNode.append(textNodes[urlEndIndex]!);
const iconNode = $createLinkIconNode(url);
linkNode.append(labelStartNode, textNodes[labelIndex]!, labelEndNode, urlStartNode, urlNode, urlEndNode, iconNode);
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
}),
// Remove dangling link marker nodes and verify their content
editor.registerNodeTransform(LinkMarkerNode, (node) => {
const linkNode = node.getParent();
if ($isLinkNode(linkNode)) {
const content = node.getTextContent();
const index = node.getIndexWithinParent();
const parentChildCount = linkNode.getChildren().length;
if (index === 0 && content === "[") return;
if (index === parentChildCount - 5 && content === "]") return;
if (index === parentChildCount - 4 && content === "(") return;
if (index === parentChildCount - 2 && content === ")") return;
}
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
node.remove();
}),
// Remove dangling link url nodes
editor.registerNodeTransform(LinkUrlNode, (node) => {
const linkNode = node.getParent();
if ($isLinkNode(linkNode)) {
const url = linkNode.getUrl();
const content = node.getTextContent();
if (url === content) return;
}
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
node.remove();
}),
// Remove dangling link icon nodes
editor.registerNodeTransform(LinkIconNode, (node) => {
const linkNode = node.getParent();
if ($isLinkNode(linkNode)) {
const url = linkNode.getUrl();
const iconUrl = node.getUrl();
if (url === iconUrl) return;
}
node.remove();
}),
// Remove links with missing markers
editor.registerNodeTransform(LinkNode, (node) => {
const children = node.getChildren();
const startLabel = children.at(0);
const endLabel = children.at(-5);
const startUrl = children.at(-4);
const url = children.at(-3);
const endUrl = children.at(-2);
const icon = children.at(-1);
if (
$isLinkMarkerNode(startLabel) &&
$isLinkMarkerNode(endLabel) &&
$isLinkMarkerNode(startUrl) &&
$isLinkUrlNode(url) &&
$isLinkMarkerNode(endUrl) &&
$isLinkIconNode(icon)
) {
return;
}
children.reverse().forEach((child) => node.insertAfter(child));
node.remove();
}),
))
return null;
}

View file

@ -0,0 +1,48 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { CAN_USE_BEFORE_INPUT, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI, mergeRegister } from "@lexical/utils";
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_NORMAL, INSERT_PARAGRAPH_COMMAND, KEY_ENTER_COMMAND } from "lexical";
import { useEffect } from "react";
export function ParagraphPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => mergeRegister(
editor.registerCommand(INSERT_PARAGRAPH_COMMAND, () => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
selection.insertParagraph();
return true;
}, COMMAND_PRIORITY_NORMAL),
editor.registerCommand<KeyboardEvent | null>(
KEY_ENTER_COMMAND,
(event) => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return false;
}
if (event !== null) {
// If we have beforeinput, then we can avoid blocking
// the default behavior. This ensures that the iOS can
// intercept that we're actually inserting a paragraph,
// and autocomplete, autocapitalize etc work as intended.
// This can also cause a strange performance issue in
// Safari, where there is a noticeable pause due to
// preventing the key down of enter.
if (
(IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) &&
CAN_USE_BEFORE_INPUT
) {
return false;
}
event.preventDefault();
}
return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
},
COMMAND_PRIORITY_NORMAL,
),
))
return null;
}

View file

@ -0,0 +1,49 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { mergeRegister } from "@lexical/utils";
import { $getNodeByKey, $getSelection, COMMAND_PRIORITY_EDITOR, type LexicalNode, SELECTION_CHANGE_COMMAND } from "lexical";
import { useEffect, useState } from "react";
import { $isFocusableNode } from "../nodes/focusable_node";
export function SelectionPlugin() {
const [editor] = useLexicalComposerContext();
const [previousSelectedNodes, setPreviousSelectedNodes] = useState<string[]>([]);
useEffect(() => mergeRegister(
editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
const selection = $getSelection();
const nodes = selection?.getNodes() || [];
previousSelectedNodes
.map((key) => $getNodeByKey(key))
.forEach(markTreeAsUnfocused);
nodes.forEach(markTreeAsFocused);
setPreviousSelectedNodes(nodes.map(n => n.getKey()));
return false;
}, COMMAND_PRIORITY_EDITOR),
));
return null;
}
function markTreeAsFocused(node: LexicalNode | null) {
if (!node) return;
if ($isFocusableNode(node)) {
node.setFocus(true);
}
markTreeAsFocused(node.getParent());
}
function markTreeAsUnfocused(node: LexicalNode | null) {
if (!node) return;
if ($isFocusableNode(node)) {
node.setFocus(false);
}
markTreeAsUnfocused(node.getParent());
}

View file

@ -0,0 +1,116 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { mergeRegister } from "@lexical/utils";
import { $createParagraphNode, $isParagraphNode, $isTextNode, type LexicalEditor, SELECTION_CHANGE_COMMAND, TextNode } from "lexical";
import { useEffect } from "react";
import { $createTaskMarkerNode, $createTaskNode, $createTaskIconNode, TaskMarkerNode, $isTaskNode, TaskIconNode, TaskNode, $isTaskIconNode, $isTaskMarkerNode } from "../nodes/task_node";
import { type Icon, icons } from "~/lib/icon";
import { type TaskLabel } from "../serialized_editor_content";
export type TaskType = {
label: TaskLabel
icon?: Icon
}
export const taskTypes: TaskType[] = [
{
label: "TODO",
icon: undefined,
},
{
label: "DOING",
icon: icons.minus,
},
{
label: "DONE",
icon: icons.check,
},
{
label: "IDEA",
icon: icons.lightbulb,
},
{
label: "DEADLINE",
icon: icons.exclamation,
},
]
export function TaskPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => mergeRegister(
// Create a task node if the text node matches the TASK_REGEX
...taskTypes.map(({ label }) =>
editor.registerNodeTransform(TextNode, createTaskNode(label, editor))
),
// Remove task marker nodes not inside a task node or with the wrong content
editor.registerNodeTransform(TaskMarkerNode, (node) => {
const taskNode = node.getParent();
if ($isTaskNode(taskNode)) {
const taskType = taskNode.getTaskType();
const content = node.getTextContent();
if (content === taskType.label) return;
}
node.getChildren().reverse().forEach(child => node.insertAfter(child));
node.remove();
}),
// Remove task icon nodes not inside a task node
editor.registerNodeTransform(TaskIconNode, (node) => {
const taskNode = node.getParent();
if ($isTaskNode(taskNode)) {
const taskType = taskNode.getTaskType();
if (node.getTaskType() === taskType) return;
}
node.remove();
}),
// Remove task nodes without an icon or marker
editor.registerNodeTransform(TaskNode, (node) => {
const iconNode = node.getChildAtIndex(0);
const markerNode = node.getChildAtIndex(1);
if ($isTaskIconNode(iconNode) && $isTaskMarkerNode(markerNode)) return;
node.replace($createParagraphNode(), true);
}),
));
return null;
}
const createTaskNode = (label: TaskLabel, editor: LexicalEditor) => (node: TextNode) => {
const prevNode = node.getPreviousSibling();
if (prevNode) return;
const paragraphNode = node.getParent();
if (!$isParagraphNode(paragraphNode)) return;
const content = node.getTextContent();
if (!content.startsWith(label + " ")) return;
const children = paragraphNode.getChildren();
const firstTextNode = children[0];
if (!$isTextNode(firstTextNode)) return;
const textNodes = firstTextNode.splitText(label.length);
const todoMarkerContent = textNodes[0];
if (!todoMarkerContent) return;
const taskType = taskTypes.find(it => it.label == label);
if (!taskType) return;
const taskNode = $createTaskNode(taskType);
const taskIconNode = $createTaskIconNode(taskType);
const taskMarkerNode = $createTaskMarkerNode();
taskMarkerNode.append(todoMarkerContent);
taskNode.append(taskIconNode);
taskNode.append(taskMarkerNode);
taskNode.append(...textNodes.slice(1));
taskNode.append(...children.slice(1));
paragraphNode.replace(taskNode, true);
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
}

View file

@ -0,0 +1,83 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { mergeRegister } from "@lexical/utils";
import { SELECTION_CHANGE_COMMAND, TextNode } from "lexical";
import { useEffect } from "react";
import { $createTermIconNode, $createTermMarkerNode, $createTermNode, $isTermIconNode, $isTermMarkerNode, $isTermNode, TermIconNode, TermMarkerNode, TermNode } from "../nodes/term_node";
const TERM_REGEX = /\[\[(.+)\]\]/;
export function TermPlugin() {
const [editor] = useLexicalComposerContext();
useEffect(() => mergeRegister(
editor.registerNodeTransform(TextNode, (node) => {
const content = node.getTextContent();
const matches = content.match(TERM_REGEX);
if (!matches) return;
const term = matches[1]!;
const start = content.indexOf(matches[0]);
const end = start + matches[0].length;
const startIndex = start > 0 ? 1 : 0;
const contentIndex = startIndex + 1;
const endIndex = contentIndex + 1;
const textNodes = node.splitText(start, start + 2, end - 2, end);
const termNode = $createTermNode(term);
textNodes[startIndex]!.insertBefore(termNode);
const iconNode = $createTermIconNode(term);
termNode.append(iconNode);
const startMarkerNode = $createTermMarkerNode();
startMarkerNode.append(textNodes[startIndex]!);
const endMarkerNode = $createTermMarkerNode();
endMarkerNode.append(textNodes[endIndex]!);
termNode.append(startMarkerNode, textNodes[contentIndex]!, endMarkerNode);
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
}),
editor.registerNodeTransform(TermMarkerNode, (node) => {
const termNode = node.getParent();
if ($isTermNode(termNode)) return;
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
node.remove();
}),
editor.registerNodeTransform(TermIconNode, (node) => {
const termNode = node.getParent();
if ($isTermNode(termNode)) return;
node.remove();
}),
editor.registerNodeTransform(TermNode, (node) => {
const children = node.getChildren();
const content = node.getTextContent();
const term = node.getTerm();
const iconNode = children.at(0);
const startMarkerNode = children.at(1);
const endMarkerNode = children.at(-1);
if ($isTermIconNode(iconNode) &&
$isTermMarkerNode(startMarkerNode) &&
$isTermMarkerNode(endMarkerNode) &&
iconNode.getTerm() === term &&
startMarkerNode.getTextContent() === "[[" &&
endMarkerNode.getTextContent() === "]]" &&
content === `[[${term}]]`
) {
return
}
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
node.remove();
}),
));
return null;
}

View file

@ -0,0 +1,263 @@
// TODO: This needs to be removed, but some exported types need to be moved first
import { $getRoot, $isLineBreakNode, $isParagraphNode, $isTextNode, type LexicalNode } from "lexical";
import { $isHeaderNode } from "./nodes/header_node";
import { $isTaskNode } from "./nodes/task_node";
import { $isFormattedTextNode } from "./nodes/formatted_text";
import { $isLinkNode } from "./nodes/link_node";
import { $isTermNode } from "./nodes/term_node";
export type SerializedEditorContent = {
content: SerializedBlockNode[];
}
type SerializedTextContainingNode =
SerializedTextNode
| SerializedFormattedTextNode
| SerializedLinebreakNode
| SerializedLinkNode
| SerializedTermNode;
type SerializedBlockNode =
SerializedHeaderNode
| SerializedParagraphNode
| SerializedTaskNode;
type SerializedTextNode = {
type: "text";
content: string;
}
type SerializedLinebreakNode = {
type: "linebreak";
}
type SerializedParagraphNode = {
type: "paragraph";
content: SerializedTextContainingNode[];
}
type SerializedHeaderNode = {
type: "header";
level: number;
content: SerializedTextContainingNode[];
}
export type TextFormat = "bold" | "italic" | "underline" | "strikethrough" | "code";
export const textFormats: Record<TextFormat, string> = {
bold: "**",
italic: "//",
underline: "__",
strikethrough: "~~",
code: "`",
};
type SerializedFormattedTextNode = {
type: "formatted_text";
format: TextFormat;
content: SerializedTextContainingNode[];
}
type SerializedLinkNode = {
type: "link";
label: SerializedTextContainingNode[];
url: string;
}
type SerializedTermNode = {
type: "term";
term: string;
}
export type TaskLabel = "TODO" | "DOING" | "DONE" | "IDEA" | "DEADLINE";
type SerializedTaskNode = {
type: "task";
label: TaskLabel;
content: SerializedTextContainingNode[];
}
export function $serializeEditorContent(): SerializedEditorContent {
return {
content: $getRoot().getChildren().map(serializeBlockNode),
};
}
function serializeBlockNode(node: LexicalNode): SerializedBlockNode {
switch (true) {
case $isParagraphNode(node): return {
type: "paragraph",
content: node.getChildren().map(serializeTextContainingNode),
}
case $isHeaderNode(node): return {
type: "header",
level: node.getLevel(),
// Slice(1) to remove the marker node
content: node.getChildren().slice(1).map(serializeTextContainingNode),
}
case $isTaskNode(node): return {
type: "task",
label: node.getTaskType().label,
// Slice(2) to remove the marker nodes
content: node.getChildren().slice(2).map(serializeTextContainingNode),
}
default: throw new Error(`Unknown block node type: ${node.getType()}`);
}
}
function serializeTextContainingNode(node: LexicalNode): SerializedTextContainingNode {
switch (true) {
case $isTextNode(node): return {
type: "text",
content: node.getTextContent(),
}
case $isFormattedTextNode(node): return {
type: "formatted_text",
format: node.getStyle(),
content: node.getChildren().slice(1, -1).map(serializeTextContainingNode),
}
case $isLineBreakNode(node): return {
type: "linebreak",
}
case $isLinkNode(node): return {
type: "link",
label: node.getChildren().slice(1, -5).map(serializeTextContainingNode),
url: node.getUrl(),
}
case $isTermNode(node): return {
type: "term",
term: node.getTerm(),
}
default: throw new Error(`Unknown text containing node type: ${node.getType()}`);
}
}
export function deserializeEditorContent(serialized: SerializedEditorContent) {
return {
root: {
type: "root",
children: serialized.content.map(deserializeBlockNode),
}
};
}
function deserializeBlockNode(serialized: SerializedBlockNode) {
switch (serialized.type) {
case "header": return {
type: "header",
level: serialized.level,
children: [
{
type: "header-marker",
children: [createTextNode(`${"#".repeat(serialized.level)} `)]
},
...serialized.content.map(deserializeTextContainingNode)
],
}
case "paragraph": return {
type: "paragraph",
children: [
...serialized.content.map(deserializeTextContainingNode)
],
}
case "task": return {
type: "task",
taskType: serialized.label,
children: [
{
type: "task-icon",
taskType: serialized.label,
},
{
type: "task-marker",
children: [createTextNode(`${serialized.label} `)]
},
...serialized.content.map(deserializeTextContainingNode)
],
}
}
}
function deserializeTextContainingNode(serialized: SerializedTextContainingNode): any {
switch (serialized.type) {
case "text": return createTextNode(serialized.content);
case "formatted_text": return {
type: "formatted-text",
style: serialized.format,
children: [
{
type: "formatted-text-marker",
children: [createTextNode(textFormats[serialized.format])]
},
...serialized.content.map(deserializeTextContainingNode),
{
type: "formatted-text-marker",
children: [createTextNode(textFormats[serialized.format])]
},
],
}
case "link": return {
type: "link",
children: [
{
type: "link-marker",
children: [createTextNode("[")],
},
...serialized.label.map(deserializeTextContainingNode),
{
type: "link-marker",
children: [createTextNode("]")],
},
{
type: "link-marker",
children: [createTextNode("(")],
},
{
type: "link-url",
children: [createTextNode(serialized.url)],
},
{
type: "link-marker",
children: [createTextNode(")")],
},
{
type: "link-icon",
url: serialized.url,
}
],
};
case "term": return {
type: "term",
term: serialized.term,
children: [
{
type: "term-icon",
term: serialized.term,
},
{
type: "term-marker",
children: [createTextNode("[[")]
},
createTextNode(serialized.term),
{
type: "term-marker",
children: [createTextNode("]]")]
},
],
}
case "linebreak": return { type: "linebreak" };
}
}
function createTextNode(content: string) {
return {
type: "text",
text: content,
version: 1,
detail: 0,
format: 0,
mode: "normal",
style: "",
}
}

8
src/global.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
import 'react';
declare module 'react' {
// Allow CSS variables inside inline styles
interface CSSProperties {
[key: `--${string}`]: string | number
}
}

View file

@ -0,0 +1,65 @@
import React, { useContext } from "react";
import * as Y from "yjs";
import { useYDoc } from "~/hooks/use-ydoc";
import type { CollectionId, CollectionMetadata, CollectionsMetadata, NoteId, NoteMetadata, NotesMetadata } from "~/lib/metadata";
import { useObserve } from "~/hooks/use-observe";
const MetadataContext = React.createContext<Y.Doc | null>(null);
export function MetadataProvider(props: { children: React.ReactNode }) {
const { ydoc } = useYDoc("metadata");
return (
<MetadataContext.Provider value={ydoc} >
{props.children}
</MetadataContext.Provider>
);
}
export function useCollections(): CollectionsMetadata {
const doc = useContext(MetadataContext)
if (!doc) {
throw new Error("useCollections must be used within a MetadataProvider")
}
const collections = doc.getArray("collections")
useObserve(collections, "deep")
return collections as any as CollectionsMetadata
}
export function useCollection(id: CollectionId | undefined): CollectionMetadata | undefined {
const collections = useCollections()
const collection = collections.toArray().find(it => it.get("id") == id)
return collection
}
export function useNotesMetadata(): NotesMetadata {
const doc = useContext(MetadataContext)
if (!doc) {
throw new Error("useNotes must be used within a MetadataProvider")
}
const notes = doc.getArray("notes")
useObserve(notes, "deep")
return notes as any as NotesMetadata
}
export function useCollectionNotesMetadata(collectionId: CollectionId | ""): NoteMetadata[] {
const notes = useNotesMetadata()
return notes.toArray().filter(it => it.get("collectionId") == collectionId)
}
export function useNoteMetadata(id: NoteId): NoteMetadata | undefined {
const notes = useNotesMetadata()
const note = notes.toArray().find(it => it.get("id") == id)
return note
}

81
src/hooks/use-note.tsx Normal file
View file

@ -0,0 +1,81 @@
import React from "react";
import * as Y from "yjs";
import type { NoteId } from "~/lib/metadata";
import { useYDoc } from "./use-ydoc";
import type { WebsocketProvider } from "y-websocket";
import type { IndexeddbPersistence } from "y-indexeddb";
const NoteContext = React.createContext<{
doc: Y.Doc,
providers: {
indexeddb: IndexeddbPersistence,
websocket: WebsocketProvider,
}
} | null>(null);
export function NoteProvider(props: { children: React.ReactNode, id: NoteId }) {
const { ydoc, indexeddbProvider, websocketProvider } = useYDoc(`note-${props.id}`);
// If we don't have the provider yet, delay rendering until we do.
if (!indexeddbProvider || !websocketProvider) {
return null;
}
return (
<NoteContext.Provider value={{
doc: ydoc,
providers: {
indexeddb: indexeddbProvider,
websocket: websocketProvider,
}
}}>
{props.children}
</NoteContext.Provider>
);
}
export function useNoteDoc() {
const ctx = React.useContext(NoteContext);
if (!ctx) {
throw new Error("useNoteDoc must be used within a NoteProvider")
}
return ctx.doc;
}
export function useNoteMap(name: string) {
const ctx = React.useContext(NoteContext);
if (!ctx) {
throw new Error("useNoteMap must be used within a NoteProvider")
}
const map = ctx.doc.getMap(name);
return map;
}
export function useNoteArray(name: string) {
const ctx = React.useContext(NoteContext);
if (!ctx) {
throw new Error("useNoteArray must be used within a NoteProvider")
}
const array = ctx.doc.getArray(name);
return array;
}
export function useNoteProviders() {
const ctx = React.useContext(NoteContext);
if (!ctx) {
throw new Error("useNoteProviders must be used within a NoteProvider")
}
return ctx.providers;
}
export function useNoteAwareness() {
const ctx = React.useContext(NoteContext);
if (!ctx) {
throw new Error("useNoteAwareness must be used within a NoteProvider")
}
const awareness = ctx.providers.websocket.awareness;
return awareness;
}

22
src/hooks/use-observe.ts Normal file
View file

@ -0,0 +1,22 @@
import { useEffect } from "react";
import * as Y from "yjs"
import { useRedraw } from "~/hooks/use-redraw"
export function useObserve(object: Y.AbstractType<any>, kind: "deep" | "normal" = "normal") {
const redraw = useRedraw();
useEffect(() => {
if (kind === "deep") {
object.observeDeep(redraw);
} else if (kind === "normal") {
object.observe(redraw);
}
return () => {
if (kind === "deep") {
object.unobserveDeep(redraw);
} else if (kind === "normal") {
object.unobserve(redraw);
}
}
}, []);
}

7
src/hooks/use-redraw.ts Normal file
View file

@ -0,0 +1,7 @@
import { useState } from "react";
export function useRedraw() {
const [, setTick] = useState(0);
return () => setTick((tick) => tick + 1);
}

47
src/hooks/use-theme.tsx Normal file
View file

@ -0,0 +1,47 @@
import React, { useCallback, useContext, useLayoutEffect, useState } from "react";
type Theme = "system" | "light" | "dark";
const ThemeContex = React.createContext<{ theme: Theme, effectiveTheme: "light" | "dark", setTheme: (theme: Theme) => void }>({
theme: "system",
effectiveTheme: "light",
setTheme: () => { }
});
export function ThemeProvider(props: { children: React.ReactNode }) {
const [localTheme, setLocalTheme] = useState<Theme>(() => localStorage.getItem("theme") as Theme || "system");
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
const effectiveTheme = localTheme === "system" ? systemTheme : localTheme;
useLayoutEffect(() => {
document.documentElement.setAttribute("data-theme", effectiveTheme)
}, [effectiveTheme])
const setTheme = useCallback((newTheme: Theme) => {
setLocalTheme(newTheme);
localStorage.setItem("theme", newTheme);
}, []);
return (
<ThemeContex.Provider value={{
theme: localTheme,
effectiveTheme,
setTheme
}}>
{props.children}
</ThemeContex.Provider>
);
}
export function useTheme() {
const { theme, setTheme } = useContext(ThemeContex);
return [theme, setTheme] as const;
}
export function useEffectiveTheme() {
const { effectiveTheme } = useContext(ThemeContex);
return effectiveTheme;
}

33
src/hooks/use-ydoc.ts Normal file
View file

@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
import * as Y from 'yjs';
import { IndexeddbPersistence } from 'y-indexeddb'
import { WebsocketProvider } from 'y-websocket';
export function useYDoc(name: string) {
const [ydoc] = useState<Y.Doc>(() => new Y.Doc());
const [indexeddbProvider, setIndexeddbProvider] = useState<IndexeddbPersistence | null>(null);
const [websocketProvider, setWebsocketProvider] = useState<WebsocketProvider | null>(null);
useEffect(() => {
if (!ydoc) {
return
}
const indexeddbProvider = new IndexeddbPersistence(name, ydoc);
setIndexeddbProvider(indexeddbProvider);
const websocketProvider = new WebsocketProvider(`ws://localhost:1234`, name, ydoc, {
// TODO: For now we don't connect to a server yet, this obviously needs to change.
connect: false,
});
setWebsocketProvider(websocketProvider);
return () => {
indexeddbProvider.destroy();
websocketProvider.destroy();
ydoc.destroy();
};
}, [ydoc]);
return { ydoc, indexeddbProvider, websocketProvider };
}

26
src/lib/color.ts Normal file
View file

@ -0,0 +1,26 @@
export type ColorName = keyof typeof colors;
export type Color = typeof colors[ColorName];
// Tailwind colors
// Base: 400
// Hover: 300
export const colors = {
red: { label: "Red", base: "#f87171", hover: "#fca5a5" },
orange: { label: "Orange", base: "#fb923c", hover: "#fdba74" },
amber: { label: "Amber", base: "#fbbf24", hover: "#fcd34d" },
yellow: { label: "Yellow", base: "#facc15", hover: "#fde047" },
lime: { label: "Lime", base: "#a3e635", hover: "#bef264" },
green: { label: "Green", base: "#4ade80", hover: "#86efac" },
emerald: { label: "Emerald", base: "#34d399", hover: "#6ee7b7" },
teal: { label: "Teal", base: "#2dd4bf", hover: "#5eead4" },
cyan: { label: "Cyan", base: "#22d3ee", hover: "#67e8f9" },
sky: { label: "Sky", base: "#38bdf8", hover: "#7dd3fc" },
blue: { label: "Blue", base: "#60a5fa", hover: "#93c5fd" },
indigo: { label: "Indigo", base: "#818cf8", hover: "#a5b4fc" },
violet: { label: "Violet", base: "#a78bfa", hover: "#c4b5fd" },
purple: { label: "Purple", base: "#c084fc", hover: "#d8b4fe" },
fuchsia: { label: "Fuchsia", base: "#e879f9", hover: "#f0abfc" },
pink: { label: "Pink", base: "#f472b6", hover: "#f9a8d4" },
rose: { label: "Rose", base: "#fb7185", hover: "#fda4af" },
white: { label: "White", base: "#a3a3a3", hover: "#d4d4d4" },
};

12
src/lib/icon.ts Normal file
View file

@ -0,0 +1,12 @@
import * as mdi_icons from "@mdi/js";
export type IconName = string; //keyof typeof icons;
export type Icon = typeof icons[IconName];
export const icons = Object.fromEntries(Object.entries(mdi_icons).map(([key, value]) => ([
key.replace(/([A-Z])/g, "-$1").replace(/^mdi-/, "").toLowerCase(),
{
path: value,
name: key.replace("mdi", "").replace(/([A-Z])/g, " $1"),
}
])));

99
src/lib/metadata.ts Normal file
View file

@ -0,0 +1,99 @@
import * as Y from "yjs"
import type { ColorName } from "~/lib/color"
import type { IconName } from "~/lib/icon"
import type { YArray, YMap } from "~/lib/yjs"
import type { PropertyType } from "~/lib/property"
export type NoteId = string
export type CollectionId = string
export type PropertyId = string
export type NotesMetadata = YArray<NoteMetadata>
export type NoteMetadata = YMap<{
id: NoteId
collectionId: CollectionId | ""
title: string
icon: IconName | ""
primaryColor: ColorName | ""
secondaryColor: ColorName | ""
pinned: boolean
properties: YArray<NoteProperty>
type: "text" | "canvas"
}>
export type NoteProperty = YMap<{
propertyId: PropertyId
value: string
}>
export type CollectionsMetadata = YArray<CollectionMetadata>
export type CollectionMetadata = YMap<{
id: CollectionId
name: string
color: ColorName
icon: IconName | ""
properties?: YArray<CollectionProperty>
}>
export type CollectionProperty = YMap<{
id: PropertyId
name: string
type: PropertyType
pinned: boolean
}>
export function createCollection(md: CollectionsMetadata, data: {
name: string,
color: ColorName,
icon: IconName,
}) {
const collection = new Y.Map() as any as CollectionMetadata;
collection.set("id", randomUUID());
collection.set("name", data.name);
collection.set("color", data.color);
collection.set("icon", data.icon);
collection.set("properties", new Y.Array() as any);
md.push([collection])
return collection;
}
export function deleteCollection(md: CollectionsMetadata, id: CollectionId) {
const index = md.toArray().findIndex(it => it.get("id") == id);
if (index == -1) {
return;
}
md.delete(index, 1)
}
export function createNote(md: NotesMetadata, data: {
name: string,
icon: IconName | undefined,
collectionId: CollectionId | undefined,
primaryColor: ColorName | undefined,
secondaryColor: ColorName | undefined,
type: "text" | "canvas"
}) {
const note = new Y.Map() as any as NoteMetadata;
note.set("id", randomUUID());
note.set("title", data.name);
note.set("icon", data.icon ?? "");
note.set("collectionId", data.collectionId ?? "");
note.set("primaryColor", data.primaryColor ?? "");
note.set("secondaryColor", data.secondaryColor ?? "");
note.set("pinned", false);
note.set("properties", new Y.Array() as any);
note.set("type", data.type);
md.push([note])
return note;
}
export function randomUUID() {
return crypto.randomUUID()
}

42
src/lib/property.ts Normal file
View file

@ -0,0 +1,42 @@
import { randomUUID, type CollectionProperty, type NoteProperty, type PropertyId } from "~/lib/metadata";
import type { YArray } from "~/lib/yjs";
import * as Y from "yjs";
export const propertyTypes = ["shortText"] as const;
export type PropertyType = typeof propertyTypes[number];
export const properties: { [P in PropertyType]: string } = {
"shortText": "Short text",
};
export function createCollectionProperty(md: YArray<CollectionProperty>) {
const property = new Y.Map() as any as CollectionProperty;
property.set("id", randomUUID());
property.set("name", "");
property.set("type", "shortText");
property.set("pinned", false);
md.push([property]);
return property;
}
export function deleteCollectionProperty(md: YArray<CollectionProperty>, id: PropertyId) {
const index = md.toArray().findIndex((prop) => prop.get("id") === id);
if (index === -1) {
return;
}
md.delete(index, 1);
}
export function createNoteProperty(md: YArray<NoteProperty>, data: {
propertyId: PropertyId;
}) {
const property = new Y.Map() as any as NoteProperty;
property.set("propertyId", data.propertyId);
property.set("value", "");
md.push([property]);
return property;
}

48
src/lib/yjs.ts Normal file
View file

@ -0,0 +1,48 @@
import * as Y from 'yjs'
export type YValue = object | boolean | string | number | Uint8Array | YMap<any> | YArray<any> | Y.Text
type YValueToJSON<T> =
T extends YMap<any> ? YMapToJSON<T>
: T extends YArray<any> ? YArrayToJSON<T>
: T extends Y.Text ? string
: T
type YMapToJSON<T> = T extends YMap<infer X>
? { [KEY in keyof X]: YValueToJSON<X[KEY]> }
: never;
type YArrayToJSON<T> = T extends YArray<infer X> ? YValueToJSON<X>[] : never;
export type YMap<T extends { [key: string]: YValue }> = {
set: <X extends keyof T>(key: X, value: T[X]) => void
get: <X extends keyof T>(key: X) => T[X]
delete: <X extends keyof T>(key: X) => void
has: <X extends keyof T>(key: X) => boolean
clear: () => void
toJSON: () => YMapToJSON<YMap<T>>
forEach: (callback: <X extends keyof T>(value: T[X], key: X, map: YMap<T>) => void) => void
entries: () => Iterator<[string, YValue]>
values: () => Iterator<YValue>
keys: () => Iterator<keyof T>
clone: () => YMap<T>
size: number
}
export type YArray<T extends YValue> = {
insert: (index: number, content: T[]) => void
delete: (index: number, length: number) => void
push: (values: T[]) => void
unshift: (content: T[]) => void
get: (index: number) => T | undefined
slice: (start: number, end?: number) => T[]
toArray: () => T[]
toJSON: () => YArrayToJSON<YArray<T>>
forEach: (callback: (value: T, index: number, yarray: YArray<T>) => void) => void
map: <X>(callback: (value: T, index: number, yarray: YArray<T>) => X) => X[]
clone: () => YArray<T>
length: number
}

View file

@ -2,28 +2,14 @@ import { StrictMode } from 'react'
import ReactDOM from 'react-dom/client'
import {
RouterProvider,
createRootRoute,
createRoute,
createRouter,
} from '@tanstack/react-router'
import '~/styles.css'
import reportWebVitals from '~/reportWebVitals.ts'
import { RootLayout } from '~/layouts/RootLayout'
import { RootPage } from '~/pages/Root'
const rootRoute = createRootRoute({
component: RootLayout,
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: RootPage,
})
const routeTree = rootRoute.addChildren([indexRoute])
// Import the generated route tree
import { routeTree } from "./routeTree.gen";
const router = createRouter({
routeTree,

369
src/routeTree.gen.ts Normal file
View file

@ -0,0 +1,369 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
// Import Routes
import { Route as rootRoute } from './routes/__root'
import { Route as AppImport } from './routes/app'
import { Route as IndexImport } from './routes/index'
import { Route as AppIndexImport } from './routes/app/index'
import { Route as AppTodoImport } from './routes/app/todo'
import { Route as AppSearchImport } from './routes/app/search'
import { Route as AppInboxImport } from './routes/app/inbox'
import { Route as AppGraphImport } from './routes/app/graph'
import { Route as AppCalendarImport } from './routes/app/calendar'
import { Route as AppTermTermImport } from './routes/app/term.$term'
import { Route as AppNoteIdImport } from './routes/app/note.$id'
import { Route as AppCollectionIdImport } from './routes/app/collection.$id'
// Create/Update Routes
const AppRoute = AppImport.update({
id: '/app',
path: '/app',
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRoute,
} as any)
const AppIndexRoute = AppIndexImport.update({
id: '/',
path: '/',
getParentRoute: () => AppRoute,
} as any)
const AppTodoRoute = AppTodoImport.update({
id: '/todo',
path: '/todo',
getParentRoute: () => AppRoute,
} as any)
const AppSearchRoute = AppSearchImport.update({
id: '/search',
path: '/search',
getParentRoute: () => AppRoute,
} as any)
const AppInboxRoute = AppInboxImport.update({
id: '/inbox',
path: '/inbox',
getParentRoute: () => AppRoute,
} as any)
const AppGraphRoute = AppGraphImport.update({
id: '/graph',
path: '/graph',
getParentRoute: () => AppRoute,
} as any)
const AppCalendarRoute = AppCalendarImport.update({
id: '/calendar',
path: '/calendar',
getParentRoute: () => AppRoute,
} as any)
const AppTermTermRoute = AppTermTermImport.update({
id: '/term/$term',
path: '/term/$term',
getParentRoute: () => AppRoute,
} as any)
const AppNoteIdRoute = AppNoteIdImport.update({
id: '/note/$id',
path: '/note/$id',
getParentRoute: () => AppRoute,
} as any)
const AppCollectionIdRoute = AppCollectionIdImport.update({
id: '/collection/$id',
path: '/collection/$id',
getParentRoute: () => AppRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
'/app': {
id: '/app'
path: '/app'
fullPath: '/app'
preLoaderRoute: typeof AppImport
parentRoute: typeof rootRoute
}
'/app/calendar': {
id: '/app/calendar'
path: '/calendar'
fullPath: '/app/calendar'
preLoaderRoute: typeof AppCalendarImport
parentRoute: typeof AppImport
}
'/app/graph': {
id: '/app/graph'
path: '/graph'
fullPath: '/app/graph'
preLoaderRoute: typeof AppGraphImport
parentRoute: typeof AppImport
}
'/app/inbox': {
id: '/app/inbox'
path: '/inbox'
fullPath: '/app/inbox'
preLoaderRoute: typeof AppInboxImport
parentRoute: typeof AppImport
}
'/app/search': {
id: '/app/search'
path: '/search'
fullPath: '/app/search'
preLoaderRoute: typeof AppSearchImport
parentRoute: typeof AppImport
}
'/app/todo': {
id: '/app/todo'
path: '/todo'
fullPath: '/app/todo'
preLoaderRoute: typeof AppTodoImport
parentRoute: typeof AppImport
}
'/app/': {
id: '/app/'
path: '/'
fullPath: '/app/'
preLoaderRoute: typeof AppIndexImport
parentRoute: typeof AppImport
}
'/app/collection/$id': {
id: '/app/collection/$id'
path: '/collection/$id'
fullPath: '/app/collection/$id'
preLoaderRoute: typeof AppCollectionIdImport
parentRoute: typeof AppImport
}
'/app/note/$id': {
id: '/app/note/$id'
path: '/note/$id'
fullPath: '/app/note/$id'
preLoaderRoute: typeof AppNoteIdImport
parentRoute: typeof AppImport
}
'/app/term/$term': {
id: '/app/term/$term'
path: '/term/$term'
fullPath: '/app/term/$term'
preLoaderRoute: typeof AppTermTermImport
parentRoute: typeof AppImport
}
}
}
// Create and export the route tree
interface AppRouteChildren {
AppCalendarRoute: typeof AppCalendarRoute
AppGraphRoute: typeof AppGraphRoute
AppInboxRoute: typeof AppInboxRoute
AppSearchRoute: typeof AppSearchRoute
AppTodoRoute: typeof AppTodoRoute
AppIndexRoute: typeof AppIndexRoute
AppCollectionIdRoute: typeof AppCollectionIdRoute
AppNoteIdRoute: typeof AppNoteIdRoute
AppTermTermRoute: typeof AppTermTermRoute
}
const AppRouteChildren: AppRouteChildren = {
AppCalendarRoute: AppCalendarRoute,
AppGraphRoute: AppGraphRoute,
AppInboxRoute: AppInboxRoute,
AppSearchRoute: AppSearchRoute,
AppTodoRoute: AppTodoRoute,
AppIndexRoute: AppIndexRoute,
AppCollectionIdRoute: AppCollectionIdRoute,
AppNoteIdRoute: AppNoteIdRoute,
AppTermTermRoute: AppTermTermRoute,
}
const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/app': typeof AppRouteWithChildren
'/app/calendar': typeof AppCalendarRoute
'/app/graph': typeof AppGraphRoute
'/app/inbox': typeof AppInboxRoute
'/app/search': typeof AppSearchRoute
'/app/todo': typeof AppTodoRoute
'/app/': typeof AppIndexRoute
'/app/collection/$id': typeof AppCollectionIdRoute
'/app/note/$id': typeof AppNoteIdRoute
'/app/term/$term': typeof AppTermTermRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/app/calendar': typeof AppCalendarRoute
'/app/graph': typeof AppGraphRoute
'/app/inbox': typeof AppInboxRoute
'/app/search': typeof AppSearchRoute
'/app/todo': typeof AppTodoRoute
'/app': typeof AppIndexRoute
'/app/collection/$id': typeof AppCollectionIdRoute
'/app/note/$id': typeof AppNoteIdRoute
'/app/term/$term': typeof AppTermTermRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
'/': typeof IndexRoute
'/app': typeof AppRouteWithChildren
'/app/calendar': typeof AppCalendarRoute
'/app/graph': typeof AppGraphRoute
'/app/inbox': typeof AppInboxRoute
'/app/search': typeof AppSearchRoute
'/app/todo': typeof AppTodoRoute
'/app/': typeof AppIndexRoute
'/app/collection/$id': typeof AppCollectionIdRoute
'/app/note/$id': typeof AppNoteIdRoute
'/app/term/$term': typeof AppTermTermRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/app'
| '/app/calendar'
| '/app/graph'
| '/app/inbox'
| '/app/search'
| '/app/todo'
| '/app/'
| '/app/collection/$id'
| '/app/note/$id'
| '/app/term/$term'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/app/calendar'
| '/app/graph'
| '/app/inbox'
| '/app/search'
| '/app/todo'
| '/app'
| '/app/collection/$id'
| '/app/note/$id'
| '/app/term/$term'
id:
| '__root__'
| '/'
| '/app'
| '/app/calendar'
| '/app/graph'
| '/app/inbox'
| '/app/search'
| '/app/todo'
| '/app/'
| '/app/collection/$id'
| '/app/note/$id'
| '/app/term/$term'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AppRoute: typeof AppRouteWithChildren
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AppRoute: AppRouteWithChildren,
}
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.tsx",
"children": [
"/",
"/app"
]
},
"/": {
"filePath": "index.tsx"
},
"/app": {
"filePath": "app.tsx",
"children": [
"/app/calendar",
"/app/graph",
"/app/inbox",
"/app/search",
"/app/todo",
"/app/",
"/app/collection/$id",
"/app/note/$id",
"/app/term/$term"
]
},
"/app/calendar": {
"filePath": "app/calendar.tsx",
"parent": "/app"
},
"/app/graph": {
"filePath": "app/graph.tsx",
"parent": "/app"
},
"/app/inbox": {
"filePath": "app/inbox.tsx",
"parent": "/app"
},
"/app/search": {
"filePath": "app/search.tsx",
"parent": "/app"
},
"/app/todo": {
"filePath": "app/todo.tsx",
"parent": "/app"
},
"/app/": {
"filePath": "app/index.tsx",
"parent": "/app"
},
"/app/collection/$id": {
"filePath": "app/collection.$id.tsx",
"parent": "/app"
},
"/app/note/$id": {
"filePath": "app/note.$id.tsx",
"parent": "/app"
},
"/app/term/$term": {
"filePath": "app/term.$term.tsx",
"parent": "/app"
}
}
}
ROUTE_MANIFEST_END */

View file

@ -1,9 +1,14 @@
import { Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { createRootRoute, Outlet } from "@tanstack/react-router";
// import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { useEffect } from "react";
import { getSerwist } from "virtual:serwist";
import { ThemeProvider } from "~/hooks/use-theme";
export function RootLayout() {
export const Route = createRootRoute({
component: RootLayout,
});
function RootLayout() {
useEffect(() => {
const loadSerwist = async () => {
if ("serviceWorker" in navigator) {
@ -20,9 +25,9 @@ export function RootLayout() {
loadSerwist();
}, []);
return (
<>
<ThemeProvider>
<Outlet />
<TanStackRouterDevtools />
</>
{/*<TanStackRouterDevtools />*/}
</ThemeProvider>
);
}

52
src/routes/app.tsx Normal file
View file

@ -0,0 +1,52 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
import { PanelLeft } from "lucide-react";
import { AppSidebar } from "~/components/app_sidebar";
import { Button } from "~/components/ui/button";
import { SidebarInset, SidebarProvider, useSidebar } from "~/components/ui/sidebar";
import { MetadataProvider } from "~/hooks/use-metadata";
import { useIsMobile } from "~/hooks/use-mobile";
export const Route = createFileRoute("/app")({
component: AppLayout,
});
export function AppLayout() {
return (
<>
<MetadataProvider>
<SidebarProvider>
<AppSidebar />
<div className="flex flex-col h-svh w-full group">
<SidebarInset className="flex-grow overflow-scroll">
<Outlet />
</SidebarInset>
<MobileBar />
</div>
</SidebarProvider>
</MetadataProvider>
</>
);
}
function MobileBar() {
const isMobile = useIsMobile();
const { toggleSidebar } = useSidebar();
if (!isMobile) {
return;
}
return (
<div className="bg-sidebar w-full p-2">
<Button
data-sidebar="trigger"
variant="ghost"
onClick={toggleSidebar}
className="justify-start"
>
<PanelLeft />
</Button>
</div>
);
}

View file

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/app/calendar')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/app/calendar"!</div>
}

View file

@ -0,0 +1,20 @@
import { createFileRoute } from "@tanstack/react-router"
import { CollectionHeader } from "~/components/collection/collection_header";
import { NotesGrid } from "~/components/note/notes_grid";
import { useCollectionNotesMetadata } from "~/hooks/use-metadata";
export const Route = createFileRoute("/app/collection/$id")({
component: RouteComponent,
})
function RouteComponent() {
const { id } = Route.useParams();
const notes = useCollectionNotesMetadata(id);
return (
<div className="flex flex-col h-full">
<CollectionHeader collectionId={id} />
<NotesGrid notes={notes} />
</div>
);
}

9
src/routes/app/graph.tsx Normal file
View file

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/app/graph')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/app/graph"!</div>
}

15
src/routes/app/inbox.tsx Normal file
View file

@ -0,0 +1,15 @@
import { createFileRoute } from '@tanstack/react-router'
import { NotesGrid } from '~/components/note/notes_grid';
import { useCollectionNotesMetadata } from '~/hooks/use-metadata';
export const Route = createFileRoute('/app/inbox')({
component: RouteComponent,
})
function RouteComponent() {
const notes = useCollectionNotesMetadata("")
return (
<NotesGrid notes={notes} />
);
}

12
src/routes/app/index.tsx Normal file
View file

@ -0,0 +1,12 @@
import { createFileRoute } from "@tanstack/react-router"
import { MetadataInspector } from "~/components/yjs/metadata-inspector";
export const Route = createFileRoute("/app/")({
component: RouteComponent,
});
function RouteComponent() {
return (
<MetadataInspector />
)
}

View file

@ -0,0 +1,36 @@
import { createFileRoute } from "@tanstack/react-router"
import NoteCanvas from "~/components/note/note_canvas";
import { NoteHeader } from "~/components/note/note_header";
import { Editor } from "~/editor/Editor";
import { useNoteMetadata } from "~/hooks/use-metadata";
import { NoteProvider } from "~/hooks/use-note";
export const Route = createFileRoute("/app/note/$id")({
component: RouteComponent,
})
function RouteComponent() {
const { id } = Route.useParams();
return (
<NoteProvider id={id}>
<Content id={id} />
</NoteProvider>
);
}
function Content(props: { id: string }) {
const metadata = useNoteMetadata(props.id);
switch (metadata?.get("type")) {
case "text": return <>
<NoteHeader id={props.id} />
<div className="flex-shrink-0 min-h-full max-w-3xl mx-auto w-full">
<Editor noteId={props.id} />
</div>
</>;
case "canvas": return <>
<NoteCanvas noteId={props.id} />
</>;
default: return null;
}
}

13
src/routes/app/search.tsx Normal file
View file

@ -0,0 +1,13 @@
import { createFileRoute } from "@tanstack/react-router"
import { NotesGrid } from "~/components/note/notes_grid";
import { useNotesMetadata } from "~/hooks/use-metadata";
export const Route = createFileRoute("/app/search")({
component: RouteComponent,
})
function RouteComponent() {
const notes = useNotesMetadata().toArray();
return <NotesGrid notes={notes} allUnpinned />
}

View file

@ -0,0 +1,10 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/app/term/$term')({
component: RouteComponent,
})
function RouteComponent() {
const { term } = Route.useParams();
return <div>Hello "/app/term/{term}"!</div>
}

9
src/routes/app/todo.tsx Normal file
View file

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/app/todo')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/app/todo"!</div>
}

View file

@ -1,6 +1,11 @@
import { createFileRoute, Link } from '@tanstack/react-router';
import logo from '~/logo.svg'
export function RootPage() {
export const Route = createFileRoute("/")({
component: RootPage,
});
function RootPage() {
return (
<div className="text-center">
<header className="min-h-screen flex flex-col items-center justify-center bg-[#282c34] text-white text-[calc(10px+2vmin)]">
@ -28,6 +33,11 @@ export function RootPage() {
>
Learn TanStack
</a>
<Link
to='/app'
>
Go to app
</Link>
</header>
</div>
);

View file

@ -1,7 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@custom-variant dark (&:is([data-theme="dark"] *));
body {
@apply m-0;
@ -52,7 +52,7 @@ code {
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
[data-theme="dark"] {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);

View file

@ -2,12 +2,14 @@ import { defineConfig } from "vite";
import { serwist } from "@serwist/vite";
import viteReact from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
import path from "path";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
TanStackRouterVite(),
viteReact(),
tailwindcss(),
serwist({
@ -16,6 +18,8 @@ export default defineConfig({
globDirectory: "dist",
injectionPoint: "self.__SW_MANIFEST",
rollupFormat: "iife",
// Insanely large max size, since the app **HAS** to function fully offline
maximumFileSizeToCacheInBytes: 512 * 1024 * 1024,
}),
],
server: {