implement bookmark delete

This commit is contained in:
2025-05-07 15:47:08 +01:00
parent 30cc4d3fb5
commit e87a6586b6
26 changed files with 763 additions and 149 deletions

View File

@@ -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=="],

View File

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:8080

View File

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

View File

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

View File

@@ -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">&gt;&nbsp;</span> <span className="invisible md:hidden">&gt;&nbsp;</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">&nbsp;</span> <span className="-ml-2">&nbsp;</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,
}) })

View File

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

View 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,
})

View File

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

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

View File

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

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

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

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

View File

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

View File

@@ -0,0 +1,8 @@
import "@tanstack/react-query"
import type { QueryKey } from "~/api"
declare module "@tanstack/react-query" {
interface Register {
queryKey: QueryKey
}
}