switch to monorepo structure
This commit is contained in:
34
packages/core/.gitignore
vendored
Normal file
34
packages/core/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
15
packages/core/README.md
Normal file
15
packages/core/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# @markone/core
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.12. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
15
packages/core/package.json
Normal file
15
packages/core/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@markone/core",
|
||||
"module": "src/index.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./bookmark": "./src/bookmark.ts",
|
||||
"./user": "./src/user.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
18
packages/core/src/bookmark.ts
Normal file
18
packages/core/src/bookmark.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
type BookmarkKind = "link" | "placeholder"
|
||||
|
||||
interface LinkBookmark {
|
||||
kind: "link"
|
||||
id: string
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
interface PlaceholderBookmark {
|
||||
id: string
|
||||
kind: "placeholder"
|
||||
}
|
||||
|
||||
type Bookmark = LinkBookmark | PlaceholderBookmark
|
||||
type BookmarkId = Bookmark["id"]
|
||||
|
||||
export type { Bookmark, BookmarkId, BookmarkKind, LinkBookmark }
|
13
packages/core/src/user.ts
Normal file
13
packages/core/src/user.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
interface User {
|
||||
id: string
|
||||
username: string
|
||||
}
|
||||
|
||||
const DEMO_USER = {
|
||||
id: "01JTEP7T7A5YTM8YXEKHYQ46KK",
|
||||
username: "demo-user",
|
||||
unhashedPassword: "secure-hunter2",
|
||||
} as const
|
||||
|
||||
export type { User }
|
||||
export { DEMO_USER }
|
29
packages/core/tsconfig.json
Normal file
29
packages/core/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
34
packages/server/.gitignore
vendored
Normal file
34
packages/server/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
15
packages/server/README.md
Normal file
15
packages/server/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# server
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.2.12. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.
|
0
packages/server/data.sqlite
Normal file
0
packages/server/data.sqlite
Normal file
21
packages/server/package.json
Normal file
21
packages/server/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "server",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch ./src/server.ts"
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.12",
|
||||
"@types/uid-safe": "^2.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"dependencies": {
|
||||
"arktype": "^2.1.20",
|
||||
"uid-safe": "^2.1.5",
|
||||
"ulid": "^3.0.0"
|
||||
}
|
||||
}
|
148
packages/server/src/auth/auth.ts
Normal file
148
packages/server/src/auth/auth.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { type User, DEMO_USER } from "@markone/core/user"
|
||||
import { type } from "arktype"
|
||||
import dayjs from "dayjs"
|
||||
import { ulid } from "ulid"
|
||||
import { db } from "~/database.ts"
|
||||
import { HttpError } from "~/error.ts"
|
||||
import { httpHandler } from "~/http-handler.ts"
|
||||
import { createUser, findUserById, findUserByUsername } from "~/user/user.ts"
|
||||
import { createSessionForUser, extendSession, forgetAllSessions, saveSession, verifySession } from "./session.ts"
|
||||
|
||||
const SignUpRequest = type({
|
||||
username: "string",
|
||||
password: "string",
|
||||
})
|
||||
|
||||
const LoginRequest = type({
|
||||
username: "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>(
|
||||
handler: (request: Bun.BunRequest<Route>, user: User) => Promise<Response>,
|
||||
) {
|
||||
return httpHandler<Route>((request) => {
|
||||
const session = verifySession(request.cookies)
|
||||
if (!session) {
|
||||
throw new HttpError(401)
|
||||
}
|
||||
|
||||
const user = findUserById(session.userId)
|
||||
if (!user) {
|
||||
throw new HttpError(401)
|
||||
}
|
||||
|
||||
const authTokenCookie = request.cookies.get("auth-token")
|
||||
if (authTokenCookie) {
|
||||
// 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]!
|
||||
deleteAuthTokenQuery.run({ id: tokenId })
|
||||
rememberLoginForUser(user, request.cookies)
|
||||
}
|
||||
|
||||
if (user.id !== DEMO_USER.id) {
|
||||
const extendedSession = extendSession(session)
|
||||
saveSession(extendedSession, request.cookies)
|
||||
}
|
||||
|
||||
return handler(request, user)
|
||||
})
|
||||
}
|
||||
|
||||
function rememberLoginForUser(user: User, cookies: Bun.CookieMap) {
|
||||
const tokenId = ulid()
|
||||
|
||||
const authToken = Buffer.alloc(32)
|
||||
crypto.getRandomValues(authToken)
|
||||
|
||||
const hasher = new Bun.CryptoHasher("sha256")
|
||||
hasher.update(authToken)
|
||||
const hashedToken = hasher.digest("base64url")
|
||||
|
||||
const expiryDate = dayjs().add(1, "month")
|
||||
|
||||
createAuthTokenQuery.run({
|
||||
id: tokenId,
|
||||
token: hashedToken,
|
||||
userId: user.id,
|
||||
expiresAt: expiryDate.valueOf(),
|
||||
})
|
||||
|
||||
cookies.set("auth-token", `${tokenId}:${authToken.toBase64({ alphabet: "base64url" })}`, {
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||
httpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
async function signUp(request: Bun.BunRequest<"/api/sign-up">) {
|
||||
const body = await request.json().catch(() => {
|
||||
throw new HttpError(500)
|
||||
})
|
||||
|
||||
const signUpRequest = SignUpRequest(body)
|
||||
if (signUpRequest instanceof type.errors) {
|
||||
throw new HttpError(400, signUpRequest.summary)
|
||||
}
|
||||
|
||||
const { username, password } = signUpRequest
|
||||
const hashedPassword = await Bun.password.hash(password, "argon2id")
|
||||
const user = createUser(username, hashedPassword)
|
||||
|
||||
await createSessionForUser(user, request.cookies)
|
||||
rememberLoginForUser(user, request.cookies)
|
||||
|
||||
return Response.json(user, { status: 200 })
|
||||
}
|
||||
|
||||
async function login(request: Bun.BunRequest<"/api/login">) {
|
||||
const body = await request.json().catch(() => {
|
||||
throw new HttpError(500)
|
||||
})
|
||||
|
||||
const loginRequest = LoginRequest(body)
|
||||
if (loginRequest instanceof type.errors) {
|
||||
throw new HttpError(400, loginRequest.summary)
|
||||
}
|
||||
|
||||
const foundUser = findUserByUsername(loginRequest.username, {
|
||||
password: true,
|
||||
})
|
||||
if (!foundUser) {
|
||||
throw new HttpError(400)
|
||||
}
|
||||
|
||||
const ok = await Bun.password.verify(loginRequest.password, foundUser.password, "argon2id").catch(() => {
|
||||
throw new HttpError(401)
|
||||
})
|
||||
if (!ok) {
|
||||
throw new HttpError(401)
|
||||
}
|
||||
|
||||
const user: User = {
|
||||
id: foundUser.id,
|
||||
username: foundUser.username,
|
||||
}
|
||||
|
||||
if (user.id === DEMO_USER.id) {
|
||||
await createSessionForUser(user, request.cookies)
|
||||
rememberLoginForUser(user, request.cookies)
|
||||
}
|
||||
|
||||
return Response.json(user, { status: 200 })
|
||||
}
|
||||
|
||||
async function logout(request: Bun.BunRequest<"/api/logout">, user: User): Promise<Response> {
|
||||
forgetAllSessions(user)
|
||||
deleteAllAuthTokensQuery.run({ userId: user.id })
|
||||
return new Response(undefined, { status: 200 })
|
||||
}
|
||||
|
||||
export { authenticated, signUp, login, logout }
|
135
packages/server/src/auth/session.ts
Normal file
135
packages/server/src/auth/session.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import dayjs from "dayjs"
|
||||
import uid from "uid-safe"
|
||||
import { db } from "~/database.ts"
|
||||
import { type User, DEMO_USER } from "~/user/user.ts"
|
||||
|
||||
interface Session {
|
||||
id: string
|
||||
signedId: string
|
||||
userId: string
|
||||
durationMs: number
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
const SESSION_ID_BYTE_LENGTH = 24
|
||||
const SESSION_ID_COOKIE_NAME = "session-id"
|
||||
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() {
|
||||
setInterval(() => {
|
||||
deleteExpiredSessionsQuery.run({ time: dayjs().valueOf() })
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
async function newSessionId(): Promise<string> {
|
||||
return await uid(SESSION_ID_BYTE_LENGTH)
|
||||
}
|
||||
|
||||
function signSessionId(sessionId: string): string {
|
||||
const hasher = new Bun.CryptoHasher("sha256", Bun.env.SESSION_SECRET)
|
||||
hasher.update(sessionId)
|
||||
return `${sessionId}.${hasher.digest("base64url")}`
|
||||
}
|
||||
|
||||
async function createSessionForUser(user: User, cookies: Bun.CookieMap) {
|
||||
const sessionId = await newSessionId()
|
||||
const signedSessionId = signSessionId(sessionId)
|
||||
|
||||
const expiryDate = dayjs().add(30, "minutes").valueOf()
|
||||
|
||||
saveSessionQuery.run({
|
||||
sessionId,
|
||||
userId: user.id,
|
||||
expiresAt: expiryDate,
|
||||
})
|
||||
|
||||
cookies.set(SESSION_ID_COOKIE_NAME, signedSessionId, {
|
||||
maxAge: user.id === DEMO_USER.id ? undefined : SESSION_DURATION_MS,
|
||||
httpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
async function saveSession(session: Session, cookies: Bun.CookieMap) {
|
||||
cookies.set(SESSION_ID_COOKIE_NAME, session.signedId, {
|
||||
maxAge: SESSION_DURATION_MS,
|
||||
httpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
function verifySession(cookie: Bun.CookieMap): Session | null {
|
||||
const signedSessionId = cookie.get(SESSION_ID_COOKIE_NAME)
|
||||
if (!signedSessionId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value = signedSessionId.slice(0, signedSessionId.lastIndexOf("."))
|
||||
const expected = signSessionId(value)
|
||||
|
||||
const a = Buffer.from(signedSessionId)
|
||||
const b = Buffer.from(expected)
|
||||
|
||||
const isEqual = a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||
if (!isEqual) {
|
||||
return null
|
||||
}
|
||||
|
||||
const row = findSessionQuery.get({ sessionId: value })
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
const foundSession = row as { user_id: string; expires_at_unix_ms: number }
|
||||
|
||||
const now = dayjs().valueOf()
|
||||
if (now > foundSession.expires_at_unix_ms) {
|
||||
deleteSessionQuery.run({ sessionId: value })
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: value,
|
||||
signedId: signedSessionId,
|
||||
userId: foundSession.user_id,
|
||||
expiresAt: foundSession.expires_at_unix_ms,
|
||||
durationMs: SESSION_DURATION_MS,
|
||||
}
|
||||
}
|
||||
|
||||
function extendSession(session: Session): Session {
|
||||
const newExpiryDate = dayjs().add(30, "minutes").valueOf()
|
||||
extendSessionQuery.run({
|
||||
sessionId: session.id,
|
||||
newExpiryDate,
|
||||
})
|
||||
return {
|
||||
...session,
|
||||
expiresAt: newExpiryDate,
|
||||
}
|
||||
}
|
||||
|
||||
function forgetAllSessions(user: User) {
|
||||
forgetAllSessionsQuery.run({ userId: user.id })
|
||||
}
|
||||
|
||||
export {
|
||||
startBackgroundSessionCleanup,
|
||||
newSessionId,
|
||||
createSessionForUser,
|
||||
verifySession,
|
||||
saveSession,
|
||||
extendSession,
|
||||
forgetAllSessions,
|
||||
}
|
32
packages/server/src/bookmark/handlers.ts
Normal file
32
packages/server/src/bookmark/handlers.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { type } from "arktype"
|
||||
import { db } from "~/database.ts"
|
||||
import { HttpError } from "~/error.ts"
|
||||
import type { User } from "~/user/user.ts"
|
||||
|
||||
const BOOKMARK_PAGINATION_LIMIT = 100
|
||||
|
||||
const ListUserBookmarksParams = type({
|
||||
limit: ["number", "=", BOOKMARK_PAGINATION_LIMIT],
|
||||
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) {
|
||||
const queryParams = ListUserBookmarksParams(request.params)
|
||||
if (queryParams instanceof type.errors) {
|
||||
throw new HttpError(400, queryParams.summary)
|
||||
}
|
||||
|
||||
const results = listBookmarksQuery.all({
|
||||
userId: user.id,
|
||||
limit: queryParams.limit,
|
||||
skip: queryParams.skip,
|
||||
})
|
||||
|
||||
return Response.json(results, { status: 200 })
|
||||
}
|
||||
|
||||
export { listUserBookmarks }
|
76
packages/server/src/database.ts
Normal file
76
packages/server/src/database.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
|
||||
const SCHEMA_VERSION = 0
|
||||
|
||||
const db = new Database("data.sqlite")
|
||||
|
||||
const createMetadataTableQuery = db.query(`
|
||||
CREATE TABLE IF NOT EXISTS metadata(
|
||||
key TEXT NOT NULL PRIMARY KEY,
|
||||
value,
|
||||
UNIQUE(key)
|
||||
);
|
||||
`)
|
||||
|
||||
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 = [
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS users(
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bookmarks(
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions(
|
||||
session_id TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at_unix_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (session_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS auth_tokens(
|
||||
id TEXT PRIMARY KEY,
|
||||
token TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at_unix_ms INTEGER NOT NULL
|
||||
);
|
||||
`,
|
||||
]
|
||||
|
||||
const executeMigrations = db.transaction((migration) => {
|
||||
db.run(migration)
|
||||
})
|
||||
|
||||
function migrateDatabase() {
|
||||
createMetadataTableQuery.run()
|
||||
|
||||
const row = schemaVersionQuery.get()
|
||||
let currentVersion: number
|
||||
if (row) {
|
||||
currentVersion = (row as { version: number }).version
|
||||
console.log(`Migrating database from version ${currentVersion} to version ${SCHEMA_VERSION}...`)
|
||||
} else {
|
||||
currentVersion = -1
|
||||
console.log("Initializing database...")
|
||||
}
|
||||
|
||||
if (currentVersion < SCHEMA_VERSION) {
|
||||
executeMigrations(migrations.slice(currentVersion + 1, SCHEMA_VERSION + 1))
|
||||
setSchemaVersionQuery.run({ schemaVersion: SCHEMA_VERSION })
|
||||
console.log("Database successfully migrated!")
|
||||
} else {
|
||||
console.error("Rolling back database to a previous version is unsupported. Are you trying to downgrade MARKONE?")
|
||||
}
|
||||
}
|
||||
|
||||
export { db, migrateDatabase }
|
8
packages/server/src/error.ts
Normal file
8
packages/server/src/error.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
class HttpError {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly message?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export { HttpError }
|
22
packages/server/src/http-handler.ts
Normal file
22
packages/server/src/http-handler.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { HttpError } from "./error.ts"
|
||||
|
||||
function httpHandler<Route extends string>(
|
||||
handler: (request: Bun.BunRequest<Route>) => Promise<Response>,
|
||||
): (request: Bun.BunRequest<Route>) => Promise<Response> {
|
||||
return async (request) => {
|
||||
try {
|
||||
const response = await handler(request)
|
||||
return response
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) {
|
||||
if (error.message) {
|
||||
return Response.json({ message: error.message }, { status: error.status })
|
||||
}
|
||||
return new Response(undefined, { status: error.status })
|
||||
}
|
||||
return new Response(undefined, { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { httpHandler }
|
31
packages/server/src/server.ts
Normal file
31
packages/server/src/server.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { authenticated, login, logout, signUp } from "./auth/auth.ts"
|
||||
import { startBackgroundSessionCleanup } from "./auth/session.ts"
|
||||
import { listUserBookmarks } from "./bookmark/handlers.ts"
|
||||
import { migrateDatabase } from "./database.ts"
|
||||
import { httpHandler } from "./http-handler.ts"
|
||||
import { createDemoUser } from "./user/user.ts"
|
||||
|
||||
function main() {
|
||||
migrateDatabase()
|
||||
createDemoUser()
|
||||
startBackgroundSessionCleanup()
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/api/login": {
|
||||
POST: httpHandler(login),
|
||||
},
|
||||
"/api/sign-up": {
|
||||
POST: httpHandler(signUp),
|
||||
},
|
||||
"/api/logout": {
|
||||
POST: authenticated(logout),
|
||||
},
|
||||
"/api/bookmarks": {
|
||||
GET: authenticated(listUserBookmarks),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
62
packages/server/src/user/user.ts
Normal file
62
packages/server/src/user/user.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { type User, DEMO_USER } from "@markone/core/user"
|
||||
import { ulid } from "ulid"
|
||||
import { db } from "~/database.ts"
|
||||
|
||||
interface UserWithPassword extends User {
|
||||
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, { password }: { password?: boolean }): User | UserWithPassword | null {
|
||||
const row = (password ? findUserByUsernameWithPwQuery : findUserByUsernameQuery).get({ username })
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
return row as User
|
||||
}
|
||||
|
||||
function findUserById(userId: string): User | null {
|
||||
const row = findUserByIdQuery.get({ userId })
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
return row as User
|
||||
}
|
||||
|
||||
function createUser(username: string, password: string): User {
|
||||
const userId = ulid()
|
||||
createUserQuery.run({
|
||||
id: userId,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
return {
|
||||
id: userId,
|
||||
username,
|
||||
}
|
||||
}
|
||||
|
||||
async function createDemoUser() {
|
||||
const row = findUserByUsernameQuery.get({ username: DEMO_USER.username })
|
||||
if (row) {
|
||||
return
|
||||
}
|
||||
|
||||
const hashedPassword = await Bun.password.hash(DEMO_USER.unhashedPassword, "argon2id")
|
||||
|
||||
createUserQuery.run({
|
||||
id: DEMO_USER.id,
|
||||
username: DEMO_USER.username,
|
||||
password: hashedPassword,
|
||||
})
|
||||
}
|
||||
|
||||
export type { User }
|
||||
export { DEMO_USER, createDemoUser, createUser, findUserByUsername, findUserById }
|
32
packages/server/tsconfig.json
Normal file
32
packages/server/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
// Environment setup & latest features
|
||||
"lib": ["ESNext"],
|
||||
"target": "ESNext",
|
||||
"module": "NodeNext",
|
||||
"moduleDetection": "force",
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": true,
|
||||
|
||||
// Bundler mode
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
|
||||
// Best practices
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
// Some stricter flags (disabled by default)
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
50
packages/web/README.md
Normal file
50
packages/web/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import react from 'eslint-plugin-react'
|
||||
|
||||
export default tseslint.config({
|
||||
// Set the react version
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
// Add the react plugin
|
||||
react,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended rules
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
},
|
||||
})
|
||||
```
|
13
packages/web/index.html
Normal file
13
packages/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MarkOne + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
42
packages/web/package.json
Normal file
42
packages/web/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@markone/web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@markone/core": "workspace:*",
|
||||
"@tailwindcss/vite": "^4.1.5",
|
||||
"@tanstack/react-query": "^5.75.2",
|
||||
"@tanstack/react-router": "^1.119.0",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"zustand": "^5.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-router-devtools": "^1.119.1",
|
||||
"@tanstack/router-plugin": "^1.119.0",
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vite-pwa/assets-generator": "^0.2.6",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"globals": "^15.14.0",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.0.11",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"workbox-core": "^7.3.0",
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"sharp": "0.32.6",
|
||||
"sharp-ico": "0.1.5"
|
||||
}
|
||||
}
|
130
packages/web/public/favicon.svg
Normal file
130
packages/web/public/favicon.svg
Normal file
@@ -0,0 +1,130 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="410"
|
||||
height="404"
|
||||
viewBox="0 0 410 404"
|
||||
fill="none"
|
||||
version="1.1"
|
||||
id="svg20"
|
||||
sodipodi:docname="favicon.svg"
|
||||
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata
|
||||
id="metadata24">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<sodipodi:namedview
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1"
|
||||
objecttolerance="10"
|
||||
gridtolerance="10"
|
||||
guidetolerance="10"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:window-width="1920"
|
||||
inkscape:window-height="1001"
|
||||
id="namedview22"
|
||||
showgrid="false"
|
||||
inkscape:zoom="0.51361386"
|
||||
inkscape:cx="-374.79518"
|
||||
inkscape:cy="145.0506"
|
||||
inkscape:window-x="-9"
|
||||
inkscape:window-y="-9"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g8"
|
||||
inkscape:document-rotation="0"
|
||||
inkscape:pagecheckerboard="0" />
|
||||
<path
|
||||
d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z"
|
||||
fill="url(#paint0_linear)"
|
||||
id="path2" />
|
||||
<defs
|
||||
id="defs18">
|
||||
<linearGradient
|
||||
id="paint0_linear"
|
||||
x1="6.00017"
|
||||
y1="32.9999"
|
||||
x2="235"
|
||||
y2="344"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
stop-color="#41D1FF"
|
||||
id="stop6" />
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="#BD34FE"
|
||||
id="stop8" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear"
|
||||
x1="194.651"
|
||||
y1="8.81818"
|
||||
x2="236.076"
|
||||
y2="292.989"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
stop-color="#FFEA83"
|
||||
id="stop11" />
|
||||
<stop
|
||||
offset="0.0833333"
|
||||
stop-color="#FFDD35"
|
||||
id="stop13" />
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="#FFA800"
|
||||
id="stop15" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path
|
||||
d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z"
|
||||
fill="url(#paint1_linear)"
|
||||
id="path4" />
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
inkscape:label="PWA">
|
||||
<g
|
||||
id="g8"
|
||||
transform="matrix(0.15789659,0,0,0.15890333,54.892928,275.21638)">
|
||||
<path
|
||||
fill="#3d3d3d"
|
||||
fill-opacity="1"
|
||||
stroke-width="0.2"
|
||||
stroke-linejoin="round"
|
||||
d="m 1436.62,603.304 56.39,-142.599 h 162.82 L 1578.56,244.39 1675.2,5.28336e-4 1952,734.933 h -204.13 l -47.3,-131.629 z"
|
||||
id="path2-1"
|
||||
style="fill:#3e3e3e;fill-opacity:1" />
|
||||
<path
|
||||
fill="#5a0fc8"
|
||||
fill-opacity="1"
|
||||
stroke-width="0.2"
|
||||
stroke-linejoin="round"
|
||||
d="M 1262.47,734.935 1558.79,0.00156593 1362.34,0.0025425 1159.64,474.933 1015.5,0.00351906 H 864.499 L 709.731,474.933 600.585,258.517 501.812,562.819 602.096,734.935 h 193.331 l 139.857,-425.91 133.346,425.91 z"
|
||||
id="path4-4"
|
||||
style="fill:#2e859c;fill-opacity:1" />
|
||||
<path
|
||||
fill="#3d3d3d"
|
||||
fill-opacity="1"
|
||||
stroke-width="0.2"
|
||||
stroke-linejoin="round"
|
||||
d="m 186.476,482.643 h 121.003 c 36.654,0 69.293,-4.091 97.917,-12.273 l 31.293,-96.408 87.459,-269.446 C 517.484,93.9535 509.876,83.9667 501.324,74.5569 456.419,24.852 390.719,4.06265e-4 304.222,4.06265e-4 H -3.8147e-6 V 734.933 H 186.476 Z M 346.642,169.079 c 17.54,17.653 26.309,41.276 26.309,70.871 0,29.822 -7.713,53.474 -23.138,70.956 -16.91,19.425 -48.047,29.137 -93.409,29.137 H 186.476 V 142.598 h 70.442 c 42.277,0 72.185,8.827 89.724,26.481 z"
|
||||
id="path6"
|
||||
style="fill:#3e3e3e;fill-opacity:1" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 4.8 KiB |
9
packages/web/pwa-assets.config.ts
Normal file
9
packages/web/pwa-assets.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig, minimal2023Preset as preset } from "@vite-pwa/assets-generator/config"
|
||||
|
||||
export default defineConfig({
|
||||
headLinkOptions: {
|
||||
preset: "2023",
|
||||
},
|
||||
preset,
|
||||
images: ["public/favicon.svg"],
|
||||
})
|
29
packages/web/src/PWABadge.css
Normal file
29
packages/web/src/PWABadge.css
Normal file
@@ -0,0 +1,29 @@
|
||||
.PWABadge-container {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.PWABadge-toast {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 16px;
|
||||
padding: 12px;
|
||||
border: 1px solid #8885;
|
||||
border-radius: 4px;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
box-shadow: 3px 4px 5px 0 #8885;
|
||||
background-color: white;
|
||||
}
|
||||
.PWABadge-toast-message {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.PWABadge-toast-button {
|
||||
border: 1px solid #8885;
|
||||
outline: none;
|
||||
margin-right: 5px;
|
||||
border-radius: 2px;
|
||||
padding: 3px 10px;
|
||||
}
|
80
packages/web/src/PWABadge.tsx
Normal file
80
packages/web/src/PWABadge.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import "./PWABadge.css"
|
||||
|
||||
import { useRegisterSW } from "virtual:pwa-register/react"
|
||||
|
||||
function PWABadge() {
|
||||
// check for updates every hour
|
||||
const period = 60 * 60 * 1000
|
||||
|
||||
const {
|
||||
offlineReady: [offlineReady, setOfflineReady],
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({
|
||||
onRegisteredSW(swUrl, r) {
|
||||
if (period <= 0) return
|
||||
if (r?.active?.state === "activated") {
|
||||
registerPeriodicSync(period, swUrl, r)
|
||||
} else if (r?.installing) {
|
||||
r.installing.addEventListener("statechange", (e) => {
|
||||
const sw = e.target as ServiceWorker
|
||||
if (sw.state === "activated") registerPeriodicSync(period, swUrl, r)
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function close() {
|
||||
setOfflineReady(false)
|
||||
setNeedRefresh(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="PWABadge" role="alert" aria-labelledby="toast-message">
|
||||
{(offlineReady || needRefresh) && (
|
||||
<div className="PWABadge-toast">
|
||||
<div className="PWABadge-message">
|
||||
{offlineReady ? (
|
||||
<span id="toast-message">App ready to work offline</span>
|
||||
) : (
|
||||
<span id="toast-message">New content available, click on reload button to update.</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="PWABadge-buttons">
|
||||
{needRefresh && (
|
||||
<button className="PWABadge-toast-button" onClick={() => updateServiceWorker(true)}>
|
||||
Reload
|
||||
</button>
|
||||
)}
|
||||
<button className="PWABadge-toast-button" onClick={() => close()}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PWABadge
|
||||
|
||||
/**
|
||||
* This function will register a periodic sync check every hour, you can modify the interval as needed.
|
||||
*/
|
||||
function registerPeriodicSync(period: number, swUrl: string, r: ServiceWorkerRegistration) {
|
||||
if (period <= 0) return
|
||||
|
||||
setInterval(async () => {
|
||||
if ("onLine" in navigator && !navigator.onLine) return
|
||||
|
||||
const resp = await fetch(swUrl, {
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
cache: "no-store",
|
||||
"cache-control": "no-cache",
|
||||
},
|
||||
})
|
||||
|
||||
if (resp?.status === 200) await r.update()
|
||||
}, period)
|
||||
}
|
111
packages/web/src/app/-route-tree.gen.ts
Normal file
111
packages/web/src/app/-route-tree.gen.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
// Import Routes
|
||||
|
||||
import { Route as rootRoute } from "./__root"
|
||||
import { Route as BookmarksImport } from "./bookmarks"
|
||||
import { Route as IndexImport } from "./index"
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const BookmarksRoute = BookmarksImport.update({
|
||||
id: "/bookmarks",
|
||||
path: "/bookmarks",
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const IndexRoute = IndexImport.update({
|
||||
id: "/",
|
||||
path: "/",
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface FileRoutesByPath {
|
||||
"/": {
|
||||
id: "/"
|
||||
path: "/"
|
||||
fullPath: "/"
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
"/bookmarks": {
|
||||
id: "/bookmarks"
|
||||
path: "/bookmarks"
|
||||
fullPath: "/bookmarks"
|
||||
preLoaderRoute: typeof BookmarksImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
"/": typeof IndexRoute
|
||||
"/bookmarks": typeof BookmarksRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
"/": typeof IndexRoute
|
||||
"/bookmarks": typeof BookmarksRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
"/": typeof IndexRoute
|
||||
"/bookmarks": typeof BookmarksRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: "/" | "/bookmarks"
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: "/" | "/bookmarks"
|
||||
id: "__root__" | "/" | "/bookmarks"
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
BookmarksRoute: typeof BookmarksRoute
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
BookmarksRoute: BookmarksRoute,
|
||||
}
|
||||
|
||||
export const routeTree = rootRoute
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
/* ROUTE_MANIFEST_START
|
||||
{
|
||||
"routes": {
|
||||
"__root__": {
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/bookmarks"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/bookmarks": {
|
||||
"filePath": "bookmarks.tsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
ROUTE_MANIFEST_END */
|
15
packages/web/src/app/__root.tsx
Normal file
15
packages/web/src/app/__root.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router"
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||
|
||||
function Root() {
|
||||
return (
|
||||
<>
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Root,
|
||||
})
|
306
packages/web/src/app/bookmarks.tsx
Normal file
306
packages/web/src/app/bookmarks.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import type { LinkBookmark } from "@markone/core/bookmark"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import clsx from "clsx"
|
||||
import { useEffect } from "react"
|
||||
import { create } from "zustand"
|
||||
import { Button } from "~/components/button"
|
||||
|
||||
const testBookmarks: LinkBookmark[] = [
|
||||
{
|
||||
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 = {
|
||||
popup: "popup",
|
||||
sideBySide: "side-by-side",
|
||||
} as const
|
||||
type LayoutMode = (typeof LAYOUT_MODE)[keyof typeof LAYOUT_MODE]
|
||||
|
||||
interface BookmarkPageState {
|
||||
bookmarks: LinkBookmark[]
|
||||
selectedBookmarkIndex: number
|
||||
isBookmarkItemExpanded: boolean
|
||||
isBookmarkPreviewOpened: boolean
|
||||
layoutMode: LayoutMode
|
||||
|
||||
setBookmarkItemExpanded: (isExpanded: boolean) => void
|
||||
setBookmarkPreviewOpened: (isOpened: boolean) => void
|
||||
setLayoutMode: (mode: LayoutMode) => void
|
||||
selectBookmarkAt: (index: number) => void
|
||||
}
|
||||
|
||||
const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
||||
bookmarks: testBookmarks,
|
||||
selectedBookmarkIndex: 0,
|
||||
isBookmarkItemExpanded: false,
|
||||
isBookmarkPreviewOpened: false,
|
||||
layoutMode: LAYOUT_MODE.popup,
|
||||
|
||||
setBookmarkItemExpanded(isExpanded: boolean) {
|
||||
set({ isBookmarkItemExpanded: isExpanded })
|
||||
},
|
||||
|
||||
setBookmarkPreviewOpened(isOpened: boolean) {
|
||||
set({ isBookmarkPreviewOpened: isOpened })
|
||||
},
|
||||
|
||||
setLayoutMode(mode: LayoutMode) {
|
||||
set({ layoutMode: mode })
|
||||
},
|
||||
|
||||
selectBookmarkAt(index: number) {
|
||||
const bookmarks = get().bookmarks
|
||||
if (index >= 0 && index < bookmarks.length) {
|
||||
set({ selectedBookmarkIndex: index })
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
function Page() {
|
||||
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(() => {
|
||||
function mediaQueryListener(this: MediaQueryList) {
|
||||
if (this.matches) {
|
||||
setLayoutMode(LAYOUT_MODE.sideBySide)
|
||||
} else {
|
||||
setLayoutMode(LAYOUT_MODE.popup)
|
||||
}
|
||||
}
|
||||
|
||||
const q = window.matchMedia("(width >= 64rem)")
|
||||
q.addEventListener("change", mediaQueryListener)
|
||||
|
||||
mediaQueryListener.call(q)
|
||||
|
||||
return () => {
|
||||
q.removeEventListener("change", mediaQueryListener)
|
||||
}
|
||||
}, [setLayoutMode])
|
||||
|
||||
return (
|
||||
<div className="flex justify-center h-full">
|
||||
<Main>
|
||||
<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">
|
||||
<h1 className="font-bold text-start">
|
||||
<span className="invisible md:hidden">> </span>
|
||||
YOUR BOOKMARKS
|
||||
</h1>
|
||||
</header>
|
||||
<div className="flex flex-col container max-w-2xl -mt-2">
|
||||
{testBookmarks.map((bookmark, i) => (
|
||||
<BookmarkListItem key={bookmark.id} index={i} bookmark={bookmark} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<BookmarkPreview />
|
||||
</Main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Main({ children }: React.PropsWithChildren) {
|
||||
const isPreviewOpened = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
|
||||
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
|
||||
|
||||
return (
|
||||
<main
|
||||
className={clsx(
|
||||
"px-4 lg:px-8 2xl:px-0 grid flex justify-center relative w-full",
|
||||
isPreviewOpened && layoutMode === LAYOUT_MODE.sideBySide ? "grid-cols-2" : "grid-cols-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function BookmarkPreview() {
|
||||
const isVisible = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
|
||||
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
|
||||
|
||||
if (!isVisible) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"h-screen flex justify-center items-center border-l border-stone-700 dark:border-stone-300 flex dark:bg-stone-900",
|
||||
{
|
||||
"absolute inset-0 border-l-0": layoutMode === LAYOUT_MODE.popup,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<p>Content here</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index: number }) {
|
||||
const url = new URL(bookmark.url)
|
||||
const selectedBookmark = useBookmarkPageStore((state) => state.bookmarks[state.selectedBookmarkIndex])
|
||||
const isSelected = selectedBookmark.id === bookmark.id
|
||||
const isBookmarkItemExpanded = useBookmarkPageStore((state) => state.isBookmarkItemExpanded)
|
||||
const setBookmarkItemExpanded = useBookmarkPageStore((state) => state.setBookmarkItemExpanded)
|
||||
const selectBookmarkAt = useBookmarkPageStore((state) => state.selectBookmarkAt)
|
||||
const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
|
||||
|
||||
function expandOrOpenPreview() {
|
||||
setBookmarkItemExpanded(true)
|
||||
if (useBookmarkPageStore.getState().layoutMode === LAYOUT_MODE.sideBySide) {
|
||||
console.log(useBookmarkPageStore.getState().layoutMode)
|
||||
setBookmarkPreviewOpened(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("group flex flex-row justify-start py-2", {
|
||||
"bg-teal-600 text-stone-100": isBookmarkItemExpanded && isSelected,
|
||||
"text-teal-600": isSelected && !isBookmarkItemExpanded,
|
||||
})}
|
||||
onMouseEnter={() => {
|
||||
if (!isBookmarkItemExpanded) {
|
||||
selectBookmarkAt(index)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isSelected}
|
||||
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
|
||||
invisible: !isSelected,
|
||||
})}
|
||||
onClick={() => {
|
||||
setBookmarkItemExpanded(!isBookmarkItemExpanded)
|
||||
setBookmarkPreviewOpened(false)
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Options for this bookmark</span>
|
||||
<span> </span>
|
||||
<span className={isBookmarkItemExpanded ? "rotate-90" : ""}>></span>
|
||||
<span> </span>
|
||||
</button>
|
||||
<div className="flex flex-col w-full">
|
||||
<button type="button" className="text-start font-bold" onClick={expandOrOpenPreview}>
|
||||
{bookmark.title}
|
||||
</button>
|
||||
<p className="opacity-80 text-sm">{url.host}</p>
|
||||
{isBookmarkItemExpanded && isSelected ? (
|
||||
<div className="flex flex-col space-y-1 md:flex-row md:space-y-0 md:space-x-2 items-end justify-between pt-2">
|
||||
<p className="text-sm">#dev #devops #devops #devops #devops #devops #devops</p>
|
||||
<div className="flex space-x-2">
|
||||
<OpenBookmarkPreviewButton />
|
||||
<Button className="text-sm">
|
||||
<span className="underline">E</span>dit
|
||||
</Button>
|
||||
<Button className="text-sm">
|
||||
<span className="underline">D</span>elete
|
||||
</Button>
|
||||
<span className="-ml-2"> </span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OpenBookmarkPreviewButton() {
|
||||
const isBookmarkPreviewOpened = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
|
||||
const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
|
||||
const setBookmarkItemExpanded = useBookmarkPageStore((state) => state.setBookmarkItemExpanded)
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (isBookmarkPreviewOpened && event.key === "c") {
|
||||
closePreview()
|
||||
} else if (!isBookmarkPreviewOpened && event.key === "o") {
|
||||
openPreview()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown)
|
||||
}
|
||||
}, [isBookmarkPreviewOpened])
|
||||
|
||||
function closePreview() {
|
||||
setBookmarkPreviewOpened(false)
|
||||
setBookmarkItemExpanded(false)
|
||||
}
|
||||
|
||||
function openPreview() {
|
||||
setBookmarkPreviewOpened(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="text-sm"
|
||||
onClick={() => {
|
||||
if (isBookmarkPreviewOpened) {
|
||||
closePreview()
|
||||
} else {
|
||||
openPreview()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isBookmarkPreviewOpened ? (
|
||||
<>
|
||||
<span className="underline">C</span>lose
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="underline">O</span>pen
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/bookmarks")({
|
||||
component: Page,
|
||||
})
|
75
packages/web/src/app/index.tsx
Normal file
75
packages/web/src/app/index.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { DEMO_USER } from "@markone/core/user"
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router"
|
||||
import { useLogin } from "~/auth"
|
||||
import { Link } from "~/components/link"
|
||||
|
||||
function Index() {
|
||||
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 />
|
||||
</h1>
|
||||
<p className="pb-4 text-2xl uppercase">BOOKMARK MANAGER</p>
|
||||
<div className="flex flex-col text-lg">
|
||||
<Link>LOGIN</Link>
|
||||
<Link>SIGN UP</Link>
|
||||
<DemoButton />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>
|
||||
<strong>WHAT IS MARKONE?</strong>
|
||||
<br />
|
||||
MARKONE is a local-first, self-hostable bookmark manager.
|
||||
</p>
|
||||
<ul className="px-2 pt-2">
|
||||
<li>* Curate interesting websites you find online, and let MARKONE archive them.</li>
|
||||
<li>* Reference your saved bookmarks anytime, even when offline.</li>
|
||||
<li>* Share your collections to others with a permalink.</li>
|
||||
</ul>
|
||||
<br />
|
||||
<p>
|
||||
<strong>WHERE IS MARKONE?</strong>
|
||||
<br />
|
||||
MARKONE is available as a web app and a browser extension for now.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
<strong>TECHNICAL STUFF</strong>
|
||||
<br />
|
||||
Source code, as well as hosting guide for MARKONE is <Link href="https://github.com/">available here</Link>.
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function DemoButton() {
|
||||
const loginMutation = useLogin()
|
||||
const navigate = useNavigate()
|
||||
|
||||
async function startDemo() {
|
||||
await loginMutation.mutateAsync({
|
||||
username: DEMO_USER.username,
|
||||
password: DEMO_USER.unhashedPassword,
|
||||
})
|
||||
navigate({ to: "/bookmarks" })
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={startDemo}
|
||||
className="underline active:bg-stone-700 dark:active:bg-stone-200 dark:active:text-stone-800 active:text-stone-200 text-left"
|
||||
>
|
||||
DEMO
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Index,
|
||||
})
|
1
packages/web/src/assets/react.svg
Normal file
1
packages/web/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
After Width: | Height: | Size: 4.0 KiB |
14
packages/web/src/auth.ts
Normal file
14
packages/web/src/auth.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
|
||||
function useLogin() {
|
||||
return useMutation({
|
||||
mutationFn: async (creds: { username: string; password: string }) => {
|
||||
await fetch("/api/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(creds),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { useLogin }
|
18
packages/web/src/components/button.tsx
Normal file
18
packages/web/src/components/button.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import clsx from "clsx"
|
||||
|
||||
function Button({
|
||||
className,
|
||||
...props
|
||||
}: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button }
|
10
packages/web/src/components/link.tsx
Normal file
10
packages/web/src/components/link.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import clsx from "clsx"
|
||||
|
||||
function Link({
|
||||
className,
|
||||
...props
|
||||
}: React.DetailedHTMLProps<React.AnchorHTMLAttributes<HTMLAnchorElement>, HTMLAnchorElement>) {
|
||||
return <a className={clsx("underline active:bg-stone-700 active:text-stone-200", className)} {...props} />
|
||||
}
|
||||
|
||||
export { Link }
|
9
packages/web/src/index.css
Normal file
9
packages/web/src/index.css
Normal file
@@ -0,0 +1,9 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-stone-200 dark:bg-stone-900 text-stone-800 dark:text-stone-300;
|
||||
}
|
20
packages/web/src/main.tsx
Normal file
20
packages/web/src/main.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import "./index.css"
|
||||
import { routeTree } from "./app/-route-tree.gen"
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
})
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
2
packages/web/src/vite-env.d.ts
vendored
Normal file
2
packages/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
30
packages/web/tsconfig.app.json
Normal file
30
packages/web/tsconfig.app.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"experimentalDecorators": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
7
packages/web/tsconfig.json
Normal file
7
packages/web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
24
packages/web/tsconfig.node.json
Normal file
24
packages/web/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
57
packages/web/vite.config.ts
Normal file
57
packages/web/vite.config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import path from "node:path"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
import { VitePWA } from "vite-plugin-pwa"
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"~": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
TanStackRouterVite({
|
||||
target: "react",
|
||||
autoCodeSplitting: false,
|
||||
routesDirectory: "./src/app",
|
||||
generatedRouteTree: "./src/app/-route-tree.gen.ts",
|
||||
routeFileIgnorePrefix: "-",
|
||||
quoteStyle: "double",
|
||||
}),
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "prompt",
|
||||
injectRegister: false,
|
||||
|
||||
pwaAssets: {
|
||||
disabled: false,
|
||||
config: true,
|
||||
},
|
||||
|
||||
manifest: {
|
||||
name: "MarkOne",
|
||||
short_name: "MarkOne",
|
||||
description: "A minimal bookmark manager",
|
||||
theme_color: "#ffffff",
|
||||
},
|
||||
|
||||
workbox: {
|
||||
globPatterns: ["**/*.{js,css,html,svg,png,ico}"],
|
||||
cleanupOutdatedCaches: true,
|
||||
clientsClaim: true,
|
||||
},
|
||||
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
navigateFallback: "index.html",
|
||||
suppressWarnings: true,
|
||||
type: "module",
|
||||
},
|
||||
}),
|
||||
tailwindcss(),
|
||||
],
|
||||
})
|
Reference in New Issue
Block a user