implement bookmark delete
This commit is contained in:
3
bun.lock
3
bun.lock
@@ -43,6 +43,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss": "^4.1.5",
|
"tailwindcss": "^4.1.5",
|
||||||
"zustand": "^5.0.4",
|
"zustand": "^5.0.4",
|
||||||
},
|
},
|
||||||
@@ -1025,6 +1026,8 @@
|
|||||||
|
|
||||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||||
|
|
||||||
|
"tailwind-merge": ["tailwind-merge@3.2.0", "", {}, "sha512-FQT/OVqCD+7edmmJpsgCsY820RTD5AkBryuG5IUqR5YQZSdj5xlH5nLgH7YPths7WsLPSpSBNneJdM8aS8aeFA=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.1.5", "", {}, "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA=="],
|
"tailwindcss": ["tailwindcss@4.1.5", "", {}, "sha512-nYtSPfWGDiWgCkwQG/m+aX83XCwf62sBgg3bIlNiiOcggnS1x3uVRDAuyelBFL+vJdOPPCGElxv9DjHJjRHiVA=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="],
|
"tapable": ["tapable@2.2.1", "", {}, "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ=="],
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { type User, DEMO_USER } from "@markone/core/user"
|
import { DEMO_USER, type User } from "@markone/core/user"
|
||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import { ulid } from "ulid"
|
import { ulid } from "ulid"
|
||||||
@@ -6,6 +6,7 @@ import { db } from "~/database.ts"
|
|||||||
import { HttpError } from "~/error.ts"
|
import { HttpError } from "~/error.ts"
|
||||||
import { httpHandler } from "~/http-handler.ts"
|
import { httpHandler } from "~/http-handler.ts"
|
||||||
import { createUser, findUserById, findUserByUsername } from "~/user/user.ts"
|
import { createUser, findUserById, findUserByUsername } from "~/user/user.ts"
|
||||||
|
import { hashPassword, verifyPassword } from "./password.ts"
|
||||||
import { createSessionForUser, extendSession, forgetAllSessions, saveSession, verifySession } from "./session.ts"
|
import { createSessionForUser, extendSession, forgetAllSessions, saveSession, verifySession } from "./session.ts"
|
||||||
|
|
||||||
const SignUpRequest = type({
|
const SignUpRequest = type({
|
||||||
@@ -18,20 +19,13 @@ const LoginRequest = type({
|
|||||||
password: "string",
|
password: "string",
|
||||||
})
|
})
|
||||||
|
|
||||||
const createAuthTokenQuery = db.query(
|
|
||||||
"INSERT INTO auth_tokens(id, token, user_id, expires_at_unix_ms) VALUES ($id, $token, $userId, $expiresAt)",
|
|
||||||
)
|
|
||||||
|
|
||||||
const deleteAuthTokenQuery = db.query("DELETE FROM auth_tokens WHERE id = $id")
|
|
||||||
|
|
||||||
const deleteAllAuthTokensQuery = db.query("DELETE FROM auth_tokens WHERE user_id = $userId")
|
|
||||||
|
|
||||||
function authenticated<Route extends string>(
|
function authenticated<Route extends string>(
|
||||||
handler: (request: Bun.BunRequest<Route>, user: User) => Promise<Response>,
|
handler: (request: Bun.BunRequest<Route>, user: User) => Promise<Response>,
|
||||||
) {
|
) {
|
||||||
return httpHandler<Route>((request) => {
|
return httpHandler<Route>((request) => {
|
||||||
const session = verifySession(request.cookies)
|
const session = verifySession(request.cookies)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
|
console.log("session not found!")
|
||||||
throw new HttpError(401)
|
throw new HttpError(401)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +36,8 @@ function authenticated<Route extends string>(
|
|||||||
|
|
||||||
const authTokenCookie = request.cookies.get("auth-token")
|
const authTokenCookie = request.cookies.get("auth-token")
|
||||||
if (authTokenCookie) {
|
if (authTokenCookie) {
|
||||||
|
const deleteAuthTokenQuery = db.query("DELETE FROM auth_tokens WHERE id = $id")
|
||||||
|
|
||||||
// biome-ignore lint/style/noNonNullAssertion: the cookie has already been verified by verifySession previously, therefore the cookie must be in the correct format <token-id>:<token-signature>
|
// biome-ignore lint/style/noNonNullAssertion: the cookie has already been verified by verifySession previously, therefore the cookie must be in the correct format <token-id>:<token-signature>
|
||||||
const tokenId = authTokenCookie.split(":")[0]!
|
const tokenId = authTokenCookie.split(":")[0]!
|
||||||
deleteAuthTokenQuery.run({ id: tokenId })
|
deleteAuthTokenQuery.run({ id: tokenId })
|
||||||
@@ -69,6 +65,10 @@ function rememberLoginForUser(user: User, cookies: Bun.CookieMap) {
|
|||||||
|
|
||||||
const expiryDate = dayjs().add(1, "month")
|
const expiryDate = dayjs().add(1, "month")
|
||||||
|
|
||||||
|
const createAuthTokenQuery = db.query(
|
||||||
|
"INSERT INTO auth_tokens(id, token, user_id, expires_at_unix_ms) VALUES ($id, $token, $userId, $expiresAt)",
|
||||||
|
)
|
||||||
|
|
||||||
createAuthTokenQuery.run({
|
createAuthTokenQuery.run({
|
||||||
id: tokenId,
|
id: tokenId,
|
||||||
token: hashedToken,
|
token: hashedToken,
|
||||||
@@ -78,6 +78,7 @@ function rememberLoginForUser(user: User, cookies: Bun.CookieMap) {
|
|||||||
|
|
||||||
cookies.set("auth-token", `${tokenId}:${authToken.toBase64({ alphabet: "base64url" })}`, {
|
cookies.set("auth-token", `${tokenId}:${authToken.toBase64({ alphabet: "base64url" })}`, {
|
||||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
path: "/api",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -93,7 +94,7 @@ async function signUp(request: Bun.BunRequest<"/api/sign-up">) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { username, password } = signUpRequest
|
const { username, password } = signUpRequest
|
||||||
const hashedPassword = await Bun.password.hash(password, "argon2id")
|
const hashedPassword = await hashPassword(password)
|
||||||
const user = createUser(username, hashedPassword)
|
const user = createUser(username, hashedPassword)
|
||||||
|
|
||||||
await createSessionForUser(user, request.cookies)
|
await createSessionForUser(user, request.cookies)
|
||||||
@@ -116,10 +117,10 @@ async function login(request: Bun.BunRequest<"/api/login">) {
|
|||||||
password: true,
|
password: true,
|
||||||
})
|
})
|
||||||
if (!foundUser) {
|
if (!foundUser) {
|
||||||
throw new HttpError(400)
|
throw new HttpError(401)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = await Bun.password.verify(loginRequest.password, foundUser.password, "argon2id").catch(() => {
|
const ok = await verifyPassword(loginRequest.password, foundUser.password).catch(() => {
|
||||||
throw new HttpError(401)
|
throw new HttpError(401)
|
||||||
})
|
})
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
@@ -131,8 +132,8 @@ async function login(request: Bun.BunRequest<"/api/login">) {
|
|||||||
username: foundUser.username,
|
username: foundUser.username,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.id === DEMO_USER.id) {
|
await createSessionForUser(user, request.cookies)
|
||||||
await createSessionForUser(user, request.cookies)
|
if (user.id !== DEMO_USER.id) {
|
||||||
rememberLoginForUser(user, request.cookies)
|
rememberLoginForUser(user, request.cookies)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +141,8 @@ async function login(request: Bun.BunRequest<"/api/login">) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function logout(request: Bun.BunRequest<"/api/logout">, user: User): Promise<Response> {
|
async function logout(request: Bun.BunRequest<"/api/logout">, user: User): Promise<Response> {
|
||||||
|
const deleteAllAuthTokensQuery = db.query("DELETE FROM auth_tokens WHERE user_id = $userId")
|
||||||
|
|
||||||
forgetAllSessions(user)
|
forgetAllSessions(user)
|
||||||
deleteAllAuthTokensQuery.run({ userId: user.id })
|
deleteAllAuthTokensQuery.run({ userId: user.id })
|
||||||
return new Response(undefined, { status: 200 })
|
return new Response(undefined, { status: 200 })
|
||||||
|
11
packages/server/src/auth/password.ts
Normal file
11
packages/server/src/auth/password.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
const HASH_ALGORITHM = "argon2id"
|
||||||
|
|
||||||
|
async function hashPassword(input: string): Promise<string> {
|
||||||
|
return await Bun.password.hash(input, HASH_ALGORITHM)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifyPassword(input: string, hash: string) {
|
||||||
|
return await Bun.password.verify(input, hash, HASH_ALGORITHM)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { hashPassword, verifyPassword }
|
@@ -15,21 +15,8 @@ const SESSION_ID_BYTE_LENGTH = 24
|
|||||||
const SESSION_ID_COOKIE_NAME = "session-id"
|
const SESSION_ID_COOKIE_NAME = "session-id"
|
||||||
const SESSION_DURATION_MS = 30 * 60 * 1000
|
const SESSION_DURATION_MS = 30 * 60 * 1000
|
||||||
|
|
||||||
const findSessionQuery = db.query("SELECT user_id, expires_at_unix FROM sessions WHERE session_id = $sessionId")
|
|
||||||
|
|
||||||
const deleteSessionQuery = db.query("DELETE FROM sessions WHERE session_id = $sessionId")
|
|
||||||
const forgetAllSessionsQuery = db.query("DELETE FROM sessions WHERE user_id = $userId")
|
|
||||||
const deleteExpiredSessionsQuery = db.query("DELETE FROM sessions WHERE expires_at_unix_ms < $time")
|
|
||||||
|
|
||||||
const saveSessionQuery = db.query(
|
|
||||||
"INSERT INTO sessions(session_id, user_id, expires_at_unix_ms) VALUES ($sessionId, $userId, $expiresAt)",
|
|
||||||
)
|
|
||||||
|
|
||||||
const extendSessionQuery = db.query(
|
|
||||||
"UPDATE sessions SET expires_at_unix_ms = $newExpiryDate WHERE session_id = $session_id",
|
|
||||||
)
|
|
||||||
|
|
||||||
function startBackgroundSessionCleanup() {
|
function startBackgroundSessionCleanup() {
|
||||||
|
const deleteExpiredSessionsQuery = db.query("DELETE FROM sessions WHERE expires_at_unix_ms < $time")
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
deleteExpiredSessionsQuery.run({ time: dayjs().valueOf() })
|
deleteExpiredSessionsQuery.run({ time: dayjs().valueOf() })
|
||||||
}, 5000)
|
}, 5000)
|
||||||
@@ -48,9 +35,11 @@ function signSessionId(sessionId: string): string {
|
|||||||
async function createSessionForUser(user: User, cookies: Bun.CookieMap) {
|
async function createSessionForUser(user: User, cookies: Bun.CookieMap) {
|
||||||
const sessionId = await newSessionId()
|
const sessionId = await newSessionId()
|
||||||
const signedSessionId = signSessionId(sessionId)
|
const signedSessionId = signSessionId(sessionId)
|
||||||
|
|
||||||
const expiryDate = dayjs().add(30, "minutes").valueOf()
|
const expiryDate = dayjs().add(30, "minutes").valueOf()
|
||||||
|
|
||||||
|
const saveSessionQuery = db.query(
|
||||||
|
"INSERT INTO sessions (session_id, user_id, expires_at_unix_ms) VALUES ($sessionId, $userId, $expiresAt)",
|
||||||
|
)
|
||||||
saveSessionQuery.run({
|
saveSessionQuery.run({
|
||||||
sessionId,
|
sessionId,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -59,13 +48,17 @@ async function createSessionForUser(user: User, cookies: Bun.CookieMap) {
|
|||||||
|
|
||||||
cookies.set(SESSION_ID_COOKIE_NAME, signedSessionId, {
|
cookies.set(SESSION_ID_COOKIE_NAME, signedSessionId, {
|
||||||
maxAge: user.id === DEMO_USER.id ? undefined : SESSION_DURATION_MS,
|
maxAge: user.id === DEMO_USER.id ? undefined : SESSION_DURATION_MS,
|
||||||
|
path: "/api",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log("session created for user", user.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSession(session: Session, cookies: Bun.CookieMap) {
|
async function saveSession(session: Session, cookies: Bun.CookieMap) {
|
||||||
cookies.set(SESSION_ID_COOKIE_NAME, session.signedId, {
|
cookies.set(SESSION_ID_COOKIE_NAME, session.signedId, {
|
||||||
maxAge: SESSION_DURATION_MS,
|
maxAge: SESSION_DURATION_MS,
|
||||||
|
path: "/api",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -73,6 +66,7 @@ async function saveSession(session: Session, cookies: Bun.CookieMap) {
|
|||||||
function verifySession(cookie: Bun.CookieMap): Session | null {
|
function verifySession(cookie: Bun.CookieMap): Session | null {
|
||||||
const signedSessionId = cookie.get(SESSION_ID_COOKIE_NAME)
|
const signedSessionId = cookie.get(SESSION_ID_COOKIE_NAME)
|
||||||
if (!signedSessionId) {
|
if (!signedSessionId) {
|
||||||
|
console.log("no cookie")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,18 +78,23 @@ function verifySession(cookie: Bun.CookieMap): Session | null {
|
|||||||
|
|
||||||
const isEqual = a.length === b.length && crypto.timingSafeEqual(a, b)
|
const isEqual = a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||||
if (!isEqual) {
|
if (!isEqual) {
|
||||||
|
console.log("not equal")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const findSessionQuery = db.query("SELECT user_id, expires_at_unix_ms FROM sessions WHERE session_id = $sessionId")
|
||||||
const row = findSessionQuery.get({ sessionId: value })
|
const row = findSessionQuery.get({ sessionId: value })
|
||||||
if (!row) {
|
if (!row) {
|
||||||
|
console.log("no row")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const foundSession = row as { user_id: string; expires_at_unix_ms: number }
|
const foundSession = row as { user_id: string; expires_at_unix_ms: number }
|
||||||
|
|
||||||
const now = dayjs().valueOf()
|
const now = dayjs().valueOf()
|
||||||
if (now > foundSession.expires_at_unix_ms) {
|
if (now > foundSession.expires_at_unix_ms) {
|
||||||
|
const deleteSessionQuery = db.query("DELETE FROM sessions WHERE session_id = $sessionId")
|
||||||
deleteSessionQuery.run({ sessionId: value })
|
deleteSessionQuery.run({ sessionId: value })
|
||||||
|
console.log("session expired!")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +109,9 @@ function verifySession(cookie: Bun.CookieMap): Session | null {
|
|||||||
|
|
||||||
function extendSession(session: Session): Session {
|
function extendSession(session: Session): Session {
|
||||||
const newExpiryDate = dayjs().add(30, "minutes").valueOf()
|
const newExpiryDate = dayjs().add(30, "minutes").valueOf()
|
||||||
|
const extendSessionQuery = db.query(
|
||||||
|
"UPDATE sessions SET expires_at_unix_ms = $newExpiryDate WHERE session_id = $session_id",
|
||||||
|
)
|
||||||
extendSessionQuery.run({
|
extendSessionQuery.run({
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
newExpiryDate,
|
newExpiryDate,
|
||||||
@@ -121,6 +123,7 @@ function extendSession(session: Session): Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function forgetAllSessions(user: User) {
|
function forgetAllSessions(user: User) {
|
||||||
|
const forgetAllSessionsQuery = db.query("DELETE FROM sessions WHERE user_id = $userId")
|
||||||
forgetAllSessionsQuery.run({ userId: user.id })
|
forgetAllSessionsQuery.run({ userId: user.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
24
packages/server/src/bookmark/bookmark.ts
Normal file
24
packages/server/src/bookmark/bookmark.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import type { User } from "@markone/core/user"
|
||||||
|
import { db } from "~/database.ts"
|
||||||
|
import { DEMO_BOOKMARKS } from "./demo-bookmarks.ts"
|
||||||
|
|
||||||
|
function insertDemoBookmarks(user: User) {
|
||||||
|
const query = db.query(`
|
||||||
|
INSERT OR IGNORE INTO bookmarks (id, user_id, kind, title, url)
|
||||||
|
VALUES ($id, $userId, $kind, $title, $url)
|
||||||
|
`)
|
||||||
|
const insert = db.transaction((bookmarks) => {
|
||||||
|
for (const bookmark of bookmarks) {
|
||||||
|
query.run({
|
||||||
|
id: bookmark.id,
|
||||||
|
userId: user.id,
|
||||||
|
kind: bookmark.kind,
|
||||||
|
title: bookmark.title,
|
||||||
|
url: bookmark.url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
insert(DEMO_BOOKMARKS)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { insertDemoBookmarks }
|
66
packages/server/src/bookmark/demo-bookmarks.ts
Normal file
66
packages/server/src/bookmark/demo-bookmarks.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { Bookmark } from "@markone/core/bookmark"
|
||||||
|
|
||||||
|
const DEMO_BOOKMARKS: Bookmark[] = [
|
||||||
|
{
|
||||||
|
kind: "link",
|
||||||
|
id: "01JTKBKVMWRRDR20PY9X4HGPVY",
|
||||||
|
title: "ULID Specification",
|
||||||
|
url: "https://github.com/ulid/spec",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "link",
|
||||||
|
id: "01JTKBKVMW8392A9361PFGZEGM",
|
||||||
|
title: "Another Example Site",
|
||||||
|
url: "https://www.example.net",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "link",
|
||||||
|
id: "01JTKBKVMW3YKY4CMANEK7502N",
|
||||||
|
title: "Documentation Hub",
|
||||||
|
url: "https://docs.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "link",
|
||||||
|
id: "01JTKBKVMWHH7QG0CFQ6Q40E2G",
|
||||||
|
title: "API Reference",
|
||||||
|
url: "https://api.example.com/docs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "link",
|
||||||
|
id: "01JTKBKVMWVR4T3KXXB3PHZD60",
|
||||||
|
title: "Blog Posts",
|
||||||
|
url: "https://blog.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "link",
|
||||||
|
id: "01JTKBKVMWAP0GGKWBN7Z3CYAM",
|
||||||
|
title: "Support Forum",
|
||||||
|
url: "https://forum.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "link",
|
||||||
|
id: "01JTKBKVMW1YJB63F2YCMZNGFE",
|
||||||
|
title: "Tutorials",
|
||||||
|
url: "https://tutorials.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "link",
|
||||||
|
id: "01JTKBKVMW93QYS2EK983RQS40",
|
||||||
|
title: "Resource Library",
|
||||||
|
url: "https://resources.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "link",
|
||||||
|
id: "01JTKBKVMW6NG61138T8SCRFYT",
|
||||||
|
title: "Community Page",
|
||||||
|
url: "https://community.example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "link",
|
||||||
|
id: "01JTKBKVMWDBKSXYHGFXC0HEZB",
|
||||||
|
title: "Project Repository",
|
||||||
|
url: "https://github.com/example/project",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export { DEMO_BOOKMARKS }
|
@@ -1,3 +1,4 @@
|
|||||||
|
import { DEMO_USER } from "@markone/core/user"
|
||||||
import { type } from "arktype"
|
import { type } from "arktype"
|
||||||
import { db } from "~/database.ts"
|
import { db } from "~/database.ts"
|
||||||
import { HttpError } from "~/error.ts"
|
import { HttpError } from "~/error.ts"
|
||||||
@@ -10,16 +11,16 @@ const ListUserBookmarksParams = type({
|
|||||||
skip: ["number", "=", 5],
|
skip: ["number", "=", 5],
|
||||||
})
|
})
|
||||||
|
|
||||||
const listBookmarksQuery = db.query(
|
|
||||||
"SELECT id, kind, title, url FROM bookmarks WHERE user_id = $userId LIMIT $limit OFFSET $skip",
|
|
||||||
)
|
|
||||||
|
|
||||||
async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user: User) {
|
async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user: User) {
|
||||||
const queryParams = ListUserBookmarksParams(request.params)
|
const queryParams = ListUserBookmarksParams(request.params)
|
||||||
if (queryParams instanceof type.errors) {
|
if (queryParams instanceof type.errors) {
|
||||||
throw new HttpError(400, queryParams.summary)
|
throw new HttpError(400, queryParams.summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listBookmarksQuery = db.query(
|
||||||
|
"SELECT id, kind, title, url FROM bookmarks WHERE user_id = $userId ORDER BY id LIMIT $limit OFFSET $skip",
|
||||||
|
)
|
||||||
|
|
||||||
const results = listBookmarksQuery.all({
|
const results = listBookmarksQuery.all({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
limit: queryParams.limit,
|
limit: queryParams.limit,
|
||||||
@@ -29,4 +30,16 @@ async function listUserBookmarks(request: Bun.BunRequest<"/api/bookmarks">, user
|
|||||||
return Response.json(results, { status: 200 })
|
return Response.json(results, { status: 200 })
|
||||||
}
|
}
|
||||||
|
|
||||||
export { listUserBookmarks }
|
async function deleteUserBookmark(request: Bun.BunRequest<"/api/bookmark/:id">, user: User) {
|
||||||
|
console.log("askldjlskajdkl")
|
||||||
|
if (user.id !== DEMO_USER.id) {
|
||||||
|
const deleteBookmarkQuery = db.query("DELETE FROM bookmarks WHERE user_id = $userId AND id = $id")
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
deleteBookmarkQuery.run({ userId: user.id, id: request.params.id })
|
||||||
|
})
|
||||||
|
tx()
|
||||||
|
}
|
||||||
|
return Response.json(undefined, { status: 204 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export { listUserBookmarks, deleteUserBookmark }
|
||||||
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite"
|
|||||||
|
|
||||||
const SCHEMA_VERSION = 0
|
const SCHEMA_VERSION = 0
|
||||||
|
|
||||||
const db = new Database("data.sqlite")
|
const db = new Database("data.sqlite", { strict: true })
|
||||||
|
|
||||||
const createMetadataTableQuery = db.query(`
|
const createMetadataTableQuery = db.query(`
|
||||||
CREATE TABLE IF NOT EXISTS metadata(
|
CREATE TABLE IF NOT EXISTS metadata(
|
||||||
@@ -12,15 +12,12 @@ const createMetadataTableQuery = db.query(`
|
|||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
const schemaVersionQuery = db.query("SELECT version FROM metadata WHERE key = 'schema_version'")
|
|
||||||
const setSchemaVersionQuery = db.query("UPDATE metadata SET value = $schemaVersion WHERE key = 'schema_version'")
|
|
||||||
|
|
||||||
const migrations = [
|
const migrations = [
|
||||||
`
|
`
|
||||||
CREATE TABLE IF NOT EXISTS users(
|
CREATE TABLE IF NOT EXISTS users(
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
password TEXT NOT NULL,
|
password TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS bookmarks(
|
CREATE TABLE IF NOT EXISTS bookmarks(
|
||||||
@@ -47,17 +44,22 @@ CREATE TABLE IF NOT EXISTS auth_tokens(
|
|||||||
`,
|
`,
|
||||||
]
|
]
|
||||||
|
|
||||||
const executeMigrations = db.transaction((migration) => {
|
const executeMigrations = db.transaction((migrations) => {
|
||||||
db.run(migration)
|
for (const migration of migrations) {
|
||||||
|
db.run(migration)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function migrateDatabase() {
|
function migrateDatabase() {
|
||||||
createMetadataTableQuery.run()
|
createMetadataTableQuery.run()
|
||||||
|
|
||||||
|
const schemaVersionQuery = db.query("SELECT value FROM metadata WHERE key = 'schema_version'")
|
||||||
|
const setSchemaVersionQuery = db.query("UPDATE metadata SET value = $schemaVersion WHERE key = 'schema_version'")
|
||||||
|
|
||||||
const row = schemaVersionQuery.get()
|
const row = schemaVersionQuery.get()
|
||||||
let currentVersion: number
|
let currentVersion: number
|
||||||
if (row) {
|
if (row) {
|
||||||
currentVersion = (row as { version: number }).version
|
currentVersion = (row as { value: number }).value
|
||||||
console.log(`Migrating database from version ${currentVersion} to version ${SCHEMA_VERSION}...`)
|
console.log(`Migrating database from version ${currentVersion} to version ${SCHEMA_VERSION}...`)
|
||||||
} else {
|
} else {
|
||||||
currentVersion = -1
|
currentVersion = -1
|
||||||
|
@@ -1,22 +1,46 @@
|
|||||||
import { HttpError } from "./error.ts"
|
import { HttpError } from "./error.ts"
|
||||||
|
|
||||||
|
type HttpMethod = "GET" | "POST" | "DELETE" | "PUT" | "OPTIONS" | "PATCH"
|
||||||
|
|
||||||
|
const ALLOWED_ORIGINS = ["http://localhost:5173"]
|
||||||
|
|
||||||
function httpHandler<Route extends string>(
|
function httpHandler<Route extends string>(
|
||||||
handler: (request: Bun.BunRequest<Route>) => Promise<Response>,
|
handler: (request: Bun.BunRequest<Route>) => Promise<Response>,
|
||||||
): (request: Bun.BunRequest<Route>) => Promise<Response> {
|
): (request: Bun.BunRequest<Route>) => Promise<Response> {
|
||||||
return async (request) => {
|
return async (request) => {
|
||||||
|
let response: Response
|
||||||
try {
|
try {
|
||||||
const response = await handler(request)
|
response = await handler(request)
|
||||||
return response
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpError) {
|
if (error instanceof HttpError) {
|
||||||
if (error.message) {
|
if (error.message) {
|
||||||
return Response.json({ message: error.message }, { status: error.status })
|
response = Response.json({ message: error.message }, { status: error.status })
|
||||||
|
} else {
|
||||||
|
response = new Response(undefined, { status: error.status })
|
||||||
}
|
}
|
||||||
return new Response(undefined, { status: error.status })
|
} else {
|
||||||
|
console.error(error)
|
||||||
|
response = new Response(undefined, { status: 500 })
|
||||||
}
|
}
|
||||||
return new Response(undefined, { status: 500 })
|
|
||||||
}
|
}
|
||||||
|
for (const origin of ALLOWED_ORIGINS) {
|
||||||
|
response.headers.set("Access-Control-Allow-Origin", origin)
|
||||||
|
}
|
||||||
|
response.headers.set("Access-Control-Allow-Credentials", "true")
|
||||||
|
return response
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { httpHandler }
|
function preflightHandler<Route extends string>({ allowedMethods }: { allowedMethods: HttpMethod[] }) {
|
||||||
|
return async (request: Bun.BunRequest<Route>) =>
|
||||||
|
new Response(undefined, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Access-Control-Allow-Origin": ALLOWED_ORIGINS,
|
||||||
|
"Access-Control-Allow-Methods": allowedMethods.join(", "),
|
||||||
|
"Access-Control-Allow-Credentials": "true",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { httpHandler, preflightHandler }
|
||||||
|
@@ -1,13 +1,15 @@
|
|||||||
import { authenticated, login, logout, signUp } from "./auth/auth.ts"
|
import { authenticated, login, logout, signUp } from "./auth/auth.ts"
|
||||||
import { startBackgroundSessionCleanup } from "./auth/session.ts"
|
import { startBackgroundSessionCleanup } from "./auth/session.ts"
|
||||||
import { listUserBookmarks } from "./bookmark/handlers.ts"
|
import { insertDemoBookmarks } from "./bookmark/bookmark.ts"
|
||||||
|
import { listUserBookmarks, deleteUserBookmark } from "./bookmark/handlers.ts"
|
||||||
import { migrateDatabase } from "./database.ts"
|
import { migrateDatabase } from "./database.ts"
|
||||||
import { httpHandler } from "./http-handler.ts"
|
import { httpHandler, preflightHandler } from "./http-handler.ts"
|
||||||
import { createDemoUser } from "./user/user.ts"
|
import { createDemoUser } from "./user/user.ts"
|
||||||
|
|
||||||
function main() {
|
async function main() {
|
||||||
migrateDatabase()
|
migrateDatabase()
|
||||||
createDemoUser()
|
const user = await createDemoUser()
|
||||||
|
insertDemoBookmarks(user)
|
||||||
startBackgroundSessionCleanup()
|
startBackgroundSessionCleanup()
|
||||||
|
|
||||||
Bun.serve({
|
Bun.serve({
|
||||||
@@ -24,7 +26,15 @@ function main() {
|
|||||||
"/api/bookmarks": {
|
"/api/bookmarks": {
|
||||||
GET: authenticated(listUserBookmarks),
|
GET: authenticated(listUserBookmarks),
|
||||||
},
|
},
|
||||||
|
"/api/bookmark/:id": {
|
||||||
|
DELETE: authenticated(deleteUserBookmark),
|
||||||
|
OPTIONS: preflightHandler({
|
||||||
|
allowedMethods: ["GET", "POST", "DELETE", "OPTIONS"],
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
port: 8080,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,20 +1,17 @@
|
|||||||
import { type User, DEMO_USER } from "@markone/core/user"
|
import { DEMO_USER, type User } from "@markone/core/user"
|
||||||
import { ulid } from "ulid"
|
import { ulid } from "ulid"
|
||||||
import { db } from "~/database.ts"
|
import { db } from "~/database.ts"
|
||||||
|
import { DEMO_BOOKMARKS } from "~/bookmark/demo-bookmarks.ts"
|
||||||
|
|
||||||
interface UserWithPassword extends User {
|
interface UserWithPassword extends User {
|
||||||
password: string
|
password: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const findUserByIdQuery = db.query("SELECT id, username FROM users WHERE id = $userId")
|
|
||||||
|
|
||||||
const findUserByUsernameQuery = db.query("SELECT id, username FROM users WHERE username = $username")
|
|
||||||
const findUserByUsernameWithPwQuery = db.query("SELECT id, username, password FROM users WHERE username = $username")
|
|
||||||
|
|
||||||
const createUserQuery = db.query("INSERT INTO users(id, username, password) VALUES ($id, $username, $password)")
|
|
||||||
|
|
||||||
function findUserByUsername(username: string, opts: { password: true }): UserWithPassword | null
|
function findUserByUsername(username: string, opts: { password: true }): UserWithPassword | null
|
||||||
function findUserByUsername(username: string, { password }: { password?: boolean }): User | UserWithPassword | null {
|
function findUserByUsername(username: string, { password }: { password?: boolean }): User | UserWithPassword | null {
|
||||||
|
const findUserByUsernameQuery = db.query("SELECT id, username FROM users WHERE username = $username")
|
||||||
|
const findUserByUsernameWithPwQuery = db.query("SELECT id, username, password FROM users WHERE username = $username")
|
||||||
|
|
||||||
const row = (password ? findUserByUsernameWithPwQuery : findUserByUsernameQuery).get({ username })
|
const row = (password ? findUserByUsernameWithPwQuery : findUserByUsernameQuery).get({ username })
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return null
|
return null
|
||||||
@@ -23,6 +20,7 @@ function findUserByUsername(username: string, { password }: { password?: boolean
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findUserById(userId: string): User | null {
|
function findUserById(userId: string): User | null {
|
||||||
|
const findUserByIdQuery = db.query("SELECT id, username FROM users WHERE id = $userId")
|
||||||
const row = findUserByIdQuery.get({ userId })
|
const row = findUserByIdQuery.get({ userId })
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return null
|
return null
|
||||||
@@ -31,6 +29,7 @@ function findUserById(userId: string): User | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createUser(username: string, password: string): User {
|
function createUser(username: string, password: string): User {
|
||||||
|
const createUserQuery = db.query("INSERT INTO users(id, username, password) VALUES ($id, $username, $password)")
|
||||||
const userId = ulid()
|
const userId = ulid()
|
||||||
createUserQuery.run({
|
createUserQuery.run({
|
||||||
id: userId,
|
id: userId,
|
||||||
@@ -43,19 +42,17 @@ function createUser(username: string, password: string): User {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createDemoUser() {
|
async function createDemoUser(): Promise<User> {
|
||||||
const row = findUserByUsernameQuery.get({ username: DEMO_USER.username })
|
const createUserQuery = db.query(
|
||||||
if (row) {
|
"INSERT OR REPLACE INTO users(id, username, password) VALUES ($id, $username, $password)",
|
||||||
return
|
)
|
||||||
}
|
|
||||||
|
|
||||||
const hashedPassword = await Bun.password.hash(DEMO_USER.unhashedPassword, "argon2id")
|
const hashedPassword = await Bun.password.hash(DEMO_USER.unhashedPassword, "argon2id")
|
||||||
|
|
||||||
createUserQuery.run({
|
createUserQuery.run({
|
||||||
id: DEMO_USER.id,
|
id: DEMO_USER.id,
|
||||||
username: DEMO_USER.username,
|
username: DEMO_USER.username,
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
})
|
})
|
||||||
|
return DEMO_USER
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { User }
|
export type { User }
|
||||||
|
1
packages/web/.env.development
Normal file
1
packages/web/.env.development
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=http://localhost:8080
|
@@ -17,6 +17,7 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"tailwind-merge": "^3.2.0",
|
||||||
"tailwindcss": "^4.1.5",
|
"tailwindcss": "^4.1.5",
|
||||||
"zustand": "^5.0.4"
|
"zustand": "^5.0.4"
|
||||||
},
|
},
|
||||||
|
55
packages/web/src/api.ts
Normal file
55
packages/web/src/api.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { type DefaultError, type UseMutationOptions, type queryOptions, useQuery } from "@tanstack/react-query"
|
||||||
|
import { useNavigate } from "@tanstack/react-router"
|
||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
class BadRequestError extends Error {}
|
||||||
|
class InternalError extends Error {}
|
||||||
|
class UnauthenticatedError extends Error {}
|
||||||
|
|
||||||
|
type QueryKey = ["bookmarks", ...ReadonlyArray<unknown>]
|
||||||
|
|
||||||
|
async function fetchApi<TData>(route: string, init?: RequestInit): Promise<[TData | void, Response]> {
|
||||||
|
const response = await fetch(`${import.meta.env.VITE_API_URL}/api${route}`, {
|
||||||
|
...init,
|
||||||
|
credentials: "include",
|
||||||
|
})
|
||||||
|
switch (response.status) {
|
||||||
|
case 200:
|
||||||
|
return [await response.json(), response]
|
||||||
|
case 204:
|
||||||
|
return [undefined, response]
|
||||||
|
case 400:
|
||||||
|
throw new BadRequestError()
|
||||||
|
case 401:
|
||||||
|
throw new UnauthenticatedError()
|
||||||
|
default:
|
||||||
|
throw new InternalError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAuthenticatedQuery<TData>(queryKey: QueryKey, fn: () => Promise<TData>) {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => fn(),
|
||||||
|
retry: (_, error) => !(error instanceof UnauthenticatedError),
|
||||||
|
})
|
||||||
|
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.error && query.error instanceof UnauthenticatedError) {
|
||||||
|
navigate({ to: "/login", replace: true })
|
||||||
|
}
|
||||||
|
}, [query.error, navigate])
|
||||||
|
|
||||||
|
return query
|
||||||
|
}
|
||||||
|
|
||||||
|
function mutationOptions<TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown>(
|
||||||
|
options: UseMutationOptions<TData, TError, TVariables, TContext>,
|
||||||
|
): UseMutationOptions<TData, TError, TVariables, TContext> {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BadRequestError, InternalError, UnauthenticatedError, fetchApi, useAuthenticatedQuery, mutationOptions }
|
||||||
|
export type { QueryKey }
|
@@ -11,11 +11,18 @@
|
|||||||
// Import Routes
|
// Import Routes
|
||||||
|
|
||||||
import { Route as rootRoute } from "./__root"
|
import { Route as rootRoute } from "./__root"
|
||||||
|
import { Route as LoginImport } from "./login"
|
||||||
import { Route as BookmarksImport } from "./bookmarks"
|
import { Route as BookmarksImport } from "./bookmarks"
|
||||||
import { Route as IndexImport } from "./index"
|
import { Route as IndexImport } from "./index"
|
||||||
|
|
||||||
// Create/Update Routes
|
// Create/Update Routes
|
||||||
|
|
||||||
|
const LoginRoute = LoginImport.update({
|
||||||
|
id: "/login",
|
||||||
|
path: "/login",
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
const BookmarksRoute = BookmarksImport.update({
|
const BookmarksRoute = BookmarksImport.update({
|
||||||
id: "/bookmarks",
|
id: "/bookmarks",
|
||||||
path: "/bookmarks",
|
path: "/bookmarks",
|
||||||
@@ -46,6 +53,13 @@ declare module "@tanstack/react-router" {
|
|||||||
preLoaderRoute: typeof BookmarksImport
|
preLoaderRoute: typeof BookmarksImport
|
||||||
parentRoute: typeof rootRoute
|
parentRoute: typeof rootRoute
|
||||||
}
|
}
|
||||||
|
"/login": {
|
||||||
|
id: "/login"
|
||||||
|
path: "/login"
|
||||||
|
fullPath: "/login"
|
||||||
|
preLoaderRoute: typeof LoginImport
|
||||||
|
parentRoute: typeof rootRoute
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,36 +68,41 @@ declare module "@tanstack/react-router" {
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
"/": typeof IndexRoute
|
"/": typeof IndexRoute
|
||||||
"/bookmarks": typeof BookmarksRoute
|
"/bookmarks": typeof BookmarksRoute
|
||||||
|
"/login": typeof LoginRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
"/": typeof IndexRoute
|
"/": typeof IndexRoute
|
||||||
"/bookmarks": typeof BookmarksRoute
|
"/bookmarks": typeof BookmarksRoute
|
||||||
|
"/login": typeof LoginRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRoute
|
__root__: typeof rootRoute
|
||||||
"/": typeof IndexRoute
|
"/": typeof IndexRoute
|
||||||
"/bookmarks": typeof BookmarksRoute
|
"/bookmarks": typeof BookmarksRoute
|
||||||
|
"/login": typeof LoginRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: "/" | "/bookmarks"
|
fullPaths: "/" | "/bookmarks" | "/login"
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: "/" | "/bookmarks"
|
to: "/" | "/bookmarks" | "/login"
|
||||||
id: "__root__" | "/" | "/bookmarks"
|
id: "__root__" | "/" | "/bookmarks" | "/login"
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
BookmarksRoute: typeof BookmarksRoute
|
BookmarksRoute: typeof BookmarksRoute
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootRouteChildren: RootRouteChildren = {
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
BookmarksRoute: BookmarksRoute,
|
BookmarksRoute: BookmarksRoute,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const routeTree = rootRoute
|
export const routeTree = rootRoute
|
||||||
@@ -97,7 +116,8 @@ export const routeTree = rootRoute
|
|||||||
"filePath": "__root.tsx",
|
"filePath": "__root.tsx",
|
||||||
"children": [
|
"children": [
|
||||||
"/",
|
"/",
|
||||||
"/bookmarks"
|
"/bookmarks",
|
||||||
|
"/login"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"/": {
|
"/": {
|
||||||
@@ -105,6 +125,9 @@ export const routeTree = rootRoute
|
|||||||
},
|
},
|
||||||
"/bookmarks": {
|
"/bookmarks": {
|
||||||
"filePath": "bookmarks.tsx"
|
"filePath": "bookmarks.tsx"
|
||||||
|
},
|
||||||
|
"/login": {
|
||||||
|
"filePath": "login.tsx"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -3,22 +3,11 @@ import { createFileRoute } from "@tanstack/react-router"
|
|||||||
import clsx from "clsx"
|
import clsx from "clsx"
|
||||||
import { useEffect } from "react"
|
import { useEffect } from "react"
|
||||||
import { create } from "zustand"
|
import { create } from "zustand"
|
||||||
|
import { fetchApi, useAuthenticatedQuery } from "~/api"
|
||||||
import { Button } from "~/components/button"
|
import { Button } from "~/components/button"
|
||||||
|
import { useDeleteBookmark } from "~/bookmark/api"
|
||||||
const testBookmarks: LinkBookmark[] = [
|
import { useMnemonics } from "~/hooks/use-mnemonics"
|
||||||
{
|
import { Dialog, DialogActionRow, DialogBody, DialogTitle } from "~/components/dialog"
|
||||||
kind: "link",
|
|
||||||
id: "1",
|
|
||||||
title: "Running a Docker container as a non-root user",
|
|
||||||
url: "https://test.website.com/article/123",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "link",
|
|
||||||
id: "2",
|
|
||||||
title: "Running a Docker container as a non-root user",
|
|
||||||
url: "https://test.website.com/article/123",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const LAYOUT_MODE = {
|
const LAYOUT_MODE = {
|
||||||
popup: "popup",
|
popup: "popup",
|
||||||
@@ -26,25 +15,45 @@ const LAYOUT_MODE = {
|
|||||||
} as const
|
} as const
|
||||||
type LayoutMode = (typeof LAYOUT_MODE)[keyof typeof LAYOUT_MODE]
|
type LayoutMode = (typeof LAYOUT_MODE)[keyof typeof LAYOUT_MODE]
|
||||||
|
|
||||||
|
enum ActiveDialog {
|
||||||
|
None = "None",
|
||||||
|
AddBookmark = "AddBookmark",
|
||||||
|
DeleteBookmark = "DeleteBookmark",
|
||||||
|
}
|
||||||
|
|
||||||
interface BookmarkPageState {
|
interface BookmarkPageState {
|
||||||
bookmarks: LinkBookmark[]
|
bookmarkCount: number
|
||||||
|
selectedBookmarkId: string
|
||||||
selectedBookmarkIndex: number
|
selectedBookmarkIndex: number
|
||||||
isBookmarkItemExpanded: boolean
|
isBookmarkItemExpanded: boolean
|
||||||
isBookmarkPreviewOpened: boolean
|
isBookmarkPreviewOpened: boolean
|
||||||
|
bookmarkToBeDeleted: LinkBookmark | null
|
||||||
layoutMode: LayoutMode
|
layoutMode: LayoutMode
|
||||||
|
activeDialog: ActiveDialog
|
||||||
|
|
||||||
|
setActiveDialog: (dialog: ActiveDialog) => void
|
||||||
setBookmarkItemExpanded: (isExpanded: boolean) => void
|
setBookmarkItemExpanded: (isExpanded: boolean) => void
|
||||||
setBookmarkPreviewOpened: (isOpened: boolean) => void
|
setBookmarkPreviewOpened: (isOpened: boolean) => void
|
||||||
setLayoutMode: (mode: LayoutMode) => void
|
setLayoutMode: (mode: LayoutMode) => void
|
||||||
selectBookmarkAt: (index: number) => void
|
selectBookmark: (bookmark: LinkBookmark, index: number) => void
|
||||||
|
reconcileSelection: (bookmarks: LinkBookmark[]) => void
|
||||||
|
markBookmarkForDeletion: (bookmark: LinkBookmark | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
||||||
bookmarks: testBookmarks,
|
bookmarkCount: 0,
|
||||||
|
bookmarks: [],
|
||||||
|
selectedBookmarkId: "",
|
||||||
selectedBookmarkIndex: 0,
|
selectedBookmarkIndex: 0,
|
||||||
isBookmarkItemExpanded: false,
|
isBookmarkItemExpanded: false,
|
||||||
isBookmarkPreviewOpened: false,
|
isBookmarkPreviewOpened: false,
|
||||||
|
bookmarkToBeDeleted: null,
|
||||||
layoutMode: LAYOUT_MODE.popup,
|
layoutMode: LAYOUT_MODE.popup,
|
||||||
|
activeDialog: ActiveDialog.None,
|
||||||
|
|
||||||
|
setActiveDialog(dialog: ActiveDialog) {
|
||||||
|
set({ activeDialog: dialog })
|
||||||
|
},
|
||||||
|
|
||||||
setBookmarkItemExpanded(isExpanded: boolean) {
|
setBookmarkItemExpanded(isExpanded: boolean) {
|
||||||
set({ isBookmarkItemExpanded: isExpanded })
|
set({ isBookmarkItemExpanded: isExpanded })
|
||||||
@@ -58,46 +67,34 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
|||||||
set({ layoutMode: mode })
|
set({ layoutMode: mode })
|
||||||
},
|
},
|
||||||
|
|
||||||
selectBookmarkAt(index: number) {
|
selectBookmark(bookmark: LinkBookmark, index: number) {
|
||||||
const bookmarks = get().bookmarks
|
set({ selectedBookmarkId: bookmark.id, selectedBookmarkIndex: index })
|
||||||
if (index >= 0 && index < bookmarks.length) {
|
},
|
||||||
set({ selectedBookmarkIndex: index })
|
|
||||||
|
reconcileSelection(bookmarks: LinkBookmark[]) {
|
||||||
|
const { selectedBookmarkId, selectedBookmarkIndex } = get()
|
||||||
|
const newIndex = bookmarks.findIndex((bookmark) => bookmark.id === selectedBookmarkId)
|
||||||
|
if (newIndex !== selectedBookmarkIndex) {
|
||||||
|
if (newIndex >= 0) {
|
||||||
|
set({ selectedBookmarkIndex: newIndex })
|
||||||
|
} else if (selectedBookmarkIndex >= bookmarks.length - 1) {
|
||||||
|
set({ selectedBookmarkIndex: bookmarks.length - 1 })
|
||||||
|
} else if (selectedBookmarkIndex === 0) {
|
||||||
|
set({ selectedBookmarkIndex: 0 })
|
||||||
|
} else {
|
||||||
|
set({ selectedBookmarkIndex: selectedBookmarkIndex + 1 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
markBookmarkForDeletion(bookmark: LinkBookmark | null) {
|
||||||
|
set({ bookmarkToBeDeleted: bookmark })
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function Page() {
|
function Page() {
|
||||||
const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode)
|
const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode)
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
|
||||||
const state = useBookmarkPageStore.getState()
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case "ArrowDown":
|
|
||||||
state.selectBookmarkAt(state.selectedBookmarkIndex + 1)
|
|
||||||
break
|
|
||||||
case "ArrowUp":
|
|
||||||
state.selectBookmarkAt(state.selectedBookmarkIndex - 1)
|
|
||||||
break
|
|
||||||
case "ArrowLeft":
|
|
||||||
state.setBookmarkItemExpanded(false)
|
|
||||||
break
|
|
||||||
case "ArrowRight":
|
|
||||||
state.setBookmarkItemExpanded(true)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("keydown", onKeyDown)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("keydown", onKeyDown)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function mediaQueryListener(this: MediaQueryList) {
|
function mediaQueryListener(this: MediaQueryList) {
|
||||||
if (this.matches) {
|
if (this.matches) {
|
||||||
@@ -118,23 +115,21 @@ function Page() {
|
|||||||
}, [setLayoutMode])
|
}, [setLayoutMode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center h-full">
|
<div className="relative">
|
||||||
<Main>
|
<Main>
|
||||||
<div className="flex flex-col md:flex-row justify-center py-16 lg:py-32 ">
|
<div className="flex flex-col md:flex-row justify-center py-16 lg:py-32">
|
||||||
<header className="mb-4 md:mb-0 md:mr-16 text-start">
|
<header className="mb-4 md:mb-0 md:mr-16 text-start">
|
||||||
<h1 className="font-bold text-start">
|
<h1 className="font-bold text-start">
|
||||||
<span className="invisible md:hidden">> </span>
|
<span className="invisible md:hidden">> </span>
|
||||||
YOUR BOOKMARKS
|
YOUR BOOKMARKS
|
||||||
</h1>
|
</h1>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex flex-col container max-w-2xl -mt-2">
|
<BookmarkListSection />
|
||||||
{testBookmarks.map((bookmark, i) => (
|
|
||||||
<BookmarkListItem key={bookmark.id} index={i} bookmark={bookmark} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<BookmarkPreview />
|
<BookmarkPreview />
|
||||||
</Main>
|
</Main>
|
||||||
|
<ActionBar />
|
||||||
|
<PageDialog />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -155,6 +150,142 @@ function Main({ children }: React.PropsWithChildren) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PageDialog() {
|
||||||
|
const dialog = useBookmarkPageStore((state) => state.activeDialog)
|
||||||
|
switch (dialog) {
|
||||||
|
case ActiveDialog.None:
|
||||||
|
return null
|
||||||
|
case ActiveDialog.AddBookmark:
|
||||||
|
return null
|
||||||
|
case ActiveDialog.DeleteBookmark:
|
||||||
|
return <DeleteBookmarkDialog />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeleteBookmarkDialog() {
|
||||||
|
// biome-ignore lint/style/noNonNullAssertion: this cannot be null when delete bookmark dialog is visible
|
||||||
|
const bookmark = useBookmarkPageStore((state) => state.bookmarkToBeDeleted!)
|
||||||
|
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
||||||
|
const markBookmarkForDeletion = useBookmarkPageStore((state) => state.markBookmarkForDeletion)
|
||||||
|
const deleteBookmarkMutation = useDeleteBookmark()
|
||||||
|
|
||||||
|
useMnemonics(
|
||||||
|
{
|
||||||
|
p: proceed,
|
||||||
|
c: cancel,
|
||||||
|
},
|
||||||
|
{ active: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function proceed() {
|
||||||
|
try {
|
||||||
|
await deleteBookmarkMutation.mutateAsync({ bookmark })
|
||||||
|
setActiveDialog(ActiveDialog.None)
|
||||||
|
markBookmarkForDeletion(null)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel() {
|
||||||
|
setActiveDialog(ActiveDialog.None)
|
||||||
|
markBookmarkForDeletion(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTitle>CONFIRM</DialogTitle>
|
||||||
|
<DialogBody>
|
||||||
|
The bookmark titled{" "}
|
||||||
|
<strong>
|
||||||
|
<em>"{bookmark.title}"</em>
|
||||||
|
</strong>{" "}
|
||||||
|
will be deleted. Proceed?
|
||||||
|
</DialogBody>
|
||||||
|
<DialogActionRow>
|
||||||
|
<Button disabled={deleteBookmarkMutation.isPending} onClick={proceed}>
|
||||||
|
<span className="underline">P</span>roceed
|
||||||
|
</Button>
|
||||||
|
<Button disabled={deleteBookmarkMutation.isPending} onClick={cancel}>
|
||||||
|
<span className="underline">C</span>ancel
|
||||||
|
</Button>
|
||||||
|
</DialogActionRow>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookmarkListSection() {
|
||||||
|
const {
|
||||||
|
data: bookmarks,
|
||||||
|
error,
|
||||||
|
status,
|
||||||
|
} = useAuthenticatedQuery(["bookmarks"], () => fetchApi<LinkBookmark[]>("/bookmarks").then(([data]) => data))
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return <p>Loading...</p>
|
||||||
|
case "success":
|
||||||
|
return <BookmarkList bookmarks={bookmarks} />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function BookmarkList({ bookmarks }: { bookmarks: LinkBookmark[] }) {
|
||||||
|
const reconcileSelection = useBookmarkPageStore((state) => state.reconcileSelection)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reconcileSelection(bookmarks)
|
||||||
|
}, [bookmarks, reconcileSelection])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
const state = useBookmarkPageStore.getState()
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowDown": {
|
||||||
|
const nextIndex = state.selectedBookmarkIndex + 1
|
||||||
|
if (nextIndex < bookmarks.length) {
|
||||||
|
state.selectBookmark(bookmarks[nextIndex], nextIndex)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "ArrowUp": {
|
||||||
|
const prevIndex = state.selectedBookmarkIndex - 1
|
||||||
|
if (prevIndex >= 0) {
|
||||||
|
state.selectBookmark(bookmarks[prevIndex], prevIndex)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case "ArrowLeft":
|
||||||
|
state.setBookmarkItemExpanded(false)
|
||||||
|
break
|
||||||
|
case "ArrowRight":
|
||||||
|
state.setBookmarkItemExpanded(true)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKeyDown)
|
||||||
|
}
|
||||||
|
}, [bookmarks])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col container max-w-2xl -mt-2">
|
||||||
|
{bookmarks.length === 0 ? (
|
||||||
|
<p className="mt-2">You have not saved any bookmark!</p>
|
||||||
|
) : (
|
||||||
|
bookmarks.map((bookmark, i) => <BookmarkListItem key={bookmark.id} index={i} bookmark={bookmark} />)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function BookmarkPreview() {
|
function BookmarkPreview() {
|
||||||
const isVisible = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
|
const isVisible = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
|
||||||
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
|
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
|
||||||
@@ -179,12 +310,28 @@ function BookmarkPreview() {
|
|||||||
|
|
||||||
function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index: number }) {
|
function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index: number }) {
|
||||||
const url = new URL(bookmark.url)
|
const url = new URL(bookmark.url)
|
||||||
const selectedBookmark = useBookmarkPageStore((state) => state.bookmarks[state.selectedBookmarkIndex])
|
const selectedBookmarkIndex = useBookmarkPageStore((state) => state.selectedBookmarkIndex)
|
||||||
const isSelected = selectedBookmark.id === bookmark.id
|
|
||||||
const isBookmarkItemExpanded = useBookmarkPageStore((state) => state.isBookmarkItemExpanded)
|
const isBookmarkItemExpanded = useBookmarkPageStore((state) => state.isBookmarkItemExpanded)
|
||||||
|
const setActiveDialog = useBookmarkPageStore((state) => state.setActiveDialog)
|
||||||
const setBookmarkItemExpanded = useBookmarkPageStore((state) => state.setBookmarkItemExpanded)
|
const setBookmarkItemExpanded = useBookmarkPageStore((state) => state.setBookmarkItemExpanded)
|
||||||
const selectBookmarkAt = useBookmarkPageStore((state) => state.selectBookmarkAt)
|
const selectBookmark = useBookmarkPageStore((state) => state.selectBookmark)
|
||||||
const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
|
const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
|
||||||
|
const markBookmarkForDeletion = useBookmarkPageStore((state) => state.markBookmarkForDeletion)
|
||||||
|
const isSelected = selectedBookmarkIndex === index
|
||||||
|
|
||||||
|
useMnemonics(
|
||||||
|
{
|
||||||
|
d: deleteItem,
|
||||||
|
},
|
||||||
|
{ active: isSelected },
|
||||||
|
)
|
||||||
|
|
||||||
|
function deleteItem() {
|
||||||
|
if (isSelected) {
|
||||||
|
markBookmarkForDeletion(bookmark)
|
||||||
|
setActiveDialog(ActiveDialog.DeleteBookmark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function expandOrOpenPreview() {
|
function expandOrOpenPreview() {
|
||||||
setBookmarkItemExpanded(true)
|
setBookmarkItemExpanded(true)
|
||||||
@@ -202,7 +349,7 @@ function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index:
|
|||||||
})}
|
})}
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (!isBookmarkItemExpanded) {
|
if (!isBookmarkItemExpanded) {
|
||||||
selectBookmarkAt(index)
|
selectBookmark(bookmark, index)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -232,10 +379,10 @@ function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index:
|
|||||||
<p className="text-sm">#dev #devops #devops #devops #devops #devops #devops</p>
|
<p className="text-sm">#dev #devops #devops #devops #devops #devops #devops</p>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<OpenBookmarkPreviewButton />
|
<OpenBookmarkPreviewButton />
|
||||||
<Button className="text-sm">
|
<Button variant="light" className="text-sm">
|
||||||
<span className="underline">E</span>dit
|
<span className="underline">E</span>dit
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="text-sm">
|
<Button variant="light" className="text-sm" onClick={deleteItem}>
|
||||||
<span className="underline">D</span>elete
|
<span className="underline">D</span>elete
|
||||||
</Button>
|
</Button>
|
||||||
<span className="-ml-2"> </span>
|
<span className="-ml-2"> </span>
|
||||||
@@ -279,6 +426,7 @@ function OpenBookmarkPreviewButton() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
variant="light"
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isBookmarkPreviewOpened) {
|
if (isBookmarkPreviewOpened) {
|
||||||
@@ -301,6 +449,19 @@ function OpenBookmarkPreviewButton() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ActionBar() {
|
||||||
|
return (
|
||||||
|
<div className="fixed z-10 bottom-0 left-0 right-0 border-t-1 flex flex-row justify-center py-4 space-x-4">
|
||||||
|
<Button>
|
||||||
|
<span className="underline">A</span>DD
|
||||||
|
</Button>
|
||||||
|
<Button>
|
||||||
|
<span className="underline">S</span>EARCH
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute("/bookmarks")({
|
export const Route = createFileRoute("/bookmarks")({
|
||||||
component: Page,
|
component: Page,
|
||||||
})
|
})
|
||||||
|
@@ -13,12 +13,12 @@ function Index() {
|
|||||||
</h1>
|
</h1>
|
||||||
<p className="pb-4 text-2xl uppercase">BOOKMARK MANAGER</p>
|
<p className="pb-4 text-2xl uppercase">BOOKMARK MANAGER</p>
|
||||||
<div className="flex flex-col text-lg">
|
<div className="flex flex-col text-lg">
|
||||||
<Link>LOGIN</Link>
|
<Link href="/login">LOGIN</Link>
|
||||||
<Link>SIGN UP</Link>
|
<Link>SIGN UP</Link>
|
||||||
<DemoButton />
|
<DemoButton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="container max-w-2xl">
|
||||||
<p>
|
<p>
|
||||||
<strong>WHAT IS MARKONE?</strong>
|
<strong>WHAT IS MARKONE?</strong>
|
||||||
<br />
|
<br />
|
||||||
@@ -52,11 +52,15 @@ function DemoButton() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
async function startDemo() {
|
async function startDemo() {
|
||||||
await loginMutation.mutateAsync({
|
try {
|
||||||
username: DEMO_USER.username,
|
const res = await loginMutation.mutateAsync({
|
||||||
password: DEMO_USER.unhashedPassword,
|
username: DEMO_USER.username,
|
||||||
})
|
password: DEMO_USER.unhashedPassword,
|
||||||
navigate({ to: "/bookmarks" })
|
})
|
||||||
|
if (res.status === 200) {
|
||||||
|
navigate({ to: "/bookmarks" })
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
80
packages/web/src/app/login.tsx
Normal file
80
packages/web/src/app/login.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useNavigate } from "@tanstack/react-router"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
import { FormField } from "~/components/form-field"
|
||||||
|
import { Button } from "~/components/button"
|
||||||
|
import { useLogin } from "~/auth"
|
||||||
|
|
||||||
|
function Page() {
|
||||||
|
const [error, setError] = useState("")
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const loginMutation = useLogin()
|
||||||
|
|
||||||
|
async function submitLoginForm(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (!event.currentTarget) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(event.currentTarget)
|
||||||
|
|
||||||
|
const username = formData.get("username")
|
||||||
|
const password = formData.get("password")
|
||||||
|
|
||||||
|
if (typeof username === "string" && typeof password === "string") {
|
||||||
|
try {
|
||||||
|
const res = await loginMutation.mutateAsync({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
})
|
||||||
|
switch (res.status) {
|
||||||
|
case 200:
|
||||||
|
navigate({ to: "/bookmarks" })
|
||||||
|
break
|
||||||
|
|
||||||
|
case 401:
|
||||||
|
setError("Incorrect username or password!")
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
setError(`Server returned an unexpected response (status ${res.status}.)`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
setError("Unable to connect to the server!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="p-48 flex flex-row items-start justify-center space-x-24">
|
||||||
|
<div className="flex flex-col items-start">
|
||||||
|
<h1 className="text-2xl">
|
||||||
|
<span className="font-bold">MARKONE</span>
|
||||||
|
<br />
|
||||||
|
<span className="pb-4 text-2xl uppercase">BOOKMARK MANAGER</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div className="container max-w-2xl">
|
||||||
|
<h2 className="font-bold">LOG IN TO YOUR ACCOUNT</h2>
|
||||||
|
{error ? (
|
||||||
|
<div className="border border-red-500 text-red-500 p-3 my-4">
|
||||||
|
<p className="text-red-500 font-bold">{error}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<form onSubmit={submitLoginForm}>
|
||||||
|
<FormField type="text" name="username" label="USERNAME" className="mb-2" />
|
||||||
|
<FormField type="password" name="password" label="PASSWORD" className="mb-8" />
|
||||||
|
<Button className="w-full py-2" type="submit">
|
||||||
|
Log in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/login")({
|
||||||
|
component: Page,
|
||||||
|
})
|
@@ -3,9 +3,10 @@ import { useMutation } from "@tanstack/react-query"
|
|||||||
function useLogin() {
|
function useLogin() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (creds: { username: string; password: string }) => {
|
mutationFn: async (creds: { username: string; password: string }) => {
|
||||||
await fetch("/api/login", {
|
return await fetch(`${import.meta.env.VITE_API_URL}/api/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(creds),
|
body: JSON.stringify(creds),
|
||||||
|
credentials: "include",
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
28
packages/web/src/bookmark/api.ts
Normal file
28
packages/web/src/bookmark/api.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Bookmark } from "@markone/core/bookmark"
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
|
import { useNavigate } from "@tanstack/react-router"
|
||||||
|
import { UnauthenticatedError, fetchApi } from "~/api"
|
||||||
|
|
||||||
|
function useDeleteBookmark() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ bookmark }: { bookmark: Bookmark }) =>
|
||||||
|
fetchApi(`/bookmark/${bookmark.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
}),
|
||||||
|
onError: (error) => {
|
||||||
|
if (error instanceof UnauthenticatedError) {
|
||||||
|
navigate({ to: "/login", replace: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: (_, { bookmark }) => {
|
||||||
|
queryClient.setQueryData(["bookmarks"], (bookmarks: Bookmark[]) =>
|
||||||
|
bookmarks.filter((it) => it.id !== bookmark.id),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useDeleteBookmark }
|
@@ -1,13 +1,27 @@
|
|||||||
import clsx from "clsx"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
const VARIANT_CLASSES = {
|
||||||
|
light:
|
||||||
|
"px-4 py-2 md:py-0 font-bold border border-2 border-b-4 border-stone-200 enabled:active:bg-stone-200 enabled:active:text-stone-800 enabled:active:border-b-1 enabled:hover:bg-stone-600 focus:bg-stone-600 focus:ring-0 focus:outline-none enabled:active:translate-y-0.5",
|
||||||
|
normal:
|
||||||
|
"px-4 py-2 md:py-0 font-bold border border-2 border-b-4 border-stone-800 dark:border-stone-200 enabled:active:bg-stone-800 dark:enabled:active:bg-stone-200 enabled:active:text-stone-200 dark:enabled:active:text-stone-800 enabled:active:border-b-1 enabled:hover:bg-stone-400 dark:enabled:hover:bg-stone-600 focus:bg-stone-400 dark:focus:bg-stone-600 focus:ring-0 focus:outline-none enabled:active:translate-y-0.5",
|
||||||
|
} as const
|
||||||
|
|
||||||
function Button({
|
function Button({
|
||||||
className,
|
className,
|
||||||
|
variant = "normal",
|
||||||
|
disabled,
|
||||||
...props
|
...props
|
||||||
}: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>) {
|
}: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> & {
|
||||||
|
variant?: keyof typeof VARIANT_CLASSES
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={clsx(
|
disabled={disabled}
|
||||||
"px-4 font-bold border border-2 border-b-4 border-text-inherit active:bg-stone-700 active:text-stone-200 active:border-b-1 active:translate-y-0.5",
|
className={twMerge(
|
||||||
|
VARIANT_CLASSES[variant],
|
||||||
|
"disabled:text-stone-500 disabled:cursor-not-allowed",
|
||||||
|
disabled ? "stripes" : "",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
23
packages/web/src/components/dialog.tsx
Normal file
23
packages/web/src/components/dialog.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
function Dialog({ children }: React.PropsWithChildren) {
|
||||||
|
return (
|
||||||
|
<div className="fixed z-20 top-0 bottom-0 left-0 right-0 flex items-center justify-center bg-stone-200">
|
||||||
|
<div className="w-full m-8 md:w-2/3 lg:w-1/3 flex flex-col items-center border-2 bg-stone-300 relative after:absolute after:-z-10 after:inset-0 after:bg-stone-400 after:translate-4 md:after:translate-8">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ children }: React.PropsWithChildren) {
|
||||||
|
return <h2 className="select-none font-bold w-full bg-stone-800 text-stone-300 text-center">{children}</h2>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogBody({ children }: React.PropsWithChildren) {
|
||||||
|
return <div className="m-8 text-center">{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogActionRow({ children }: React.PropsWithChildren) {
|
||||||
|
return <div className="flex flex-row space-x-4 mb-8">{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Dialog, DialogTitle, DialogBody, DialogActionRow }
|
32
packages/web/src/components/form-field.tsx
Normal file
32
packages/web/src/components/form-field.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { clsx } from "clsx"
|
||||||
|
import { useId } from "react"
|
||||||
|
|
||||||
|
interface FormFieldProps {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
type: React.HTMLInputTypeAttribute
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormField({ name, label, type, className }: FormFieldProps) {
|
||||||
|
const id = useId()
|
||||||
|
return (
|
||||||
|
<div className={clsx("flex flex-col-reverse focus:text-teal-600", className)}>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
defaultValue=""
|
||||||
|
className="peer px-3 pb-2 pt-3 border focus:border-2 border-stone-800 focus:border-teal-600 focus:ring-0 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className="select-none border-x-2 border-transparent w-min translate-y-[55%] bg-stone-200 mx-2 px-1 peer-focus:text-teal-600 peer-focus:font-bold"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FormField }
|
17
packages/web/src/hooks/use-mnemonics.ts
Normal file
17
packages/web/src/hooks/use-mnemonics.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect } from "react"
|
||||||
|
|
||||||
|
function useMnemonics(mnemonicMap: Record<string, (event: KeyboardEvent) => void>, { active }: { active: boolean }) {
|
||||||
|
useEffect(() => {
|
||||||
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
|
mnemonicMap[event.key]?.(event)
|
||||||
|
}
|
||||||
|
if (active) {
|
||||||
|
document.addEventListener("keydown", onKeyDown)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", onKeyDown)
|
||||||
|
}
|
||||||
|
}, [mnemonicMap, active])
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useMnemonics }
|
@@ -1,9 +1,19 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-stone-200 dark:bg-stone-900 text-stone-800 dark:text-stone-300;
|
@apply bg-stone-200 dark:bg-stone-900 text-stone-800 dark:text-stone-300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stripes {
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
-45deg,
|
||||||
|
var(--color-stone-400),
|
||||||
|
var(--color-stone-400) 4px,
|
||||||
|
var(--color-stone-300) 4px,
|
||||||
|
var(--color-stone-300) 12px
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
8
packages/web/src/typedefs/react-query.d.ts
vendored
Normal file
8
packages/web/src/typedefs/react-query.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import "@tanstack/react-query"
|
||||||
|
import type { QueryKey } from "~/api"
|
||||||
|
|
||||||
|
declare module "@tanstack/react-query" {
|
||||||
|
interface Register {
|
||||||
|
queryKey: QueryKey
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user