From 4bc5630922fb1e0b2c5f02e702d03d988600b716 Mon Sep 17 00:00:00 2001 From: Kenneth Date: Sat, 31 May 2025 22:58:00 +0100 Subject: [PATCH] implement collections page --- packages/core/src/collection.ts | 10 + packages/core/src/index.ts | 1 + packages/server/src/collection/collection.ts | 59 ++++ packages/server/src/collection/handlers.ts | 82 ++++++ packages/server/src/database.ts | 13 + packages/server/src/server.ts | 12 + packages/web/src/api.ts | 4 +- packages/web/src/app/-route-tree.gen.ts | 70 ++++- packages/web/src/app/bookmarks.tsx | 1 - .../web/src/app/bookmarks/-bookmark-list.tsx | 2 +- packages/web/src/app/bookmarks/index.tsx | 10 +- packages/web/src/app/collections.tsx | 32 +++ .../src/app/collections/-collection-list.tsx | 259 ++++++++++++++++++ .../-dialogs/add-collection-dialog.tsx | 118 ++++++++ .../-dialogs/delete-collection-dialog.tsx | 89 ++++++ .../-dialogs/edit-collection-dialog.tsx | 161 +++++++++++ packages/web/src/app/collections/-store.tsx | 73 +++++ packages/web/src/app/collections/index.tsx | 158 +++++++++++ packages/web/src/collection/api.ts | 61 +++++ packages/web/src/collections/api.ts | 32 +++ packages/web/src/components/button.tsx | 6 +- 21 files changed, 1244 insertions(+), 9 deletions(-) create mode 100644 packages/core/src/collection.ts create mode 100644 packages/server/src/collection/collection.ts create mode 100644 packages/server/src/collection/handlers.ts create mode 100644 packages/web/src/app/collections.tsx create mode 100644 packages/web/src/app/collections/-collection-list.tsx create mode 100644 packages/web/src/app/collections/-dialogs/add-collection-dialog.tsx create mode 100644 packages/web/src/app/collections/-dialogs/delete-collection-dialog.tsx create mode 100644 packages/web/src/app/collections/-dialogs/edit-collection-dialog.tsx create mode 100644 packages/web/src/app/collections/-store.tsx create mode 100644 packages/web/src/app/collections/index.tsx create mode 100644 packages/web/src/collection/api.ts create mode 100644 packages/web/src/collections/api.ts diff --git a/packages/core/src/collection.ts b/packages/core/src/collection.ts new file mode 100644 index 0000000..472f878 --- /dev/null +++ b/packages/core/src/collection.ts @@ -0,0 +1,10 @@ +import type { Bookmark } from "./bookmark.js" + +interface Collection { + id: string + name: string + description: string + bookmarks: Bookmark[] +} + +export type { Collection } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d801707..97f0f3c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,3 +1,4 @@ export * from "./bookmark.ts" export * from "./tag.ts" export * from "./user.ts" +export * from "./collection.ts" diff --git a/packages/server/src/collection/collection.ts b/packages/server/src/collection/collection.ts new file mode 100644 index 0000000..1de4ef0 --- /dev/null +++ b/packages/server/src/collection/collection.ts @@ -0,0 +1,59 @@ +import type { User } from "@markone/core" +import type { Collection } from "@markone/core" +import { db } from "~/database.js" + +function findCollections(user: User): Collection[] { + const collectionsQuery = db.query(` + SELECT id, name, description + FROM collections + WHERE user_id = $userId + `) + return collectionsQuery.all({ userId: user.id }) +} + +function insertCollection(collection: Collection, user: User): void { + const query = db.query(` + INSERT INTO collections (id, user_id, name, description) + VALUES ($id, $userId, $name, $description) + `) + + query.run({ + id: collection.id, + userId: user.id, + name: collection.name, + description: collection.description, + }) +} + +function updateCollection(collection: Collection, user: User): void { + db.query(` + UPDATE collections + SET name = $name, description = $description + WHERE id = $id AND user_id = $userId + `).run({ + id: collection.id, + userId: user.id, + name: collection.name, + description: collection.description, + }) +} + +function deleteCollection(collectionId: string, user: User): void { + db.query(` + DELETE FROM collections + WHERE id = $id AND user_id = $userId + `).run({ + id: collectionId, + userId: user.id, + }) +} + +function findCollectionById(collectionId: string, user: User): Collection | null { + return db.query(` + SELECT * + FROM collections + WHERE id = $id AND user_id = $userId + `).get({ id: collectionId, userId: user.id }) +} + +export { findCollections, insertCollection, updateCollection, deleteCollection, findCollectionById } diff --git a/packages/server/src/collection/handlers.ts b/packages/server/src/collection/handlers.ts new file mode 100644 index 0000000..809574d --- /dev/null +++ b/packages/server/src/collection/handlers.ts @@ -0,0 +1,82 @@ +import { type Collection, DEMO_USER, type User } from "@markone/core" +import { type } from "arktype" +import { ulid } from "ulid" +import { HttpError } from "~/error.ts" +import { findCollections, insertCollection, deleteCollection, updateCollection, findCollectionById } from "./collection.ts" + +const AddCollectionRequestBody = type({ + title: "string", + description: "string", +}) + +const UpdateCollectionRequestBody = type({ + title: "string", + description: "string", +}) + +async function createCollection(request: Bun.BunRequest<"/api/collections">, user: User) { + if (user.id === DEMO_USER.id) { + return Response.json(undefined, { status: 204 }) + } + + const body = AddCollectionRequestBody( + await request.json().catch(() => { + throw new HttpError(400) + }), + ) + + if (body instanceof type.errors) { + throw new HttpError(400, "", body.summary) + } + + const collection: Collection = { + id: ulid(), + name: body.title, + description: body.description, + bookmarks: [], + } + + insertCollection(collection, user) + + return Response.json(collection, { status: 200 }) +} + +async function listUserCollections(request: Bun.BunRequest<"/api/collections">, user: User) { + const collections = findCollections(user) + return Response.json(collections, { status: 200 }) +} + +async function deleteUserCollection(request: Bun.BunRequest<"/api/collections/:id">, user: User) { + if (user.id !== DEMO_USER.id) { + deleteCollection(request.params.id, user) + } + return Response.json(undefined, { status: 204 }) +} + +async function updateUserCollection(request: Bun.BunRequest<"/api/collections/:id">, user: User) { + if (user.id === DEMO_USER.id) { + return Response.json(undefined, { status: 204 }) + } + + const bodyJson = await request.json().catch(() => { + throw new HttpError(400) + }) + const body = UpdateCollectionRequestBody(bodyJson) + if (body instanceof type.errors) { + throw new HttpError(400, "", body.summary) + } + + const existingCollection = findCollectionById(request.params.id, user) + if (!existingCollection) { + throw new HttpError(404) + } + + existingCollection.name = body.title + existingCollection.description = body.description + + updateCollection(existingCollection, user) + + return Response.json(existingCollection, { status: 200 }) +} + +export { createCollection, listUserCollections, deleteUserCollection, updateUserCollection } diff --git a/packages/server/src/database.ts b/packages/server/src/database.ts index 00be5eb..48e9c70 100644 --- a/packages/server/src/database.ts +++ b/packages/server/src/database.ts @@ -54,6 +54,19 @@ CREATE TABLE IF NOT EXISTS auth_tokens( user_id TEXT NOT NULL, expires_at_unix_ms INTEGER NOT NULL ); + +CREATE TABLE IF NOT EXISTS collections( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + user_id TEXT NOT NULL, + description TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS bookmark_collections( + collection_id TEXT NOT NULL, + bookmark_id TEXT NOT NULL, + PRIMARY KEY (collection_id, bookmark_id) +); `, ] diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 0b00a6a..b9a8926 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,3 +1,4 @@ +import { createCollection, deleteUserCollection, listUserCollections } from "~/collection/handlers.js" import { authenticated, login, logout, signUp, startBackgroundAuthTokenCleanup } from "./auth/auth.ts" import { startBackgroundSessionCleanup } from "./auth/session.ts" import { insertDemoBookmarks } from "./bookmark/bookmark.ts" @@ -53,6 +54,17 @@ async function main() { GET: authenticated(listUserTags), POST: authenticated(createUserTag), }, + "/api/collections": { + GET: authenticated(listUserCollections), + POST: authenticated(createCollection), + }, + "/api/collections/:id": { + DELETE: authenticated(deleteUserCollection), + OPTIONS: preflightHandler({ + allowedMethods: ["GET", "POST", "DELETE", "PATCH", "OPTIONS"], + allowedHeaders: ["Accept"], + }), + }, }, port: 8080, diff --git a/packages/web/src/api.ts b/packages/web/src/api.ts index a617a1f..e76e4c1 100644 --- a/packages/web/src/api.ts +++ b/packages/web/src/api.ts @@ -26,8 +26,8 @@ interface ErrorBody { } type QueryKey = - | ["bookmarks" | "tags", ...ReadonlyArray] - | ["bookmarks" | "tags", string, ...ReadonlyArray] + | ["bookmarks" | "tags" | "collections", ...ReadonlyArray] + | ["bookmarks" | "tags" | "collections", string, ...ReadonlyArray] async function fetchApi(route: string, init?: RequestInit): Promise { const response = await fetch(`${import.meta.env.VITE_API_URL}/api${route}`, { diff --git a/packages/web/src/app/-route-tree.gen.ts b/packages/web/src/app/-route-tree.gen.ts index cbfa424..a38579e 100644 --- a/packages/web/src/app/-route-tree.gen.ts +++ b/packages/web/src/app/-route-tree.gen.ts @@ -13,8 +13,10 @@ import { Route as rootRoute } from "./__root" import { Route as SignupImport } from "./signup" import { Route as LoginImport } from "./login" +import { Route as CollectionsImport } from "./collections" import { Route as BookmarksImport } from "./bookmarks" import { Route as IndexImport } from "./index" +import { Route as CollectionsIndexImport } from "./collections/index" import { Route as BookmarksIndexImport } from "./bookmarks/index" import { Route as BookmarksBookmarkIdImport } from "./bookmarks/$bookmarkId" @@ -32,6 +34,12 @@ const LoginRoute = LoginImport.update({ getParentRoute: () => rootRoute, } as any) +const CollectionsRoute = CollectionsImport.update({ + id: "/collections", + path: "/collections", + getParentRoute: () => rootRoute, +} as any) + const BookmarksRoute = BookmarksImport.update({ id: "/bookmarks", path: "/bookmarks", @@ -44,6 +52,12 @@ const IndexRoute = IndexImport.update({ getParentRoute: () => rootRoute, } as any) +const CollectionsIndexRoute = CollectionsIndexImport.update({ + id: "/", + path: "/", + getParentRoute: () => CollectionsRoute, +} as any) + const BookmarksIndexRoute = BookmarksIndexImport.update({ id: "/", path: "/", @@ -74,6 +88,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof BookmarksImport parentRoute: typeof rootRoute } + "/collections": { + id: "/collections" + path: "/collections" + fullPath: "/collections" + preLoaderRoute: typeof CollectionsImport + parentRoute: typeof rootRoute + } "/login": { id: "/login" path: "/login" @@ -102,6 +123,13 @@ declare module "@tanstack/react-router" { preLoaderRoute: typeof BookmarksIndexImport parentRoute: typeof BookmarksImport } + "/collections/": { + id: "/collections/" + path: "/" + fullPath: "/collections/" + preLoaderRoute: typeof CollectionsIndexImport + parentRoute: typeof CollectionsImport + } } } @@ -121,13 +149,27 @@ const BookmarksRouteWithChildren = BookmarksRoute._addFileChildren( BookmarksRouteChildren, ) +interface CollectionsRouteChildren { + CollectionsIndexRoute: typeof CollectionsIndexRoute +} + +const CollectionsRouteChildren: CollectionsRouteChildren = { + CollectionsIndexRoute: CollectionsIndexRoute, +} + +const CollectionsRouteWithChildren = CollectionsRoute._addFileChildren( + CollectionsRouteChildren, +) + export interface FileRoutesByFullPath { "/": typeof IndexRoute "/bookmarks": typeof BookmarksRouteWithChildren + "/collections": typeof CollectionsRouteWithChildren "/login": typeof LoginRoute "/signup": typeof SignupRoute "/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute "/bookmarks/": typeof BookmarksIndexRoute + "/collections/": typeof CollectionsIndexRoute } export interface FileRoutesByTo { @@ -136,16 +178,19 @@ export interface FileRoutesByTo { "/signup": typeof SignupRoute "/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute "/bookmarks": typeof BookmarksIndexRoute + "/collections": typeof CollectionsIndexRoute } export interface FileRoutesById { __root__: typeof rootRoute "/": typeof IndexRoute "/bookmarks": typeof BookmarksRouteWithChildren + "/collections": typeof CollectionsRouteWithChildren "/login": typeof LoginRoute "/signup": typeof SignupRoute "/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute "/bookmarks/": typeof BookmarksIndexRoute + "/collections/": typeof CollectionsIndexRoute } export interface FileRouteTypes { @@ -153,26 +198,37 @@ export interface FileRouteTypes { fullPaths: | "/" | "/bookmarks" + | "/collections" | "/login" | "/signup" | "/bookmarks/$bookmarkId" | "/bookmarks/" + | "/collections/" fileRoutesByTo: FileRoutesByTo - to: "/" | "/login" | "/signup" | "/bookmarks/$bookmarkId" | "/bookmarks" + to: + | "/" + | "/login" + | "/signup" + | "/bookmarks/$bookmarkId" + | "/bookmarks" + | "/collections" id: | "__root__" | "/" | "/bookmarks" + | "/collections" | "/login" | "/signup" | "/bookmarks/$bookmarkId" | "/bookmarks/" + | "/collections/" fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute BookmarksRoute: typeof BookmarksRouteWithChildren + CollectionsRoute: typeof CollectionsRouteWithChildren LoginRoute: typeof LoginRoute SignupRoute: typeof SignupRoute } @@ -180,6 +236,7 @@ export interface RootRouteChildren { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, BookmarksRoute: BookmarksRouteWithChildren, + CollectionsRoute: CollectionsRouteWithChildren, LoginRoute: LoginRoute, SignupRoute: SignupRoute, } @@ -196,6 +253,7 @@ export const routeTree = rootRoute "children": [ "/", "/bookmarks", + "/collections", "/login", "/signup" ] @@ -210,6 +268,12 @@ export const routeTree = rootRoute "/bookmarks/" ] }, + "/collections": { + "filePath": "collections.tsx", + "children": [ + "/collections/" + ] + }, "/login": { "filePath": "login.tsx" }, @@ -223,6 +287,10 @@ export const routeTree = rootRoute "/bookmarks/": { "filePath": "bookmarks/index.tsx", "parent": "/bookmarks" + }, + "/collections/": { + "filePath": "collections/index.tsx", + "parent": "/collections" } } } diff --git a/packages/web/src/app/bookmarks.tsx b/packages/web/src/app/bookmarks.tsx index e5eeff0..8c8b17b 100644 --- a/packages/web/src/app/bookmarks.tsx +++ b/packages/web/src/app/bookmarks.tsx @@ -28,7 +28,6 @@ function RouteComponent() { useEffect(() => { function mediaQueryListener(this: MediaQueryList) { - console.log(this.matches) if (this.matches) { setLayoutMode(LayoutMode.SideBySide) } else { diff --git a/packages/web/src/app/bookmarks/-bookmark-list.tsx b/packages/web/src/app/bookmarks/-bookmark-list.tsx index b608956..cd11cf0 100644 --- a/packages/web/src/app/bookmarks/-bookmark-list.tsx +++ b/packages/web/src/app/bookmarks/-bookmark-list.tsx @@ -74,7 +74,7 @@ function createBookmarkListStore({ alwaysExpandItem, selectedIndex: selectedBookmarkId ? bookmarks.findIndex((bookmark) => bookmark.id === selectedBookmarkId) : 0, selectedBookmarkId: _selectedBookmarkId ?? "", - isItemExpanded: false, + isItemExpanded: alwaysExpandItem, onItemAction, diff --git a/packages/web/src/app/bookmarks/index.tsx b/packages/web/src/app/bookmarks/index.tsx index cacde2b..677d6f1 100644 --- a/packages/web/src/app/bookmarks/index.tsx +++ b/packages/web/src/app/bookmarks/index.tsx @@ -6,7 +6,7 @@ import { fetchApi, useAuthenticatedQuery } from "~/api" import { ActionBar } from "~/app/bookmarks/-action-bar.tsx" import { useLogOut } from "~/auth.ts" import { useTags } from "~/bookmark/api.ts" -import { Button } from "~/components/button.tsx" +import { Button, LinkButton } from "~/components/button" import { LoadingSpinner } from "~/components/loading-spinner" import { Message, MessageVariant } from "~/components/message.tsx" import { useDocumentEvent } from "~/hooks/use-document-event.ts" @@ -359,11 +359,15 @@ function AppMenuWindow({ ref, style }: { ref: React.Ref; style:

MENU

-
    +
      +
    • + + COLLECTIONS + +
    • - {/* Add other menu items here */}
diff --git a/packages/web/src/app/collections.tsx b/packages/web/src/app/collections.tsx new file mode 100644 index 0000000..4802416 --- /dev/null +++ b/packages/web/src/app/collections.tsx @@ -0,0 +1,32 @@ +import { Outlet, createFileRoute } from "@tanstack/react-router" +import { AddCollectionDialog } from "./collections/-dialogs/add-collection-dialog" +import { DialogKind, useCollectionPageStore } from "./collections/-store" +import { EditCollectionDialog } from "./collections/-dialogs/edit-collection-dialog" +import { DeleteCollectionDialog } from "./collections/-dialogs/delete-collection-dialog" + +export const Route = createFileRoute("/collections")({ + component: RouteComponent, +}) + +function RouteComponent() { + return ( +
+ + +
+ ) +} + +function PageDialog() { + const dialog = useCollectionPageStore((state) => state.dialog) + switch (dialog.kind) { + case DialogKind.None: + return null + case DialogKind.AddCollection: + return + case DialogKind.DeleteCollection: + return + case DialogKind.EditCollection: + return + } +} diff --git a/packages/web/src/app/collections/-collection-list.tsx b/packages/web/src/app/collections/-collection-list.tsx new file mode 100644 index 0000000..652e5dc --- /dev/null +++ b/packages/web/src/app/collections/-collection-list.tsx @@ -0,0 +1,259 @@ +import { create } from "zustand/react" +import { subscribeWithSelector } from "zustand/middleware" +import type { Collection } from "@markone/core" +import { createContext, memo, useCallback, useContext, useEffect, useRef } from "react" +import { clsx } from "clsx" +import { Button } from "~/components/button" +import { useMnemonics } from "~/hooks/use-mnemonics" +import { DialogKind, useCollectionPageStore } from "./-store" +import { useStore } from "zustand" + +export enum CollectionListItemAction { + Delete = "Delete", + Edit = "Edit", +} + +type ItemActionCallback = (collection: Collection, action: CollectionListItemAction) => void + +interface CollectionListProps { + collections: Collection[] + className?: string +} + +interface CreateStoreOptions { + collections: Collection[] +} + +interface CollectionListState { + collections: Collection[] + selectedIndex: number + selectedCollectionId: string + isItemExpanded: boolean + + setCollections: (collections: Collection[]) => void + setSelectedIndex: (index: number) => void + setSelectedCollectionId: (id: string) => void + setIsItemExpanded: (expanded: boolean) => void +} + +type CollectionListStore = ReturnType + +const CollectionListStoreContext = createContext(null) + +function createCollectionListStore({ collections }: CreateStoreOptions) { + return create()( + subscribeWithSelector((set) => ({ + collections, + selectedIndex: 0, + selectedCollectionId: collections.length > 0 ? collections[0].id : "", + isItemExpanded: false, + + setCollections(collections: Collection[]) { + set({ collections, selectedCollectionId: collections.length > 0 ? collections[0].id : "", selectedIndex: 0 }) + }, + + setSelectedIndex(index: number) { + set({ selectedIndex: index }) + }, + + setSelectedCollectionId(id: string) { + set({ selectedCollectionId: id }) + }, + + setIsItemExpanded(expanded: boolean) { + set({ isItemExpanded: expanded }) + }, + })), + ) +} + +function useCollectionListStoreContext() { + const store = useContext(CollectionListStoreContext) + if (!store) throw new Error("CollectionListStoreContext not found") + return store +} + +function useCollectionListStore(selector: (state: CollectionListState) => T): T { + const store = useCollectionListStoreContext() + return useStore(store, selector) +} + +function CollectionList({ collections, className }: CollectionListProps) { + const storeRef = useRef(null) + if (!storeRef.current) { + storeRef.current = createCollectionListStore({ collections }) + } + + useEffect(() => { + // biome-ignore lint/style/noNonNullAssertion: storeRef.current is already set above, so cant be null + const store = storeRef.current! + store.getState().setCollections(collections) + }, [collections]) + + return ( + + <_CollectionList className={className} /> + + ) +} + +const _CollectionList = memo(({ className }: { className?: string }) => { + const store = useCollectionListStoreContext() + const handleCollectionListAction = useCollectionPageStore((state) => state.handleCollectionListAction) + + useMnemonics( + { + j: selectNextItem, + ArrowDown: selectNextItem, + + k: selectPrevItem, + ArrowUp: selectPrevItem, + + h: collapseItem, + ArrowLeft: collapseItem, + + l: expandItem, + ArrowRight: expandItem, + + d: deleteItem, + e: editItem, + }, + { + ignore: useCallback(() => useCollectionPageStore.getState().dialog.kind !== DialogKind.None, []), + }, + ) + + function deleteItem() { + const { collections, selectedIndex } = store.getState() + handleCollectionListAction(collections[selectedIndex], CollectionListItemAction.Delete) + } + + function editItem() { + const { collections, selectedIndex } = store.getState() + handleCollectionListAction(collections[selectedIndex], CollectionListItemAction.Edit) + } + + function selectPrevItem() { + const { collections, selectedIndex, setSelectedCollectionId } = store.getState() + const prevIndex = selectedIndex - 1 + if (prevIndex >= 0) { + setSelectedCollectionId(collections[prevIndex].id) + } + } + + function selectNextItem() { + const { collections, selectedIndex, setSelectedCollectionId } = store.getState() + const nextIndex = selectedIndex + 1 + if (nextIndex < collections.length) { + setSelectedCollectionId(collections[nextIndex].id) + } + } + + function expandItem() { + store.getState().setIsItemExpanded(true) + } + + function collapseItem() { + store.getState().setIsItemExpanded(false) + } + + return ( +
    + +
+ ) +}) + +function ListContainer() { + const collections = useCollectionListStore((state) => state.collections) + const selectedItemId = useCollectionListStore((state) => state.selectedCollectionId) + + return collections.length === 0 ? ( +

You have not created any collections!

+ ) : ( + collections.map((collection, index) => ( + + )) + ) +} + +const CollectionListItem = memo( + ({ collection, selected, index }: { collection: Collection; selected: boolean; index: number }) => { + const store = useCollectionListStoreContext() + const isItemExpanded = useCollectionListStore((state) => state.isItemExpanded) + const setSelectedCollectionId = useCollectionListStore((state) => state.setSelectedCollectionId) + const setIsItemExpanded = useCollectionListStore((state) => state.setIsItemExpanded) + const handleCollectionListAction = useCollectionPageStore((state) => state.handleCollectionListAction) + + useEffect(() => { + if (selected) { + store.getState().setSelectedIndex(index) + } + }, [selected, index, store]) + + function onItemHover() { + if (!store.getState().isItemExpanded) { + setSelectedCollectionId(collection.id) + } + } + + function deleteItem() { + handleCollectionListAction(collection, CollectionListItemAction.Delete) + } + + function editItem() { + handleCollectionListAction(collection, CollectionListItemAction.Edit) + } + + return ( +
  • + +
    +
    {collection.name}
    +

    {collection.description}

    + {isItemExpanded && selected ? ( +
    +
    + + +   +
    +
    + ) : null} +
    +
  • + ) + }, +) + +export { CollectionList } +export type { CollectionListState } diff --git a/packages/web/src/app/collections/-dialogs/add-collection-dialog.tsx b/packages/web/src/app/collections/-dialogs/add-collection-dialog.tsx new file mode 100644 index 0000000..a960286 --- /dev/null +++ b/packages/web/src/app/collections/-dialogs/add-collection-dialog.tsx @@ -0,0 +1,118 @@ +import { useId, useRef, useEffect } from "react" +import { useCreateCollection } from "~/collection/api" +import { Button } from "~/components/button" +import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog" +import { FormField } from "~/components/form-field" +import { LoadingSpinner } from "~/components/loading-spinner" +import { Message, MessageVariant } from "~/components/message" +import { useMnemonics } from "~/hooks/use-mnemonics" +import { DialogKind, useCollectionPageStore } from "../-store" + +function AddCollectionDialog() { + const createCollectionMutation = useCreateCollection() + const setActiveDialog = useCollectionPageStore((state) => state.setActiveDialog) + const formId = useId() + const titleInputRef = useRef(null) + const descriptionInputRef = useRef(null) + + useEffect(() => { + setTimeout(() => { + titleInputRef.current?.focus() + }, 0) + }, []) + + useMnemonics( + { + c: () => { + if ( + titleInputRef.current !== document.activeElement && + descriptionInputRef.current !== document.activeElement + ) { + cancel() + } + }, + Escape: () => { + titleInputRef.current?.blur() + descriptionInputRef.current?.blur() + }, + }, + { ignore: () => false }, + ) + + async function onSubmit(event: React.FormEvent) { + event.preventDefault() + const formData = new FormData(event.currentTarget) + const title = formData.get("title") + const description = formData.get("description") + + if (title && typeof title === "string" && description && typeof description === "string") { + try { + await createCollectionMutation.mutateAsync({ title, description }) + setActiveDialog({ kind: DialogKind.None }) + } catch (error) { + // Error will be handled by message() display + } + } + } + + function cancel() { + setActiveDialog({ kind: DialogKind.None }) + } + + function message() { + if (createCollectionMutation.isPending) { + return ( +

    + Loading +

    + ) + } + if (createCollectionMutation.status === "error") { + return ( + + An error occurred when creating collection + + ) + } + return null + } + + return ( + + NEW COLLECTION + + {message()} +
    + + + +
    + + + + +
    + ) +} + +export { AddCollectionDialog } diff --git a/packages/web/src/app/collections/-dialogs/delete-collection-dialog.tsx b/packages/web/src/app/collections/-dialogs/delete-collection-dialog.tsx new file mode 100644 index 0000000..f33bc3c --- /dev/null +++ b/packages/web/src/app/collections/-dialogs/delete-collection-dialog.tsx @@ -0,0 +1,89 @@ +import { useDeleteCollection } from "~/collection/api" +import type { Collection } from "@markone/core" +import { Button } from "~/components/button" +import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog" +import { LoadingSpinner } from "~/components/loading-spinner" +import { Message, MessageVariant } from "~/components/message" +import { useMnemonics } from "~/hooks/use-mnemonics" +import { DialogKind, useCollectionPageStore } from "../-store" + +function DeleteCollectionDialog({ collection }: { collection: Collection }) { + const setActiveDialog = useCollectionPageStore((state) => state.setActiveDialog) + const deleteCollectionMutation = useDeleteCollection() + + useMnemonics( + { + y: proceed, + n: cancel, + }, + { ignore: () => false }, + ) + + async function proceed() { + try { + await deleteCollectionMutation.mutateAsync({ collection }) + setActiveDialog({ kind: DialogKind.None }) + } catch (error) { + console.error(error) + } + } + + function cancel() { + setActiveDialog({ kind: DialogKind.None }) + } + + function body() { + switch (deleteCollectionMutation.status) { + case "pending": + return ( +

    + Deleting +

    + ) + case "idle": + return ( +

    + The collection titled: +
    +
    + + "{collection.name}" + +
    +
    + will be deleted. Proceed? +

    + ) + case "error": + return Failed to delete the collection! + } + } + + function title() { + switch (deleteCollectionMutation.status) { + case "pending": + return "PLEASE WAIT" + case "idle": + return "CONFIRM" + case "error": + return "ERROR OCCURRED" + } + } + + return ( + + {title()} + {body()} + + + + + + ) +} + +export { DeleteCollectionDialog } diff --git a/packages/web/src/app/collections/-dialogs/edit-collection-dialog.tsx b/packages/web/src/app/collections/-dialogs/edit-collection-dialog.tsx new file mode 100644 index 0000000..b1b2b85 --- /dev/null +++ b/packages/web/src/app/collections/-dialogs/edit-collection-dialog.tsx @@ -0,0 +1,161 @@ +import { useId, useRef, useEffect } from "react" +import type { Collection } from "@markone/core" +import { useUpdateCollection } from "~/collections/api" +import { Button } from "~/components/button" +import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog" +import { FormField } from "~/components/form-field" +import { LoadingSpinner } from "~/components/loading-spinner" +import { Message, MessageVariant } from "~/components/message" +import { useMnemonics } from "~/hooks/use-mnemonics" +import { DialogKind, useCollectionPageStore } from "../-store" + +type EditFormRef = { + form: HTMLFormElement | null + titleInput: HTMLInputElement | null + descriptionInput: HTMLInputElement | null +} + +function EditCollectionDialog({ collection }: { collection: Collection }) { + const setActiveDialog = useCollectionPageStore((state) => state.setActiveDialog) + const editFormId = useId() + const editFormRef = useRef({ form: null, titleInput: null, descriptionInput: null }) + + useEffect(() => { + setTimeout(() => { + editFormRef.current.titleInput?.focus() + }, 0) + }, []) + + useMnemonics( + { + s: () => { + if ( + editFormRef.current && + editFormRef.current.titleInput !== document.activeElement && + editFormRef.current.descriptionInput !== document.activeElement + ) { + editFormRef.current.form?.requestSubmit() + } + }, + c: () => { + if ( + editFormRef.current?.titleInput !== document.activeElement && + editFormRef.current?.descriptionInput !== document.activeElement + ) { + setActiveDialog({ kind: DialogKind.None }) + } + }, + Escape: () => { + editFormRef.current?.titleInput?.blur() + editFormRef.current?.descriptionInput?.blur() + }, + }, + { ignore: () => false }, + ) + + return ( + + EDIT COLLECTION + + + + + + + + + ) +} + +function EditForm({ + ref, + formId, + collection, +}: { + ref: React.RefObject + formId: string + collection: Collection +}) { + const updateCollectionMutation = useUpdateCollection(collection) + const setActiveDialog = useCollectionPageStore((state) => state.setActiveDialog) + + async function onSubmit(event: React.FormEvent) { + event.preventDefault() + const formData = new FormData(event.currentTarget) + const title = formData.get("title") + const description = formData.get("description") + + if (title && typeof title === "string" && description && typeof description === "string") { + try { + await updateCollectionMutation.mutateAsync({ title, description }) + setActiveDialog({ kind: DialogKind.None }) + } catch (error) { + // Error will be handled by message() display + } + } + } + + function message() { + if (updateCollectionMutation.isPending) { + return ( +

    + Loading +

    + ) + } + if (updateCollectionMutation.isError) { + return ( + + An error occurred when updating collection + + ) + } + return null + } + + function setFormRef(el: HTMLFormElement | null) { + ref.current.form = el + } + + function setTitleRef(el: HTMLInputElement | null) { + ref.current.titleInput = el + } + + function setDescriptionRef(el: HTMLInputElement | null) { + ref.current.descriptionInput = el + } + + return ( + <> + {message()} +
    + + + + + ) +} + +export { EditCollectionDialog } diff --git a/packages/web/src/app/collections/-store.tsx b/packages/web/src/app/collections/-store.tsx new file mode 100644 index 0000000..35b14d9 --- /dev/null +++ b/packages/web/src/app/collections/-store.tsx @@ -0,0 +1,73 @@ +import { create } from "zustand" +import type { Collection } from "@markone/core" +import { CollectionListItemAction } from "./-collection-list" + +enum DialogKind { + None = "None", + AddCollection = "AddCollection", + DeleteCollection = "DeleteCollection", + EditCollection = "EditCollection", +} + +enum WindowKind { + None = "None", + AppMenu = "AppMenu", +} + +interface DeleteCollectionDialogData { + kind: DialogKind.DeleteCollection + collection: Collection +} + +interface EditCollectionDialogData { + kind: DialogKind.EditCollection + collection: Collection +} + +type DialogData = + | { kind: DialogKind.None } + | { kind: DialogKind.AddCollection } + | DeleteCollectionDialogData + | EditCollectionDialogData + +interface CollectionPageState { + dialog: DialogData + readonly hasDialog: boolean + activeWindow: WindowKind + + setActiveDialog: (dialog: DialogData) => void + closeDialog: () => void + setActiveWindow: (window: WindowKind) => void + handleCollectionListAction: (collection: Collection, action: CollectionListItemAction) => void +} + +const NO_DIALOG: DialogData = { kind: DialogKind.None } + +const useCollectionPageStore = create()((set, get) => ({ + dialog: NO_DIALOG, + activeWindow: WindowKind.None, + + get hasDialog() { + return get().dialog.kind !== DialogKind.None + }, + + setActiveDialog: (dialog) => set({ dialog }), + + closeDialog: () => set({ dialog: NO_DIALOG }), + + setActiveWindow: (window) => set({ activeWindow: window }), + + handleCollectionListAction: (collection, action) => { + switch (action) { + case CollectionListItemAction.Delete: + set({ dialog: { kind: DialogKind.DeleteCollection, collection } }) + break + case CollectionListItemAction.Edit: + set({ dialog: { kind: DialogKind.EditCollection, collection } }) + break + } + }, +})) + +export { DialogKind, WindowKind, useCollectionPageStore } +export type { CollectionPageState, DeleteCollectionDialogData, EditCollectionDialogData } diff --git a/packages/web/src/app/collections/index.tsx b/packages/web/src/app/collections/index.tsx new file mode 100644 index 0000000..98a8eb8 --- /dev/null +++ b/packages/web/src/app/collections/index.tsx @@ -0,0 +1,158 @@ +import { autoUpdate, offset, useFloating } from "@floating-ui/react-dom" +import { createFileRoute, useNavigate } from "@tanstack/react-router" +import { useCallback } from "react" +import { ActionBar } from "~/app/bookmarks/-action-bar" +import { useLogOut } from "~/auth" +import { useCollections } from "~/collection/api" +import { Button } from "~/components/button" +import { LoadingSpinner } from "~/components/loading-spinner" +import { useMnemonics } from "~/hooks/use-mnemonics" +import { CollectionList } from "./-collection-list" +import { AddCollectionDialog } from "./-dialogs/add-collection-dialog" +import { DeleteCollectionDialog } from "./-dialogs/delete-collection-dialog" +import { DialogKind, WindowKind, useCollectionPageStore } from "./-store" + +export const Route = createFileRoute("/collections/")({ + component: CollectionsPage, +}) + +function ActiveDialog() { + const dialog = useCollectionPageStore((state) => state.dialog) + + switch (dialog.kind) { + case DialogKind.AddCollection: + return + case DialogKind.DeleteCollection: + return + default: + return null + } +} + +function CollectionsPage() { + return ( +
    + + + +
    + ) +} + +function CollectionsPane() { + return ( +
    +
    +

    +  >  + YOUR COLLECTIONS +

    +
    +
    + +
    +
    + ) +} + +function CollectionsContainer() { + const { data: collections, status } = useCollections() + + switch (status) { + case "success": + return collections.length === 0 ? ( +

    You have not created any collections!

    + ) : ( + + ) + + case "pending": + return ( +

    + Loading +

    + ) + + case "error": + return

    Error loading collections

    + } +} + +function CollectionsActionBar({ className }: { className?: string }) { + const activeWindow = useCollectionPageStore((state) => state.activeWindow) + const { refs, floatingStyles } = useFloating({ + placement: "top", + whileElementsMounted: autoUpdate, + middleware: [offset(8)], + }) + + return ( + <> + + + + {activeWindow === WindowKind.AppMenu && } + + ) +} + +function ActionButtons() { + const setActiveWindow = useCollectionPageStore((state) => state.setActiveWindow) + const activeWindow = useCollectionPageStore((state) => state.activeWindow) + const setActiveDialog = useCollectionPageStore((state) => state.setActiveDialog) + + useMnemonics( + { + a: addCollection, + }, + { ignore: useCallback(() => useCollectionPageStore.getState().dialog.kind !== DialogKind.None, []) }, + ) + + function addCollection() { + setActiveDialog({ kind: DialogKind.AddCollection }) + } + + function toggleAppMenu() { + setActiveWindow(activeWindow === WindowKind.AppMenu ? WindowKind.None : WindowKind.AppMenu) + } + + return ( +
    + + +
    + ) +} + +function AppMenuWindow({ ref, style }: { ref: React.Ref; style: React.CSSProperties }) { + return ( +
    +

    MENU

    +
    +
      +
    • + +
    • +
    +
    +
    + ) +} + +function LogOutButton() { + const logOutMutation = useLogOut() + const navigate = useNavigate() + + function logOut() { + logOutMutation.mutate() + navigate({ to: "/", replace: true }) + } + + return ( + + ) +} diff --git a/packages/web/src/collection/api.ts b/packages/web/src/collection/api.ts new file mode 100644 index 0000000..1126f71 --- /dev/null +++ b/packages/web/src/collection/api.ts @@ -0,0 +1,61 @@ +import type { Collection } from "@markone/core" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" +import { UnauthenticatedError, useAuthenticatedQuery } from "~/api" +import { fetchApi } from "~/api" + +function useCollections() { + return useAuthenticatedQuery(["collections"], async () => { + const res = await fetchApi("/collections") + return (await res.json()) as Collection[] + }) +} + +function useCreateCollection() { + const navigate = useNavigate() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (body: { title: string; description: string }) => + fetchApi("/collections", { + method: "POST", + body: JSON.stringify(body), + }).then((res) => (res.status === 204 ? Promise.resolve() : res.json())), + onError: (error) => { + if (error instanceof UnauthenticatedError) { + navigate({ to: "/login", replace: true }) + } + }, + onSuccess: (collection: Collection | undefined) => { + if (collection) { + queryClient.setQueryData(["collections"], (collections: Collection[]) => + collections ? [collection, ...collections] : [collection], + ) + } + }, + }) +} + +function useDeleteCollection() { + const navigate = useNavigate() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ collection }: { collection: Collection }) => + fetchApi(`/collections/${collection.id}`, { + method: "DELETE", + }), + onError: (error) => { + if (error instanceof UnauthenticatedError) { + navigate({ to: "/login", replace: true }) + } + }, + onSuccess: (_, { collection }) => { + queryClient.setQueryData(["collections"], (collections: Collection[]) => + collections.filter((it) => it.id !== collection.id), + ) + }, + }) +} + +export { useCollections, useCreateCollection, useDeleteCollection } diff --git a/packages/web/src/collections/api.ts b/packages/web/src/collections/api.ts new file mode 100644 index 0000000..6e778ca --- /dev/null +++ b/packages/web/src/collections/api.ts @@ -0,0 +1,32 @@ +import type { Collection } from "@markone/core" +import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useNavigate } from "@tanstack/react-router" +import { UnauthenticatedError, fetchApi } from "~/api" + +function useUpdateCollection(collection: Collection) { + const queryClient = useQueryClient() + const navigate = useNavigate() + + return useMutation({ + mutationFn: (body: { title: string; description: string }) => + fetchApi(`/collections/${collection.id}`, { + method: "PATCH", + body: JSON.stringify(body), + }).then((res) => (res.status === 204 ? collection : res.json())), + onError: (error) => { + if (error instanceof UnauthenticatedError) { + navigate({ to: "/login", replace: true }) + } + }, + onSuccess: (updatedCollection: Collection | undefined) => { + if (updatedCollection) { + queryClient.setQueryData(["collections"], (collections: Collection[]) => + collections ? collections.map((it) => (it.id === updatedCollection.id ? updatedCollection : it)) : [updatedCollection], + ) + queryClient.setQueryData(["collections", updatedCollection.id], updatedCollection) + } + }, + }) +} + +export { useUpdateCollection } \ No newline at end of file diff --git a/packages/web/src/components/button.tsx b/packages/web/src/components/button.tsx index 62f2185..96cd2e7 100644 --- a/packages/web/src/components/button.tsx +++ b/packages/web/src/components/button.tsx @@ -47,7 +47,11 @@ function LinkButton< return ( )