switch to monorepo structure
This commit is contained in:
39
biome.json
Normal file
39
biome.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": []
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab"
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "double",
|
||||
"semicolons": "asNeeded",
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 120
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"parser": {
|
||||
"allowComments": true
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,28 +0,0 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
48
package.json
48
package.json
@@ -1,52 +1,12 @@
|
||||
{
|
||||
"name": "markone",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:server": "bun run src/server-main.ts",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.5",
|
||||
"@tanstack/react-router": "^1.119.0",
|
||||
"arktype": "^2.1.20",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"uid-safe": "^2.1.5",
|
||||
"ulid": "^3.0.0",
|
||||
"zustand": "^5.0.4"
|
||||
"dev": "bun --filter '*' dev"
|
||||
},
|
||||
"workspaces": ["packages/*"],
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@tanstack/react-router-devtools": "^1.119.1",
|
||||
"@tanstack/router-plugin": "^1.119.0",
|
||||
"@types/bun": "^1.2.12",
|
||||
"@types/node": "^22.15.3",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@types/uid-safe": "^2.1.5",
|
||||
"@vite-pwa/assets-generator": "^0.2.6",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.18",
|
||||
"globals": "^15.14.0",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^6.0.11",
|
||||
"vite-plugin-pwa": "^0.21.1",
|
||||
"workbox-core": "^7.3.0",
|
||||
"workbox-window": "^7.3.0"
|
||||
"@biomejs/biome": "1.9.4"
|
||||
},
|
||||
"resolutions": {
|
||||
"sharp": "0.32.6",
|
||||
"sharp-ico": "0.1.5"
|
||||
}
|
||||
"trustedDependencies": ["@biomejs/biome"]
|
||||
}
|
||||
|
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"]
|
||||
}
|
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"
|
||||
}
|
||||
}
|
Before Width: | Height: | Size: 4.8 KiB 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)
|
||||
}
|
@@ -11,23 +11,23 @@
|
||||
// Import Routes
|
||||
|
||||
import { Route as rootRoute } from "./__root"
|
||||
import { Route as BookmarksImport } from "./bookmarks"
|
||||
import { Route as IndexImport } from "./index"
|
||||
import { Route as UsernameBookmarksImport } from "./$username/bookmarks"
|
||||
|
||||
// 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)
|
||||
|
||||
const UsernameBookmarksRoute = UsernameBookmarksImport.update({
|
||||
id: "/$username/bookmarks",
|
||||
path: "/$username/bookmarks",
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
@@ -39,11 +39,11 @@ declare module "@tanstack/react-router" {
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
"/$username/bookmarks": {
|
||||
id: "/$username/bookmarks"
|
||||
path: "/$username/bookmarks"
|
||||
fullPath: "/$username/bookmarks"
|
||||
preLoaderRoute: typeof UsernameBookmarksImport
|
||||
"/bookmarks": {
|
||||
id: "/bookmarks"
|
||||
path: "/bookmarks"
|
||||
fullPath: "/bookmarks"
|
||||
preLoaderRoute: typeof BookmarksImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
}
|
||||
@@ -53,37 +53,37 @@ declare module "@tanstack/react-router" {
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
"/": typeof IndexRoute
|
||||
"/$username/bookmarks": typeof UsernameBookmarksRoute
|
||||
"/bookmarks": typeof BookmarksRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
"/": typeof IndexRoute
|
||||
"/$username/bookmarks": typeof UsernameBookmarksRoute
|
||||
"/bookmarks": typeof BookmarksRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
"/": typeof IndexRoute
|
||||
"/$username/bookmarks": typeof UsernameBookmarksRoute
|
||||
"/bookmarks": typeof BookmarksRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: "/" | "/$username/bookmarks"
|
||||
fullPaths: "/" | "/bookmarks"
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: "/" | "/$username/bookmarks"
|
||||
id: "__root__" | "/" | "/$username/bookmarks"
|
||||
to: "/" | "/bookmarks"
|
||||
id: "__root__" | "/" | "/bookmarks"
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
UsernameBookmarksRoute: typeof UsernameBookmarksRoute
|
||||
BookmarksRoute: typeof BookmarksRoute
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
UsernameBookmarksRoute: UsernameBookmarksRoute,
|
||||
BookmarksRoute: BookmarksRoute,
|
||||
}
|
||||
|
||||
export const routeTree = rootRoute
|
||||
@@ -97,14 +97,14 @@ export const routeTree = rootRoute
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/$username/bookmarks"
|
||||
"/bookmarks"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/$username/bookmarks": {
|
||||
"filePath": "$username/bookmarks.tsx"
|
||||
"/bookmarks": {
|
||||
"filePath": "bookmarks.tsx"
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,5 +1,5 @@
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router"
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
|
||||
|
||||
function Root() {
|
||||
return (
|
||||
@@ -7,9 +7,9 @@ function Root() {
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Root,
|
||||
});
|
||||
})
|
@@ -1,9 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { create } from "zustand";
|
||||
import clsx from "clsx";
|
||||
import { Button } from "~/components/button";
|
||||
import type { LinkBookmark } from "~/bookmark/bookmark";
|
||||
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[] = [
|
||||
{
|
||||
@@ -18,25 +18,25 @@ const testBookmarks: LinkBookmark[] = [
|
||||
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];
|
||||
} as const
|
||||
type LayoutMode = (typeof LAYOUT_MODE)[keyof typeof LAYOUT_MODE]
|
||||
|
||||
interface BookmarkPageState {
|
||||
bookmarks: LinkBookmark[];
|
||||
selectedBookmarkIndex: number;
|
||||
isBookmarkItemExpanded: boolean;
|
||||
isBookmarkPreviewOpened: boolean;
|
||||
layoutMode: LayoutMode;
|
||||
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;
|
||||
setBookmarkItemExpanded: (isExpanded: boolean) => void
|
||||
setBookmarkPreviewOpened: (isOpened: boolean) => void
|
||||
setLayoutMode: (mode: LayoutMode) => void
|
||||
selectBookmarkAt: (index: number) => void
|
||||
}
|
||||
|
||||
const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
||||
@@ -47,75 +47,75 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
|
||||
layoutMode: LAYOUT_MODE.popup,
|
||||
|
||||
setBookmarkItemExpanded(isExpanded: boolean) {
|
||||
set({ isBookmarkItemExpanded: isExpanded });
|
||||
set({ isBookmarkItemExpanded: isExpanded })
|
||||
},
|
||||
|
||||
setBookmarkPreviewOpened(isOpened: boolean) {
|
||||
set({ isBookmarkPreviewOpened: isOpened });
|
||||
set({ isBookmarkPreviewOpened: isOpened })
|
||||
},
|
||||
|
||||
setLayoutMode(mode: LayoutMode) {
|
||||
set({ layoutMode: mode });
|
||||
set({ layoutMode: mode })
|
||||
},
|
||||
|
||||
selectBookmarkAt(index: number) {
|
||||
const bookmarks = get().bookmarks;
|
||||
const bookmarks = get().bookmarks
|
||||
if (index >= 0 && index < bookmarks.length) {
|
||||
set({ selectedBookmarkIndex: index });
|
||||
set({ selectedBookmarkIndex: index })
|
||||
}
|
||||
},
|
||||
}));
|
||||
}))
|
||||
|
||||
function Page() {
|
||||
const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode);
|
||||
const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode)
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
const state = useBookmarkPageStore.getState();
|
||||
const state = useBookmarkPageStore.getState()
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
state.selectBookmarkAt(state.selectedBookmarkIndex + 1);
|
||||
break;
|
||||
state.selectBookmarkAt(state.selectedBookmarkIndex + 1)
|
||||
break
|
||||
case "ArrowUp":
|
||||
state.selectBookmarkAt(state.selectedBookmarkIndex - 1);
|
||||
break;
|
||||
state.selectBookmarkAt(state.selectedBookmarkIndex - 1)
|
||||
break
|
||||
case "ArrowLeft":
|
||||
state.setBookmarkItemExpanded(false);
|
||||
break;
|
||||
state.setBookmarkItemExpanded(false)
|
||||
break
|
||||
case "ArrowRight":
|
||||
state.setBookmarkItemExpanded(true);
|
||||
break;
|
||||
state.setBookmarkItemExpanded(true)
|
||||
break
|
||||
default:
|
||||
break;
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [useBookmarkPageStore]);
|
||||
window.removeEventListener("keydown", onKeyDown)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
function mediaQueryListener(this: MediaQueryList) {
|
||||
if (this.matches) {
|
||||
setLayoutMode(LAYOUT_MODE.sideBySide);
|
||||
setLayoutMode(LAYOUT_MODE.sideBySide)
|
||||
} else {
|
||||
setLayoutMode(LAYOUT_MODE.popup);
|
||||
setLayoutMode(LAYOUT_MODE.popup)
|
||||
}
|
||||
}
|
||||
|
||||
const q = window.matchMedia("(width >= 64rem)");
|
||||
q.addEventListener("change", mediaQueryListener);
|
||||
const q = window.matchMedia("(width >= 64rem)")
|
||||
q.addEventListener("change", mediaQueryListener)
|
||||
|
||||
mediaQueryListener.call(q);
|
||||
mediaQueryListener.call(q)
|
||||
|
||||
return () => {
|
||||
q.removeEventListener("change", mediaQueryListener);
|
||||
};
|
||||
}, []);
|
||||
q.removeEventListener("change", mediaQueryListener)
|
||||
}
|
||||
}, [setLayoutMode])
|
||||
|
||||
return (
|
||||
<div className="flex justify-center h-full">
|
||||
@@ -129,48 +129,38 @@ function Page() {
|
||||
</header>
|
||||
<div className="flex flex-col container max-w-2xl -mt-2">
|
||||
{testBookmarks.map((bookmark, i) => (
|
||||
<BookmarkListItem
|
||||
key={bookmark.id}
|
||||
index={i}
|
||||
bookmark={bookmark}
|
||||
/>
|
||||
<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);
|
||||
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",
|
||||
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);
|
||||
const isVisible = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
|
||||
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -184,36 +174,23 @@ function BookmarkPreview() {
|
||||
>
|
||||
<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 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);
|
||||
setBookmarkItemExpanded(true)
|
||||
if (useBookmarkPageStore.getState().layoutMode === LAYOUT_MODE.sideBySide) {
|
||||
console.log(useBookmarkPageStore.getState().layoutMode);
|
||||
setBookmarkPreviewOpened(true);
|
||||
console.log(useBookmarkPageStore.getState().layoutMode)
|
||||
setBookmarkPreviewOpened(true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,21 +202,19 @@ function BookmarkListItem({
|
||||
})}
|
||||
onMouseEnter={() => {
|
||||
if (!isBookmarkItemExpanded) {
|
||||
selectBookmarkAt(index);
|
||||
selectBookmarkAt(index)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isSelected}
|
||||
className={clsx(
|
||||
"select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100",
|
||||
{
|
||||
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
|
||||
invisible: !isSelected,
|
||||
},
|
||||
)}
|
||||
})}
|
||||
onClick={() => {
|
||||
setBookmarkItemExpanded(!isBookmarkItemExpanded);
|
||||
setBookmarkPreviewOpened(false);
|
||||
setBookmarkItemExpanded(!isBookmarkItemExpanded)
|
||||
setBookmarkPreviewOpened(false)
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">Options for this bookmark</span>
|
||||
@@ -248,15 +223,13 @@ function BookmarkListItem({
|
||||
<span> </span>
|
||||
</button>
|
||||
<div className="flex flex-col w-full">
|
||||
<button className="text-start font-bold" onClick={expandOrOpenPreview}>
|
||||
<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>
|
||||
<p className="text-sm">#dev #devops #devops #devops #devops #devops #devops</p>
|
||||
<div className="flex space-x-2">
|
||||
<OpenBookmarkPreviewButton />
|
||||
<Button className="text-sm">
|
||||
@@ -271,43 +244,37 @@ function BookmarkListItem({
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function OpenBookmarkPreviewButton() {
|
||||
const isBookmarkPreviewOpened = useBookmarkPageStore(
|
||||
(state) => state.isBookmarkPreviewOpened,
|
||||
);
|
||||
const setBookmarkPreviewOpened = useBookmarkPageStore(
|
||||
(state) => state.setBookmarkPreviewOpened,
|
||||
);
|
||||
const setBookmarkItemExpanded = useBookmarkPageStore(
|
||||
(state) => state.setBookmarkItemExpanded,
|
||||
);
|
||||
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();
|
||||
closePreview()
|
||||
} else if (!isBookmarkPreviewOpened && event.key === "o") {
|
||||
openPreview();
|
||||
openPreview()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keydown", onKeyDown)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [isBookmarkPreviewOpened]);
|
||||
window.removeEventListener("keydown", onKeyDown)
|
||||
}
|
||||
}, [isBookmarkPreviewOpened])
|
||||
|
||||
function closePreview() {
|
||||
setBookmarkPreviewOpened(false);
|
||||
setBookmarkItemExpanded(false);
|
||||
setBookmarkPreviewOpened(false)
|
||||
setBookmarkItemExpanded(false)
|
||||
}
|
||||
|
||||
function openPreview() {
|
||||
setBookmarkPreviewOpened(true);
|
||||
setBookmarkPreviewOpened(true)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -315,9 +282,9 @@ function OpenBookmarkPreviewButton() {
|
||||
className="text-sm"
|
||||
onClick={() => {
|
||||
if (isBookmarkPreviewOpened) {
|
||||
closePreview();
|
||||
closePreview()
|
||||
} else {
|
||||
openPreview();
|
||||
openPreview()
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -331,9 +298,9 @@ function OpenBookmarkPreviewButton() {
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute("/$username/bookmarks")({
|
||||
export const Route = createFileRoute("/bookmarks")({
|
||||
component: Page,
|
||||
});
|
||||
})
|
@@ -1,5 +1,7 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Link } from "~/components/link";
|
||||
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 (
|
||||
@@ -13,7 +15,7 @@ function Index() {
|
||||
<div className="flex flex-col text-lg">
|
||||
<Link>LOGIN</Link>
|
||||
<Link>SIGN UP</Link>
|
||||
<Link href="/demo-user/bookmarks">DEMO</Link>
|
||||
<DemoButton />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -23,10 +25,7 @@ function Index() {
|
||||
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>* 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>
|
||||
@@ -40,15 +39,37 @@ function Index() {
|
||||
<p>
|
||||
<strong>TECHNICAL STUFF</strong>
|
||||
<br />
|
||||
Source code, as well as hosting guide for MARKONE is{" "}
|
||||
<Link href="https://github.com/">available here</Link>.
|
||||
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,
|
||||
});
|
||||
})
|
Before Width: | Height: | Size: 4.0 KiB 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 }
|
@@ -1,12 +1,9 @@
|
||||
import clsx from "clsx";
|
||||
import clsx from "clsx"
|
||||
|
||||
function Button({
|
||||
className,
|
||||
...props
|
||||
}: React.DetailedHTMLProps<
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
HTMLButtonElement
|
||||
>) {
|
||||
}: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>) {
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
@@ -15,7 +12,7 @@ function Button({
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export { Button };
|
||||
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>,
|
||||
)
|
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"]
|
||||
}
|
@@ -1,9 +1,9 @@
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
import { defineConfig } from "vite";
|
||||
import path from "path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||
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({
|
||||
@@ -54,4 +54,4 @@ export default defineConfig({
|
||||
}),
|
||||
tailwindcss(),
|
||||
],
|
||||
});
|
||||
})
|
@@ -1,12 +0,0 @@
|
||||
import {
|
||||
defineConfig,
|
||||
minimal2023Preset as preset,
|
||||
} from '@vite-pwa/assets-generator/config'
|
||||
|
||||
export default defineConfig({
|
||||
headLinkOptions: {
|
||||
preset: '2023',
|
||||
},
|
||||
preset,
|
||||
images: ['public/favicon.svg'],
|
||||
})
|
@@ -1,29 +0,0 @@
|
||||
.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;
|
||||
}
|
@@ -1,77 +0,0 @@
|
||||
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)
|
||||
}
|
160
src/auth/auth.ts
160
src/auth/auth.ts
@@ -1,160 +0,0 @@
|
||||
import { HttpError, httpHandler } from "~/server-util";
|
||||
import dayjs from "dayjs";
|
||||
import { ulid } from "ulid";
|
||||
import {
|
||||
createSessionForUser,
|
||||
verifySession,
|
||||
extendSession,
|
||||
saveSession,
|
||||
forgetAllSessions,
|
||||
} from "./session";
|
||||
import { db } from "~/database";
|
||||
import {
|
||||
createUser,
|
||||
findUserById,
|
||||
findUserByUsername,
|
||||
type User,
|
||||
} from "~/user/user";
|
||||
import { type } from "arktype";
|
||||
|
||||
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) {
|
||||
const tokenId = authTokenCookie.split(":")[0];
|
||||
deleteAuthTokenQuery.run({ id: tokenId });
|
||||
rememberLoginForUser(user, request.cookies);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
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 };
|
@@ -1,134 +0,0 @@
|
||||
import uid from "uid-safe";
|
||||
import dayjs from "dayjs";
|
||||
import { db } from "~/database";
|
||||
import type { User } from "~/user/user";
|
||||
|
||||
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 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",
|
||||
);
|
||||
|
||||
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: 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 {
|
||||
newSessionId,
|
||||
createSessionForUser,
|
||||
verifySession,
|
||||
saveSession,
|
||||
extendSession,
|
||||
forgetAllSessions,
|
||||
};
|
@@ -1,18 +0,0 @@
|
||||
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 };
|
@@ -1,35 +0,0 @@
|
||||
import type { User } from "~/user/user";
|
||||
import { type } from "arktype";
|
||||
import { HttpError } from "~/server";
|
||||
import { db } from "~/database";
|
||||
|
||||
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 };
|
@@ -1,21 +0,0 @@
|
||||
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 };
|
@@ -1,62 +0,0 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
|
||||
const DB_VERSION = 0;
|
||||
|
||||
const db = new Database("data.sqlite");
|
||||
|
||||
const dbVersionQuery = db.query("SELECT version FROM migration");
|
||||
|
||||
const migrations = [
|
||||
`
|
||||
CREATE TABLE IF NOT EXISTS migration(
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
|
||||
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
|
||||
);
|
||||
`,
|
||||
];
|
||||
|
||||
function migrateDatabase() {
|
||||
let row = dbVersionQuery.get();
|
||||
let currentVersion: number;
|
||||
if (row) {
|
||||
currentVersion = (row as { version: number }).version;
|
||||
console.log(
|
||||
`Migrating database from version ${currentVersion} to version ${DB_VERSION}...`,
|
||||
);
|
||||
} else {
|
||||
currentVersion = -1;
|
||||
console.log("Initializing database...");
|
||||
}
|
||||
for (let version = currentVersion + 1; version <= DB_VERSION; ++version) {
|
||||
db.run(migrations[version]);
|
||||
}
|
||||
}
|
||||
|
||||
export { db, migrateDatabase };
|
@@ -1,9 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-stone-200 dark:bg-stone-900 text-stone-800 dark:text-stone-300;
|
||||
}
|
15
src/main.tsx
15
src/main.tsx
@@ -1,15 +0,0 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router";
|
||||
import "./index.css";
|
||||
import { routeTree } from "./app/-route-tree.gen";
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
@@ -1,27 +0,0 @@
|
||||
import { authenticated, signUp, login, logout } from "./auth/auth";
|
||||
import { listUserBookmarks } from "./bookmark/handlers";
|
||||
import { migrateDatabase } from "./database";
|
||||
import { httpHandler } from "./server-util";
|
||||
|
||||
function main() {
|
||||
migrateDatabase();
|
||||
|
||||
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();
|
@@ -1,30 +0,0 @@
|
||||
class HttpError {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
public readonly message?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
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 { HttpError, httpHandler };
|
@@ -1,67 +0,0 @@
|
||||
import { ulid } from "ulid";
|
||||
import { db } from "~/database";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export type { User };
|
||||
export { createUser, findUserByUsername, findUserById };
|
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"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,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
Reference in New Issue
Block a user