Port everything from v2 except for things that require a server
This commit is contained in:
parent
cfff9199b6
commit
82e329023f
77 changed files with 8505 additions and 52 deletions
|
@ -6,11 +6,6 @@ Yes, another rewrite was needed. Again.
|
||||||
- Yes all of it
|
- Yes all of it
|
||||||
- Client
|
- Client
|
||||||
- Overview
|
- Overview
|
||||||
- Search
|
|
||||||
- Graph
|
- Graph
|
||||||
- Calendar
|
- Calendar
|
||||||
- Todo
|
- Todo
|
||||||
- Collection pages
|
|
||||||
- Note pages
|
|
||||||
- Lexical
|
|
||||||
- Excalidraw
|
|
||||||
|
|
24
package.json
24
package.json
|
@ -9,25 +9,47 @@
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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-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-separator": "^1.1.2",
|
||||||
"@radix-ui/react-slot": "^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",
|
"@radix-ui/react-tooltip": "^1.1.8",
|
||||||
"@tailwindcss/vite": "^4.0.6",
|
"@tailwindcss/vite": "^4.0.6",
|
||||||
"@tanstack/react-router": "^1.114.3",
|
"@tanstack/react-router": "^1.114.3",
|
||||||
"@tanstack/react-router-devtools": "^1.114.3",
|
"@tanstack/react-router-devtools": "^1.114.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "1.0.0",
|
||||||
|
"lexical": "^0.28.0",
|
||||||
"lucide-react": "^0.483.0",
|
"lucide-react": "^0.483.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss": "^4.0.6",
|
"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": {
|
"devDependencies": {
|
||||||
"@serwist/vite": "^9.0.12",
|
"@serwist/vite": "^9.0.12",
|
||||||
"@serwist/window": "^9.0.12",
|
"@serwist/window": "^9.0.12",
|
||||||
|
"@tanstack/router-plugin": "^1.114.25",
|
||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
|
|
3059
pnpm-lock.yaml
generated
3059
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
253
src/components/app_sidebar.tsx
Normal file
253
src/components/app_sidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
46
src/components/collection/collection_header.tsx
Normal file
46
src/components/collection/collection_header.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
211
src/components/collection/collection_settings_dialog.tsx
Normal file
211
src/components/collection/collection_settings_dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
76
src/components/collection/new_collection_dialog.tsx
Normal file
76
src/components/collection/new_collection_dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
17
src/components/editor/link_icon.tsx
Normal file
17
src/components/editor/link_icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
13
src/components/editor/task_icon.tsx
Normal file
13
src/components/editor/task_icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
18
src/components/editor/term_icon.tsx
Normal file
18
src/components/editor/term_icon.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
85
src/components/form/collection_picker.tsx
Normal file
85
src/components/form/collection_picker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
84
src/components/form/color_picker.tsx
Normal file
84
src/components/form/color_picker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
82
src/components/form/icon_picker.tsx
Normal file
82
src/components/form/icon_picker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
57
src/components/form/property_type_combobox.tsx
Normal file
57
src/components/form/property_type_combobox.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
120
src/components/note/new_note_dialog.tsx
Normal file
120
src/components/note/new_note_dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
76
src/components/note/note_canvas.tsx
Normal file
76
src/components/note/note_canvas.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
96
src/components/note/note_header.tsx
Normal file
96
src/components/note/note_header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
114
src/components/note/note_properties_dialog.tsx
Normal file
114
src/components/note/note_properties_dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
75
src/components/note/note_settings_dialog.tsx
Normal file
75
src/components/note/note_settings_dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
105
src/components/note/notes_grid.tsx
Normal file
105
src/components/note/notes_grid.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
155
src/components/ui/alert-dialog.tsx
Normal file
155
src/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
}
|
51
src/components/ui/avatar.tsx
Normal file
51
src/components/ui/avatar.tsx
Normal 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 }
|
175
src/components/ui/command.tsx
Normal file
175
src/components/ui/command.tsx
Normal 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,
|
||||||
|
}
|
133
src/components/ui/dialog.tsx
Normal file
133
src/components/ui/dialog.tsx
Normal 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,
|
||||||
|
}
|
255
src/components/ui/dropdown-menu.tsx
Normal file
255
src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||||
|
}
|
22
src/components/ui/label.tsx
Normal file
22
src/components/ui/label.tsx
Normal 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 }
|
46
src/components/ui/popover.tsx
Normal file
46
src/components/ui/popover.tsx
Normal 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 }
|
45
src/components/ui/radio-group.tsx
Normal file
45
src/components/ui/radio-group.tsx
Normal 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 }
|
|
@ -308,7 +308,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||||
data-slot="sidebar-inset"
|
data-slot="sidebar-inset"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background relative flex w-full flex-1 flex-col",
|
"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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
71
src/components/ui/toggle-group.tsx
Normal file
71
src/components/ui/toggle-group.tsx
Normal 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 }
|
47
src/components/ui/toggle.tsx
Normal file
47
src/components/ui/toggle.tsx
Normal 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 }
|
51
src/components/user/user_avatar.tsx
Normal file
51
src/components/user/user_avatar.tsx
Normal 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("");
|
||||||
|
}
|
64
src/components/yjs/metadata-inspector.tsx
Normal file
64
src/components/yjs/metadata-inspector.tsx
Normal 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
90
src/editor/Editor.tsx
Normal 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,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
26
src/editor/editor_utils.ts
Normal file
26
src/editor/editor_utils.ts
Normal 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();
|
||||||
|
}
|
||||||
|
|
32
src/editor/nodes/focusable_node.ts
Normal file
32
src/editor/nodes/focusable_node.ts
Normal 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;
|
||||||
|
}
|
109
src/editor/nodes/formatted_text.ts
Normal file
109
src/editor/nodes/formatted_text.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
108
src/editor/nodes/header_node.ts
Normal file
108
src/editor/nodes/header_node.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
184
src/editor/nodes/link_node.tsx
Normal file
184
src/editor/nodes/link_node.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
159
src/editor/nodes/task_node.tsx
Normal file
159
src/editor/nodes/task_node.tsx
Normal 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;
|
||||||
|
}
|
142
src/editor/nodes/term_node.tsx
Normal file
142
src/editor/nodes/term_node.tsx
Normal 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;
|
||||||
|
}
|
||||||
|
|
102
src/editor/plugins/formatted_text_plugin.tsx
Normal file
102
src/editor/plugins/formatted_text_plugin.tsx
Normal 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);
|
||||||
|
}
|
78
src/editor/plugins/header_plugin.tsx
Normal file
78
src/editor/plugins/header_plugin.tsx
Normal 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;
|
||||||
|
}
|
136
src/editor/plugins/link_plugin.tsx
Normal file
136
src/editor/plugins/link_plugin.tsx
Normal 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;
|
||||||
|
}
|
48
src/editor/plugins/paragraph_plugin.tsx
Normal file
48
src/editor/plugins/paragraph_plugin.tsx
Normal 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;
|
||||||
|
}
|
49
src/editor/plugins/selection_plugin.tsx
Normal file
49
src/editor/plugins/selection_plugin.tsx
Normal 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());
|
||||||
|
}
|
116
src/editor/plugins/task_plugin.tsx
Normal file
116
src/editor/plugins/task_plugin.tsx
Normal 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);
|
||||||
|
}
|
83
src/editor/plugins/term_plugin.tsx
Normal file
83
src/editor/plugins/term_plugin.tsx
Normal 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;
|
||||||
|
}
|
263
src/editor/serialized_editor_content.ts
Normal file
263
src/editor/serialized_editor_content.ts
Normal 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
8
src/global.d.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import 'react';
|
||||||
|
|
||||||
|
declare module 'react' {
|
||||||
|
// Allow CSS variables inside inline styles
|
||||||
|
interface CSSProperties {
|
||||||
|
[key: `--${string}`]: string | number
|
||||||
|
}
|
||||||
|
}
|
65
src/hooks/use-metadata.tsx
Normal file
65
src/hooks/use-metadata.tsx
Normal 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
81
src/hooks/use-note.tsx
Normal 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
22
src/hooks/use-observe.ts
Normal 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
7
src/hooks/use-redraw.ts
Normal 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
47
src/hooks/use-theme.tsx
Normal 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
33
src/hooks/use-ydoc.ts
Normal 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
26
src/lib/color.ts
Normal 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
12
src/lib/icon.ts
Normal 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
99
src/lib/metadata.ts
Normal 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
42
src/lib/property.ts
Normal 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
48
src/lib/yjs.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
18
src/main.tsx
18
src/main.tsx
|
@ -2,28 +2,14 @@ import { StrictMode } from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import {
|
import {
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
createRootRoute,
|
|
||||||
createRoute,
|
|
||||||
createRouter,
|
createRouter,
|
||||||
} from '@tanstack/react-router'
|
} from '@tanstack/react-router'
|
||||||
|
|
||||||
import '~/styles.css'
|
import '~/styles.css'
|
||||||
import reportWebVitals from '~/reportWebVitals.ts'
|
import reportWebVitals from '~/reportWebVitals.ts'
|
||||||
|
|
||||||
import { RootLayout } from '~/layouts/RootLayout'
|
// Import the generated route tree
|
||||||
import { RootPage } from '~/pages/Root'
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
|
||||||
component: RootLayout,
|
|
||||||
})
|
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: '/',
|
|
||||||
component: RootPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([indexRoute])
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
|
|
369
src/routeTree.gen.ts
Normal file
369
src/routeTree.gen.ts
Normal 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 */
|
|
@ -1,9 +1,14 @@
|
||||||
import { Outlet } from "@tanstack/react-router";
|
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
// import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { getSerwist } from "virtual:serwist";
|
import { getSerwist } from "virtual:serwist";
|
||||||
|
import { ThemeProvider } from "~/hooks/use-theme";
|
||||||
|
|
||||||
export function RootLayout() {
|
export const Route = createRootRoute({
|
||||||
|
component: RootLayout,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RootLayout() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSerwist = async () => {
|
const loadSerwist = async () => {
|
||||||
if ("serviceWorker" in navigator) {
|
if ("serviceWorker" in navigator) {
|
||||||
|
@ -20,9 +25,9 @@ export function RootLayout() {
|
||||||
loadSerwist();
|
loadSerwist();
|
||||||
}, []);
|
}, []);
|
||||||
return (
|
return (
|
||||||
<>
|
<ThemeProvider>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<TanStackRouterDevtools />
|
{/*<TanStackRouterDevtools />*/}
|
||||||
</>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
52
src/routes/app.tsx
Normal file
52
src/routes/app.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
9
src/routes/app/calendar.tsx
Normal file
9
src/routes/app/calendar.tsx
Normal 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>
|
||||||
|
}
|
20
src/routes/app/collection.$id.tsx
Normal file
20
src/routes/app/collection.$id.tsx
Normal 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
9
src/routes/app/graph.tsx
Normal 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
15
src/routes/app/inbox.tsx
Normal 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
12
src/routes/app/index.tsx
Normal 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 />
|
||||||
|
)
|
||||||
|
}
|
36
src/routes/app/note.$id.tsx
Normal file
36
src/routes/app/note.$id.tsx
Normal 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
13
src/routes/app/search.tsx
Normal 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 />
|
||||||
|
}
|
10
src/routes/app/term.$term.tsx
Normal file
10
src/routes/app/term.$term.tsx
Normal 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
9
src/routes/app/todo.tsx
Normal 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>
|
||||||
|
}
|
|
@ -1,6 +1,11 @@
|
||||||
|
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||||
import logo from '~/logo.svg'
|
import logo from '~/logo.svg'
|
||||||
|
|
||||||
export function RootPage() {
|
export const Route = createFileRoute("/")({
|
||||||
|
component: RootPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
function RootPage() {
|
||||||
return (
|
return (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<header className="min-h-screen flex flex-col items-center justify-center bg-[#282c34] text-white text-[calc(10px+2vmin)]">
|
<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
|
Learn TanStack
|
||||||
</a>
|
</a>
|
||||||
|
<Link
|
||||||
|
to='/app'
|
||||||
|
>
|
||||||
|
Go to app
|
||||||
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
|
@ -1,7 +1,7 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is([data-theme="dark"] *));
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply m-0;
|
@apply m-0;
|
||||||
|
@ -52,7 +52,7 @@ code {
|
||||||
--sidebar-ring: oklch(0.708 0 0);
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
[data-theme="dark"] {
|
||||||
--background: oklch(0.145 0 0);
|
--background: oklch(0.145 0 0);
|
||||||
--foreground: oklch(0.985 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(0.205 0 0);
|
--card: oklch(0.205 0 0);
|
||||||
|
|
|
@ -2,12 +2,14 @@ import { defineConfig } from "vite";
|
||||||
import { serwist } from "@serwist/vite";
|
import { serwist } from "@serwist/vite";
|
||||||
import viteReact from "@vitejs/plugin-react";
|
import viteReact from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
|
TanStackRouterVite(),
|
||||||
viteReact(),
|
viteReact(),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
serwist({
|
serwist({
|
||||||
|
@ -16,6 +18,8 @@ export default defineConfig({
|
||||||
globDirectory: "dist",
|
globDirectory: "dist",
|
||||||
injectionPoint: "self.__SW_MANIFEST",
|
injectionPoint: "self.__SW_MANIFEST",
|
||||||
rollupFormat: "iife",
|
rollupFormat: "iife",
|
||||||
|
// Insanely large max size, since the app **HAS** to function fully offline
|
||||||
|
maximumFileSizeToCacheInBytes: 512 * 1024 * 1024,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
|
|
Loading…
Add table
Reference in a new issue