implement collections page
This commit is contained in:
10
packages/core/src/collection.ts
Normal file
10
packages/core/src/collection.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Bookmark } from "./bookmark.js"
|
||||||
|
|
||||||
|
interface Collection {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
bookmarks: Bookmark[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { Collection }
|
@@ -1,3 +1,4 @@
|
|||||||
export * from "./bookmark.ts"
|
export * from "./bookmark.ts"
|
||||||
export * from "./tag.ts"
|
export * from "./tag.ts"
|
||||||
export * from "./user.ts"
|
export * from "./user.ts"
|
||||||
|
export * from "./collection.ts"
|
||||||
|
59
packages/server/src/collection/collection.ts
Normal file
59
packages/server/src/collection/collection.ts
Normal 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 }
|
82
packages/server/src/collection/handlers.ts
Normal file
82
packages/server/src/collection/handlers.ts
Normal 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 }
|
@@ -54,6 +54,19 @@ CREATE TABLE IF NOT EXISTS auth_tokens(
|
|||||||
user_id TEXT NOT NULL,
|
user_id TEXT NOT NULL,
|
||||||
expires_at_unix_ms INTEGER 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)
|
||||||
|
);
|
||||||
`,
|
`,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@@ -1,3 +1,4 @@
|
|||||||
|
import { createCollection, deleteUserCollection, listUserCollections } from "~/collection/handlers.js"
|
||||||
import { authenticated, login, logout, signUp, startBackgroundAuthTokenCleanup } from "./auth/auth.ts"
|
import { authenticated, login, logout, signUp, startBackgroundAuthTokenCleanup } from "./auth/auth.ts"
|
||||||
import { startBackgroundSessionCleanup } from "./auth/session.ts"
|
import { startBackgroundSessionCleanup } from "./auth/session.ts"
|
||||||
import { insertDemoBookmarks } from "./bookmark/bookmark.ts"
|
import { insertDemoBookmarks } from "./bookmark/bookmark.ts"
|
||||||
@@ -53,6 +54,17 @@ async function main() {
|
|||||||
GET: authenticated(listUserTags),
|
GET: authenticated(listUserTags),
|
||||||
POST: authenticated(createUserTag),
|
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,
|
port: 8080,
|
||||||
|
@@ -26,8 +26,8 @@ interface ErrorBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type QueryKey =
|
type QueryKey =
|
||||||
| ["bookmarks" | "tags", ...ReadonlyArray<unknown>]
|
| ["bookmarks" | "tags" | "collections", ...ReadonlyArray<unknown>]
|
||||||
| ["bookmarks" | "tags", string, ...ReadonlyArray<unknown>]
|
| ["bookmarks" | "tags" | "collections", string, ...ReadonlyArray<unknown>]
|
||||||
|
|
||||||
async function fetchApi(route: string, init?: RequestInit): Promise<Response> {
|
async function fetchApi(route: string, init?: RequestInit): Promise<Response> {
|
||||||
const response = await fetch(`${import.meta.env.VITE_API_URL}/api${route}`, {
|
const response = await fetch(`${import.meta.env.VITE_API_URL}/api${route}`, {
|
||||||
|
@@ -13,8 +13,10 @@
|
|||||||
import { Route as rootRoute } from "./__root"
|
import { Route as rootRoute } from "./__root"
|
||||||
import { Route as SignupImport } from "./signup"
|
import { Route as SignupImport } from "./signup"
|
||||||
import { Route as LoginImport } from "./login"
|
import { Route as LoginImport } from "./login"
|
||||||
|
import { Route as CollectionsImport } from "./collections"
|
||||||
import { Route as BookmarksImport } from "./bookmarks"
|
import { Route as BookmarksImport } from "./bookmarks"
|
||||||
import { Route as IndexImport } from "./index"
|
import { Route as IndexImport } from "./index"
|
||||||
|
import { Route as CollectionsIndexImport } from "./collections/index"
|
||||||
import { Route as BookmarksIndexImport } from "./bookmarks/index"
|
import { Route as BookmarksIndexImport } from "./bookmarks/index"
|
||||||
import { Route as BookmarksBookmarkIdImport } from "./bookmarks/$bookmarkId"
|
import { Route as BookmarksBookmarkIdImport } from "./bookmarks/$bookmarkId"
|
||||||
|
|
||||||
@@ -32,6 +34,12 @@ const LoginRoute = LoginImport.update({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
|
const CollectionsRoute = CollectionsImport.update({
|
||||||
|
id: "/collections",
|
||||||
|
path: "/collections",
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
const BookmarksRoute = BookmarksImport.update({
|
const BookmarksRoute = BookmarksImport.update({
|
||||||
id: "/bookmarks",
|
id: "/bookmarks",
|
||||||
path: "/bookmarks",
|
path: "/bookmarks",
|
||||||
@@ -44,6 +52,12 @@ const IndexRoute = IndexImport.update({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
|
const CollectionsIndexRoute = CollectionsIndexImport.update({
|
||||||
|
id: "/",
|
||||||
|
path: "/",
|
||||||
|
getParentRoute: () => CollectionsRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
const BookmarksIndexRoute = BookmarksIndexImport.update({
|
const BookmarksIndexRoute = BookmarksIndexImport.update({
|
||||||
id: "/",
|
id: "/",
|
||||||
path: "/",
|
path: "/",
|
||||||
@@ -74,6 +88,13 @@ declare module "@tanstack/react-router" {
|
|||||||
preLoaderRoute: typeof BookmarksImport
|
preLoaderRoute: typeof BookmarksImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
|
"/collections": {
|
||||||
|
id: "/collections"
|
||||||
|
path: "/collections"
|
||||||
|
fullPath: "/collections"
|
||||||
|
preLoaderRoute: typeof CollectionsImport
|
||||||
|
parentRoute: typeof rootRoute
|
||||||
|
}
|
||||||
"/login": {
|
"/login": {
|
||||||
id: "/login"
|
id: "/login"
|
||||||
path: "/login"
|
path: "/login"
|
||||||
@@ -102,6 +123,13 @@ declare module "@tanstack/react-router" {
|
|||||||
preLoaderRoute: typeof BookmarksIndexImport
|
preLoaderRoute: typeof BookmarksIndexImport
|
||||||
parentRoute: typeof BookmarksImport
|
parentRoute: typeof BookmarksImport
|
||||||
}
|
}
|
||||||
|
"/collections/": {
|
||||||
|
id: "/collections/"
|
||||||
|
path: "/"
|
||||||
|
fullPath: "/collections/"
|
||||||
|
preLoaderRoute: typeof CollectionsIndexImport
|
||||||
|
parentRoute: typeof CollectionsImport
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,13 +149,27 @@ const BookmarksRouteWithChildren = BookmarksRoute._addFileChildren(
|
|||||||
BookmarksRouteChildren,
|
BookmarksRouteChildren,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
interface CollectionsRouteChildren {
|
||||||
|
CollectionsIndexRoute: typeof CollectionsIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollectionsRouteChildren: CollectionsRouteChildren = {
|
||||||
|
CollectionsIndexRoute: CollectionsIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CollectionsRouteWithChildren = CollectionsRoute._addFileChildren(
|
||||||
|
CollectionsRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
"/": typeof IndexRoute
|
"/": typeof IndexRoute
|
||||||
"/bookmarks": typeof BookmarksRouteWithChildren
|
"/bookmarks": typeof BookmarksRouteWithChildren
|
||||||
|
"/collections": typeof CollectionsRouteWithChildren
|
||||||
"/login": typeof LoginRoute
|
"/login": typeof LoginRoute
|
||||||
"/signup": typeof SignupRoute
|
"/signup": typeof SignupRoute
|
||||||
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
||||||
"/bookmarks/": typeof BookmarksIndexRoute
|
"/bookmarks/": typeof BookmarksIndexRoute
|
||||||
|
"/collections/": typeof CollectionsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
@@ -136,16 +178,19 @@ export interface FileRoutesByTo {
|
|||||||
"/signup": typeof SignupRoute
|
"/signup": typeof SignupRoute
|
||||||
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
||||||
"/bookmarks": typeof BookmarksIndexRoute
|
"/bookmarks": typeof BookmarksIndexRoute
|
||||||
|
"/collections": typeof CollectionsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRoute
|
__root__: typeof rootRoute
|
||||||
"/": typeof IndexRoute
|
"/": typeof IndexRoute
|
||||||
"/bookmarks": typeof BookmarksRouteWithChildren
|
"/bookmarks": typeof BookmarksRouteWithChildren
|
||||||
|
"/collections": typeof CollectionsRouteWithChildren
|
||||||
"/login": typeof LoginRoute
|
"/login": typeof LoginRoute
|
||||||
"/signup": typeof SignupRoute
|
"/signup": typeof SignupRoute
|
||||||
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
"/bookmarks/$bookmarkId": typeof BookmarksBookmarkIdRoute
|
||||||
"/bookmarks/": typeof BookmarksIndexRoute
|
"/bookmarks/": typeof BookmarksIndexRoute
|
||||||
|
"/collections/": typeof CollectionsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
@@ -153,26 +198,37 @@ export interface FileRouteTypes {
|
|||||||
fullPaths:
|
fullPaths:
|
||||||
| "/"
|
| "/"
|
||||||
| "/bookmarks"
|
| "/bookmarks"
|
||||||
|
| "/collections"
|
||||||
| "/login"
|
| "/login"
|
||||||
| "/signup"
|
| "/signup"
|
||||||
| "/bookmarks/$bookmarkId"
|
| "/bookmarks/$bookmarkId"
|
||||||
| "/bookmarks/"
|
| "/bookmarks/"
|
||||||
|
| "/collections/"
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: "/" | "/login" | "/signup" | "/bookmarks/$bookmarkId" | "/bookmarks"
|
to:
|
||||||
|
| "/"
|
||||||
|
| "/login"
|
||||||
|
| "/signup"
|
||||||
|
| "/bookmarks/$bookmarkId"
|
||||||
|
| "/bookmarks"
|
||||||
|
| "/collections"
|
||||||
id:
|
id:
|
||||||
| "__root__"
|
| "__root__"
|
||||||
| "/"
|
| "/"
|
||||||
| "/bookmarks"
|
| "/bookmarks"
|
||||||
|
| "/collections"
|
||||||
| "/login"
|
| "/login"
|
||||||
| "/signup"
|
| "/signup"
|
||||||
| "/bookmarks/$bookmarkId"
|
| "/bookmarks/$bookmarkId"
|
||||||
| "/bookmarks/"
|
| "/bookmarks/"
|
||||||
|
| "/collections/"
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
BookmarksRoute: typeof BookmarksRouteWithChildren
|
BookmarksRoute: typeof BookmarksRouteWithChildren
|
||||||
|
CollectionsRoute: typeof CollectionsRouteWithChildren
|
||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
SignupRoute: typeof SignupRoute
|
SignupRoute: typeof SignupRoute
|
||||||
}
|
}
|
||||||
@@ -180,6 +236,7 @@ export interface RootRouteChildren {
|
|||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
BookmarksRoute: BookmarksRouteWithChildren,
|
BookmarksRoute: BookmarksRouteWithChildren,
|
||||||
|
CollectionsRoute: CollectionsRouteWithChildren,
|
||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
SignupRoute: SignupRoute,
|
SignupRoute: SignupRoute,
|
||||||
}
|
}
|
||||||
@@ -196,6 +253,7 @@ export const routeTree = rootRoute
|
|||||||
"children": [
|
"children": [
|
||||||
"/",
|
"/",
|
||||||
"/bookmarks",
|
"/bookmarks",
|
||||||
|
"/collections",
|
||||||
"/login",
|
"/login",
|
||||||
"/signup"
|
"/signup"
|
||||||
]
|
]
|
||||||
@@ -210,6 +268,12 @@ export const routeTree = rootRoute
|
|||||||
"/bookmarks/"
|
"/bookmarks/"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"/collections": {
|
||||||
|
"filePath": "collections.tsx",
|
||||||
|
"children": [
|
||||||
|
"/collections/"
|
||||||
|
]
|
||||||
|
},
|
||||||
"/login": {
|
"/login": {
|
||||||
"filePath": "login.tsx"
|
"filePath": "login.tsx"
|
||||||
},
|
},
|
||||||
@@ -223,6 +287,10 @@ export const routeTree = rootRoute
|
|||||||
"/bookmarks/": {
|
"/bookmarks/": {
|
||||||
"filePath": "bookmarks/index.tsx",
|
"filePath": "bookmarks/index.tsx",
|
||||||
"parent": "/bookmarks"
|
"parent": "/bookmarks"
|
||||||
|
},
|
||||||
|
"/collections/": {
|
||||||
|
"filePath": "collections/index.tsx",
|
||||||
|
"parent": "/collections"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -28,7 +28,6 @@ function RouteComponent() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function mediaQueryListener(this: MediaQueryList) {
|
function mediaQueryListener(this: MediaQueryList) {
|
||||||
console.log(this.matches)
|
|
||||||
if (this.matches) {
|
if (this.matches) {
|
||||||
setLayoutMode(LayoutMode.SideBySide)
|
setLayoutMode(LayoutMode.SideBySide)
|
||||||
} else {
|
} else {
|
||||||
|
@@ -74,7 +74,7 @@ function createBookmarkListStore({
|
|||||||
alwaysExpandItem,
|
alwaysExpandItem,
|
||||||
selectedIndex: selectedBookmarkId ? bookmarks.findIndex((bookmark) => bookmark.id === selectedBookmarkId) : 0,
|
selectedIndex: selectedBookmarkId ? bookmarks.findIndex((bookmark) => bookmark.id === selectedBookmarkId) : 0,
|
||||||
selectedBookmarkId: _selectedBookmarkId ?? "",
|
selectedBookmarkId: _selectedBookmarkId ?? "",
|
||||||
isItemExpanded: false,
|
isItemExpanded: alwaysExpandItem,
|
||||||
|
|
||||||
onItemAction,
|
onItemAction,
|
||||||
|
|
||||||
|
@@ -6,7 +6,7 @@ import { fetchApi, useAuthenticatedQuery } from "~/api"
|
|||||||
import { ActionBar } from "~/app/bookmarks/-action-bar.tsx"
|
import { ActionBar } from "~/app/bookmarks/-action-bar.tsx"
|
||||||
import { useLogOut } from "~/auth.ts"
|
import { useLogOut } from "~/auth.ts"
|
||||||
import { useTags } from "~/bookmark/api.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 { LoadingSpinner } from "~/components/loading-spinner"
|
||||||
import { Message, MessageVariant } from "~/components/message.tsx"
|
import { Message, MessageVariant } from "~/components/message.tsx"
|
||||||
import { useDocumentEvent } from "~/hooks/use-document-event.ts"
|
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">
|
<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>
|
<p className="bg-stone-900 dark:bg-stone-200 text-stone-300 dark:text-stone-800 text-center">MENU</p>
|
||||||
<div className="p-4">
|
<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>
|
<li>
|
||||||
<LogOutButton />
|
<LogOutButton />
|
||||||
</li>
|
</li>
|
||||||
{/* Add other menu items here */}
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
32
packages/web/src/app/collections.tsx
Normal file
32
packages/web/src/app/collections.tsx
Normal 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} />
|
||||||
|
}
|
||||||
|
}
|
259
packages/web/src/app/collections/-collection-list.tsx
Normal file
259
packages/web/src/app/collections/-collection-list.tsx
Normal 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> </span>
|
||||||
|
<span className={isItemExpanded ? "rotate-90" : ""}>></span>
|
||||||
|
<span> </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"> </span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export { CollectionList }
|
||||||
|
export type { CollectionListState }
|
@@ -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 }
|
@@ -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 }
|
@@ -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 }
|
73
packages/web/src/app/collections/-store.tsx
Normal file
73
packages/web/src/app/collections/-store.tsx
Normal 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 }
|
158
packages/web/src/app/collections/index.tsx
Normal file
158
packages/web/src/app/collections/index.tsx
Normal 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"> > </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>
|
||||||
|
)
|
||||||
|
}
|
61
packages/web/src/collection/api.ts
Normal file
61
packages/web/src/collection/api.ts
Normal 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 }
|
32
packages/web/src/collections/api.ts
Normal file
32
packages/web/src/collections/api.ts
Normal 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 }
|
@@ -47,7 +47,11 @@ function LinkButton<
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
ref={ref}
|
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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
Reference in New Issue
Block a user