implement collections page

This commit is contained in:
2025-05-31 22:58:00 +01:00
parent 525d768235
commit 4bc5630922
21 changed files with 1244 additions and 9 deletions

View File

@@ -0,0 +1,10 @@
import type { Bookmark } from "./bookmark.js"
interface Collection {
id: string
name: string
description: string
bookmarks: Bookmark[]
}
export type { Collection }

View File

@@ -1,3 +1,4 @@
export * from "./bookmark.ts"
export * from "./tag.ts"
export * from "./user.ts"
export * from "./collection.ts"

View File

@@ -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<Collection, { userId: string }>(`
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<Collection, { id: string; userId: string }>(`
SELECT *
FROM collections
WHERE id = $id AND user_id = $userId
`).get({ id: collectionId, userId: user.id })
}
export { findCollections, insertCollection, updateCollection, deleteCollection, findCollectionById }

View File

@@ -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 }

View File

@@ -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)
);
`,
]

View File

@@ -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,

View File

@@ -26,8 +26,8 @@ interface ErrorBody {
}
type QueryKey =
| ["bookmarks" | "tags", ...ReadonlyArray<unknown>]
| ["bookmarks" | "tags", string, ...ReadonlyArray<unknown>]
| ["bookmarks" | "tags" | "collections", ...ReadonlyArray<unknown>]
| ["bookmarks" | "tags" | "collections", string, ...ReadonlyArray<unknown>]
async function fetchApi(route: string, init?: RequestInit): Promise<Response> {
const response = await fetch(`${import.meta.env.VITE_API_URL}/api${route}`, {

View File

@@ -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"
}
}
}

View File

@@ -28,7 +28,6 @@ function RouteComponent() {
useEffect(() => {
function mediaQueryListener(this: MediaQueryList) {
console.log(this.matches)
if (this.matches) {
setLayoutMode(LayoutMode.SideBySide)
} else {

View File

@@ -74,7 +74,7 @@ function createBookmarkListStore({
alwaysExpandItem,
selectedIndex: selectedBookmarkId ? bookmarks.findIndex((bookmark) => bookmark.id === selectedBookmarkId) : 0,
selectedBookmarkId: _selectedBookmarkId ?? "",
isItemExpanded: false,
isItemExpanded: alwaysExpandItem,
onItemAction,

View File

@@ -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<HTMLDivElement>; style:
<div ref={ref} style={style} className="border w-full md:w-100">
<p className="bg-stone-900 dark:bg-stone-200 text-stone-300 dark:text-stone-800 text-center">MENU</p>
<div className="p-4">
<ul className="space-y-2">
<ul className="space-x-4 flex justify-center">
<li>
<LinkButton to="/collections">
<span className="underline">C</span>OLLECTIONS
</LinkButton>
</li>
<li>
<LogOutButton />
</li>
{/* Add other menu items here */}
</ul>
</div>
</div>

View File

@@ -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 (
<div className="relative">
<Outlet />
<PageDialog />
</div>
)
}
function PageDialog() {
const dialog = useCollectionPageStore((state) => state.dialog)
switch (dialog.kind) {
case DialogKind.None:
return null
case DialogKind.AddCollection:
return <AddCollectionDialog />
case DialogKind.DeleteCollection:
return <DeleteCollectionDialog collection={dialog.collection} />
case DialogKind.EditCollection:
return <EditCollectionDialog collection={dialog.collection} />
}
}

View File

@@ -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<typeof createCollectionListStore>
const CollectionListStoreContext = createContext<CollectionListStore | null>(null)
function createCollectionListStore({ collections }: CreateStoreOptions) {
return create<CollectionListState>()(
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<T>(selector: (state: CollectionListState) => T): T {
const store = useCollectionListStoreContext()
return useStore(store, selector)
}
function CollectionList({ collections, className }: CollectionListProps) {
const storeRef = useRef<CollectionListStore | null>(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 (
<CollectionListStoreContext.Provider value={storeRef.current}>
<_CollectionList className={className} />
</CollectionListStoreContext.Provider>
)
}
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 (
<ul className={clsx("flex flex-col -mt-2", className)}>
<ListContainer />
</ul>
)
})
function ListContainer() {
const collections = useCollectionListStore((state) => state.collections)
const selectedItemId = useCollectionListStore((state) => state.selectedCollectionId)
return collections.length === 0 ? (
<p>You have not created any collections!</p>
) : (
collections.map((collection, index) => (
<CollectionListItem
key={collection.id}
collection={collection}
selected={collection.id === selectedItemId}
index={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 (
<li
className={clsx("group flex flex-row justify-start py-2", {
"bg-teal-600 text-stone-100": isItemExpanded && selected,
"text-teal-600": selected && !isItemExpanded,
})}
onMouseEnter={onItemHover}
>
<button
type="button"
disabled={!selected}
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
invisible: !selected,
})}
onClick={() => {
setIsItemExpanded(!isItemExpanded)
}}
>
<span className="sr-only">Options for this collection</span>
<span>&nbsp;</span>
<span className={isItemExpanded ? "rotate-90" : ""}>&gt;</span>
<span>&nbsp;</span>
</button>
<div className="flex flex-col w-full">
<div className="block w-full text-start font-bold">{collection.name}</div>
<p className="opacity-80 text-sm">{collection.description}</p>
{isItemExpanded && selected ? (
<div className="flex flex-col space-y-1 md:flex-row md:space-y-0 md:space-x-2 items-end justify-between pt-2">
<div className="flex space-x-2">
<Button variant="light" className="text-sm" onClick={editItem}>
<span className="underline">E</span>DIT
</Button>
<Button variant="light" className="text-sm" onClick={deleteItem}>
<span className="underline">D</span>ELETE
</Button>
<span className="-ml-2">&nbsp;</span>
</div>
</div>
) : null}
</div>
</li>
)
},
)
export { CollectionList }
export type { CollectionListState }

View File

@@ -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<HTMLInputElement | null>(null)
const descriptionInputRef = useRef<HTMLInputElement | null>(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<HTMLFormElement>) {
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 (
<p>
Loading <LoadingSpinner />
</p>
)
}
if (createCollectionMutation.status === "error") {
return (
<Message variant={MessageVariant.Error} className="px-4">
An error occurred when creating collection
</Message>
)
}
return null
}
return (
<Dialog>
<DialogTitle>NEW COLLECTION</DialogTitle>
<DialogBody>
{message()}
<form id={formId} onSubmit={onSubmit}>
<FormField
ref={titleInputRef}
type="text"
name="title"
label="TITLE"
className="w-full mb-4"
labelClassName="bg-stone-300 dark:bg-stone-800"
required
/>
<FormField
ref={descriptionInputRef}
type="text"
name="description"
label="DESCRIPTION"
className="w-full"
labelClassName="bg-stone-300 dark:bg-stone-800"
required
/>
</form>
</DialogBody>
<DialogActionRow>
<Button type="submit" disabled={createCollectionMutation.isPending} form={formId}>
SAVE
</Button>
<Button type="button" disabled={createCollectionMutation.isPending} onClick={cancel}>
<span className="underline">C</span>ANCEL
</Button>
</DialogActionRow>
</Dialog>
)
}
export { AddCollectionDialog }

View File

@@ -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 (
<p>
Deleting <LoadingSpinner />
</p>
)
case "idle":
return (
<p>
The collection titled:
<br />
<br />
<strong>
<em>"{collection.name}"</em>
</strong>
<br />
<br />
will be deleted. Proceed?
</p>
)
case "error":
return <Message variant={MessageVariant.Error}>Failed to delete the collection!</Message>
}
}
function title() {
switch (deleteCollectionMutation.status) {
case "pending":
return "PLEASE WAIT"
case "idle":
return "CONFIRM"
case "error":
return "ERROR OCCURRED"
}
}
return (
<Dialog>
<DialogTitle>{title()}</DialogTitle>
<DialogBody>{body()}</DialogBody>
<DialogActionRow>
<Button disabled={deleteCollectionMutation.isPending} onClick={proceed}>
{deleteCollectionMutation.isError ? "Retry" : "Proceed"} (y)
</Button>
<Button disabled={deleteCollectionMutation.isPending} onClick={cancel}>
Cancel (n)
</Button>
</DialogActionRow>
</Dialog>
)
}
export { DeleteCollectionDialog }

View File

@@ -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<EditFormRef>({ 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 (
<Dialog>
<DialogTitle>EDIT COLLECTION</DialogTitle>
<DialogBody>
<EditForm ref={editFormRef} formId={editFormId} collection={collection} />
</DialogBody>
<DialogActionRow>
<Button type="submit" form={editFormId}>
<span className="underline">S</span>AVE
</Button>
<Button onClick={() => setActiveDialog({ kind: DialogKind.None })}>
<span className="underline">C</span>ANCEL
</Button>
</DialogActionRow>
</Dialog>
)
}
function EditForm({
ref,
formId,
collection,
}: {
ref: React.RefObject<EditFormRef>
formId: string
collection: Collection
}) {
const updateCollectionMutation = useUpdateCollection(collection)
const setActiveDialog = useCollectionPageStore((state) => state.setActiveDialog)
async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
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 (
<p>
Loading <LoadingSpinner />
</p>
)
}
if (updateCollectionMutation.isError) {
return (
<Message variant={MessageVariant.Error} className="px-4">
An error occurred when updating collection
</Message>
)
}
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()}
<form id={formId} onSubmit={onSubmit} ref={setFormRef}>
<FormField
ref={setTitleRef}
type="text"
name="title"
label="TITLE"
defaultValue={collection.name}
className="w-full mb-4"
labelClassName="bg-stone-300 dark:bg-stone-800"
required
/>
<FormField
ref={setDescriptionRef}
type="text"
name="description"
label="DESCRIPTION"
defaultValue={collection.description}
className="w-full"
labelClassName="bg-stone-300 dark:bg-stone-800"
required
/>
</form>
</>
)
}
export { EditCollectionDialog }

View File

@@ -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<CollectionPageState>()((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 }

View File

@@ -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 <AddCollectionDialog />
case DialogKind.DeleteCollection:
return <DeleteCollectionDialog collection={dialog.collection} />
default:
return null
}
}
function CollectionsPage() {
return (
<main className="w-full flex justify-center">
<CollectionsPane />
<CollectionsActionBar className="fixed left-0 right-0 bottom-0" />
<ActiveDialog />
</main>
)
}
function CollectionsPane() {
return (
<div className="flex flex-col py-16 container max-w-3xl md:flex-row lg:py-32">
<header className="mb-4 md:mb-0 md:mr-16 text-start">
<h1 className="font-bold text-start">
<span className="invisible md:hidden">&nbsp;&gt;&nbsp;</span>
YOUR COLLECTIONS
</h1>
</header>
<div className="flex-1">
<CollectionsContainer />
</div>
</div>
)
}
function CollectionsContainer() {
const { data: collections, status } = useCollections()
switch (status) {
case "success":
return collections.length === 0 ? (
<p>You have not created any collections!</p>
) : (
<CollectionList collections={collections} />
)
case "pending":
return (
<p>
Loading <LoadingSpinner />
</p>
)
case "error":
return <p>Error loading collections</p>
}
}
function CollectionsActionBar({ className }: { className?: string }) {
const activeWindow = useCollectionPageStore((state) => state.activeWindow)
const { refs, floatingStyles } = useFloating({
placement: "top",
whileElementsMounted: autoUpdate,
middleware: [offset(8)],
})
return (
<>
<ActionBar ref={refs.setReference} className={className}>
<ActionButtons />
</ActionBar>
{activeWindow === WindowKind.AppMenu && <AppMenuWindow ref={refs.setFloating} style={floatingStyles} />}
</>
)
}
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 (
<div className="flex flex-row justify-center space-x-4">
<Button onClick={addCollection}>
<span className="underline">A</span>DD COLLECTION
</Button>
<Button onClick={toggleAppMenu}></Button>
</div>
)
}
function AppMenuWindow({ ref, style }: { ref: React.Ref<HTMLDivElement>; style: React.CSSProperties }) {
return (
<div ref={ref} style={style} className="border w-full md:w-100">
<p className="bg-stone-900 dark:bg-stone-200 text-stone-300 dark:text-stone-800 text-center">MENU</p>
<div className="p-4">
<ul className="space-y-2">
<li>
<LogOutButton />
</li>
</ul>
</div>
</div>
)
}
function LogOutButton() {
const logOutMutation = useLogOut()
const navigate = useNavigate()
function logOut() {
logOutMutation.mutate()
navigate({ to: "/", replace: true })
}
return (
<Button disabled={logOutMutation.isPending} onClick={logOut}>
LOG OUT
</Button>
)
}

View File

@@ -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 }

View File

@@ -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 }

View File

@@ -47,7 +47,11 @@ function LinkButton<
return (
<Link
ref={ref}
className={twMerge(VARIANT_CLASSES[variant], "disabled:text-stone-500 disabled:cursor-not-allowed", className)}
className={twMerge(
VARIANT_CLASSES[variant],
"block disabled:text-stone-500 disabled:cursor-not-allowed",
className,
)}
{...props}
/>
)