switch to monorepo structure

This commit is contained in:
2025-05-06 11:00:35 +01:00
parent e1f927ad27
commit 07b7f1b51f
63 changed files with 2440 additions and 1011 deletions

39
biome.json Normal file
View 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
}
}
}

1218
bun.lock Normal file

File diff suppressed because it is too large Load Diff

BIN
bun.lockb

Binary file not shown.

View File

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

View File

@@ -1,52 +1,12 @@
{ {
"name": "markone", "name": "markone",
"private": true, "private": true,
"version": "0.0.0",
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "bun --filter '*' dev"
"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"
}, },
"workspaces": ["packages/*"],
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.18.0", "@biomejs/biome": "1.9.4"
"@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"
}, },
"resolutions": { "trustedDependencies": ["@biomejs/biome"]
"sharp": "0.32.6",
"sharp-ico": "0.1.5"
}
} }

34
packages/core/.gitignore vendored Normal file
View 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
View 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.

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

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

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

View File

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

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

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

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

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

View File

@@ -0,0 +1,8 @@
class HttpError {
constructor(
public readonly status: number,
public readonly message?: string,
) {}
}
export { HttpError }

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

View 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()

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

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

View File

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

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

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

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

View File

@@ -11,23 +11,23 @@
// Import Routes // Import Routes
import { Route as rootRoute } from "./__root" import { Route as rootRoute } from "./__root"
import { Route as BookmarksImport } from "./bookmarks"
import { Route as IndexImport } from "./index" import { Route as IndexImport } from "./index"
import { Route as UsernameBookmarksImport } from "./$username/bookmarks"
// Create/Update Routes // Create/Update Routes
const BookmarksRoute = BookmarksImport.update({
id: "/bookmarks",
path: "/bookmarks",
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({ const IndexRoute = IndexImport.update({
id: "/", id: "/",
path: "/", path: "/",
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
} as any) } as any)
const UsernameBookmarksRoute = UsernameBookmarksImport.update({
id: "/$username/bookmarks",
path: "/$username/bookmarks",
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface // Populate the FileRoutesByPath interface
declare module "@tanstack/react-router" { declare module "@tanstack/react-router" {
@@ -39,11 +39,11 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof IndexImport preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
"/$username/bookmarks": { "/bookmarks": {
id: "/$username/bookmarks" id: "/bookmarks"
path: "/$username/bookmarks" path: "/bookmarks"
fullPath: "/$username/bookmarks" fullPath: "/bookmarks"
preLoaderRoute: typeof UsernameBookmarksImport preLoaderRoute: typeof BookmarksImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
} }
@@ -53,37 +53,37 @@ declare module "@tanstack/react-router" {
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
"/": typeof IndexRoute "/": typeof IndexRoute
"/$username/bookmarks": typeof UsernameBookmarksRoute "/bookmarks": typeof BookmarksRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
"/": typeof IndexRoute "/": typeof IndexRoute
"/$username/bookmarks": typeof UsernameBookmarksRoute "/bookmarks": typeof BookmarksRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRoute __root__: typeof rootRoute
"/": typeof IndexRoute "/": typeof IndexRoute
"/$username/bookmarks": typeof UsernameBookmarksRoute "/bookmarks": typeof BookmarksRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: "/" | "/$username/bookmarks" fullPaths: "/" | "/bookmarks"
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: "/" | "/$username/bookmarks" to: "/" | "/bookmarks"
id: "__root__" | "/" | "/$username/bookmarks" id: "__root__" | "/" | "/bookmarks"
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
UsernameBookmarksRoute: typeof UsernameBookmarksRoute BookmarksRoute: typeof BookmarksRoute
} }
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
UsernameBookmarksRoute: UsernameBookmarksRoute, BookmarksRoute: BookmarksRoute,
} }
export const routeTree = rootRoute export const routeTree = rootRoute
@@ -97,14 +97,14 @@ export const routeTree = rootRoute
"filePath": "__root.tsx", "filePath": "__root.tsx",
"children": [ "children": [
"/", "/",
"/$username/bookmarks" "/bookmarks"
] ]
}, },
"/": { "/": {
"filePath": "index.tsx" "filePath": "index.tsx"
}, },
"/$username/bookmarks": { "/bookmarks": {
"filePath": "$username/bookmarks.tsx" "filePath": "bookmarks.tsx"
} }
} }
} }

View File

@@ -1,5 +1,5 @@
import { createRootRoute, Outlet } from "@tanstack/react-router"; import { Outlet, createRootRoute } from "@tanstack/react-router"
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"
function Root() { function Root() {
return ( return (
@@ -7,9 +7,9 @@ function Root() {
<Outlet /> <Outlet />
<TanStackRouterDevtools /> <TanStackRouterDevtools />
</> </>
); )
} }
export const Route = createRootRoute({ export const Route = createRootRoute({
component: Root, component: Root,
}); })

View File

@@ -1,9 +1,9 @@
import { useEffect } from "react"; import type { LinkBookmark } from "@markone/core/bookmark"
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router"
import { create } from "zustand"; import clsx from "clsx"
import clsx from "clsx"; import { useEffect } from "react"
import { Button } from "~/components/button"; import { create } from "zustand"
import type { LinkBookmark } from "~/bookmark/bookmark"; import { Button } from "~/components/button"
const testBookmarks: LinkBookmark[] = [ const testBookmarks: LinkBookmark[] = [
{ {
@@ -18,25 +18,25 @@ const testBookmarks: LinkBookmark[] = [
title: "Running a Docker container as a non-root user", title: "Running a Docker container as a non-root user",
url: "https://test.website.com/article/123", url: "https://test.website.com/article/123",
}, },
]; ]
const LAYOUT_MODE = { const LAYOUT_MODE = {
popup: "popup", popup: "popup",
sideBySide: "side-by-side", sideBySide: "side-by-side",
} as const; } as const
type LayoutMode = (typeof LAYOUT_MODE)[keyof typeof LAYOUT_MODE]; type LayoutMode = (typeof LAYOUT_MODE)[keyof typeof LAYOUT_MODE]
interface BookmarkPageState { interface BookmarkPageState {
bookmarks: LinkBookmark[]; bookmarks: LinkBookmark[]
selectedBookmarkIndex: number; selectedBookmarkIndex: number
isBookmarkItemExpanded: boolean; isBookmarkItemExpanded: boolean
isBookmarkPreviewOpened: boolean; isBookmarkPreviewOpened: boolean
layoutMode: LayoutMode; layoutMode: LayoutMode
setBookmarkItemExpanded: (isExpanded: boolean) => void; setBookmarkItemExpanded: (isExpanded: boolean) => void
setBookmarkPreviewOpened: (isOpened: boolean) => void; setBookmarkPreviewOpened: (isOpened: boolean) => void
setLayoutMode: (mode: LayoutMode) => void; setLayoutMode: (mode: LayoutMode) => void
selectBookmarkAt: (index: number) => void; selectBookmarkAt: (index: number) => void
} }
const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
@@ -47,75 +47,75 @@ const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
layoutMode: LAYOUT_MODE.popup, layoutMode: LAYOUT_MODE.popup,
setBookmarkItemExpanded(isExpanded: boolean) { setBookmarkItemExpanded(isExpanded: boolean) {
set({ isBookmarkItemExpanded: isExpanded }); set({ isBookmarkItemExpanded: isExpanded })
}, },
setBookmarkPreviewOpened(isOpened: boolean) { setBookmarkPreviewOpened(isOpened: boolean) {
set({ isBookmarkPreviewOpened: isOpened }); set({ isBookmarkPreviewOpened: isOpened })
}, },
setLayoutMode(mode: LayoutMode) { setLayoutMode(mode: LayoutMode) {
set({ layoutMode: mode }); set({ layoutMode: mode })
}, },
selectBookmarkAt(index: number) { selectBookmarkAt(index: number) {
const bookmarks = get().bookmarks; const bookmarks = get().bookmarks
if (index >= 0 && index < bookmarks.length) { if (index >= 0 && index < bookmarks.length) {
set({ selectedBookmarkIndex: index }); set({ selectedBookmarkIndex: index })
} }
}, },
})); }))
function Page() { function Page() {
const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode); const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode)
useEffect(() => { useEffect(() => {
function onKeyDown(event: KeyboardEvent) { function onKeyDown(event: KeyboardEvent) {
const state = useBookmarkPageStore.getState(); const state = useBookmarkPageStore.getState()
switch (event.key) { switch (event.key) {
case "ArrowDown": case "ArrowDown":
state.selectBookmarkAt(state.selectedBookmarkIndex + 1); state.selectBookmarkAt(state.selectedBookmarkIndex + 1)
break; break
case "ArrowUp": case "ArrowUp":
state.selectBookmarkAt(state.selectedBookmarkIndex - 1); state.selectBookmarkAt(state.selectedBookmarkIndex - 1)
break; break
case "ArrowLeft": case "ArrowLeft":
state.setBookmarkItemExpanded(false); state.setBookmarkItemExpanded(false)
break; break
case "ArrowRight": case "ArrowRight":
state.setBookmarkItemExpanded(true); state.setBookmarkItemExpanded(true)
break; break
default: default:
break; break
} }
} }
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown)
return () => { return () => {
window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keydown", onKeyDown)
}; }
}, [useBookmarkPageStore]); }, [])
useEffect(() => { useEffect(() => {
function mediaQueryListener(this: MediaQueryList) { function mediaQueryListener(this: MediaQueryList) {
if (this.matches) { if (this.matches) {
setLayoutMode(LAYOUT_MODE.sideBySide); setLayoutMode(LAYOUT_MODE.sideBySide)
} else { } else {
setLayoutMode(LAYOUT_MODE.popup); setLayoutMode(LAYOUT_MODE.popup)
} }
} }
const q = window.matchMedia("(width >= 64rem)"); const q = window.matchMedia("(width >= 64rem)")
q.addEventListener("change", mediaQueryListener); q.addEventListener("change", mediaQueryListener)
mediaQueryListener.call(q); mediaQueryListener.call(q)
return () => { return () => {
q.removeEventListener("change", mediaQueryListener); q.removeEventListener("change", mediaQueryListener)
}; }
}, []); }, [setLayoutMode])
return ( return (
<div className="flex justify-center h-full"> <div className="flex justify-center h-full">
@@ -129,48 +129,38 @@ function Page() {
</header> </header>
<div className="flex flex-col container max-w-2xl -mt-2"> <div className="flex flex-col container max-w-2xl -mt-2">
{testBookmarks.map((bookmark, i) => ( {testBookmarks.map((bookmark, i) => (
<BookmarkListItem <BookmarkListItem key={bookmark.id} index={i} bookmark={bookmark} />
key={bookmark.id}
index={i}
bookmark={bookmark}
/>
))} ))}
</div> </div>
</div> </div>
<BookmarkPreview /> <BookmarkPreview />
</Main> </Main>
</div> </div>
); )
} }
function Main({ children }: React.PropsWithChildren) { function Main({ children }: React.PropsWithChildren) {
const isPreviewOpened = useBookmarkPageStore( const isPreviewOpened = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
(state) => state.isBookmarkPreviewOpened, const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
);
const layoutMode = useBookmarkPageStore((state) => state.layoutMode);
return ( return (
<main <main
className={clsx( className={clsx(
"px-4 lg:px-8 2xl:px-0 grid flex justify-center relative w-full", "px-4 lg:px-8 2xl:px-0 grid flex justify-center relative w-full",
isPreviewOpened && layoutMode === LAYOUT_MODE.sideBySide isPreviewOpened && layoutMode === LAYOUT_MODE.sideBySide ? "grid-cols-2" : "grid-cols-1",
? "grid-cols-2"
: "grid-cols-1",
)} )}
> >
{children} {children}
</main> </main>
); )
} }
function BookmarkPreview() { function BookmarkPreview() {
const isVisible = useBookmarkPageStore( const isVisible = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
(state) => state.isBookmarkPreviewOpened, const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
);
const layoutMode = useBookmarkPageStore((state) => state.layoutMode);
if (!isVisible) { if (!isVisible) {
return null; return null
} }
return ( return (
@@ -184,36 +174,23 @@ function BookmarkPreview() {
> >
<p>Content here</p> <p>Content here</p>
</div> </div>
); )
} }
function BookmarkListItem({ function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index: number }) {
bookmark, const url = new URL(bookmark.url)
index, const selectedBookmark = useBookmarkPageStore((state) => state.bookmarks[state.selectedBookmarkIndex])
}: { bookmark: LinkBookmark; index: number }) { const isSelected = selectedBookmark.id === bookmark.id
const url = new URL(bookmark.url); const isBookmarkItemExpanded = useBookmarkPageStore((state) => state.isBookmarkItemExpanded)
const selectedBookmark = useBookmarkPageStore( const setBookmarkItemExpanded = useBookmarkPageStore((state) => state.setBookmarkItemExpanded)
(state) => state.bookmarks[state.selectedBookmarkIndex], const selectBookmarkAt = useBookmarkPageStore((state) => state.selectBookmarkAt)
); const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
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() { function expandOrOpenPreview() {
setBookmarkItemExpanded(true); setBookmarkItemExpanded(true)
if (useBookmarkPageStore.getState().layoutMode === LAYOUT_MODE.sideBySide) { if (useBookmarkPageStore.getState().layoutMode === LAYOUT_MODE.sideBySide) {
console.log(useBookmarkPageStore.getState().layoutMode); console.log(useBookmarkPageStore.getState().layoutMode)
setBookmarkPreviewOpened(true); setBookmarkPreviewOpened(true)
} }
} }
@@ -225,21 +202,19 @@ function BookmarkListItem({
})} })}
onMouseEnter={() => { onMouseEnter={() => {
if (!isBookmarkItemExpanded) { if (!isBookmarkItemExpanded) {
selectBookmarkAt(index); selectBookmarkAt(index)
} }
}} }}
> >
<button <button
type="button"
disabled={!isSelected} disabled={!isSelected}
className={clsx( className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
"select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", invisible: !isSelected,
{ })}
invisible: !isSelected,
},
)}
onClick={() => { onClick={() => {
setBookmarkItemExpanded(!isBookmarkItemExpanded); setBookmarkItemExpanded(!isBookmarkItemExpanded)
setBookmarkPreviewOpened(false); setBookmarkPreviewOpened(false)
}} }}
> >
<span className="sr-only">Options for this bookmark</span> <span className="sr-only">Options for this bookmark</span>
@@ -248,15 +223,13 @@ function BookmarkListItem({
<span>&nbsp;</span> <span>&nbsp;</span>
</button> </button>
<div className="flex flex-col w-full"> <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} {bookmark.title}
</button> </button>
<p className="opacity-80 text-sm">{url.host}</p> <p className="opacity-80 text-sm">{url.host}</p>
{isBookmarkItemExpanded && isSelected ? ( {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"> <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"> <p className="text-sm">#dev #devops #devops #devops #devops #devops #devops</p>
#dev #devops #devops #devops #devops #devops #devops
</p>
<div className="flex space-x-2"> <div className="flex space-x-2">
<OpenBookmarkPreviewButton /> <OpenBookmarkPreviewButton />
<Button className="text-sm"> <Button className="text-sm">
@@ -271,43 +244,37 @@ function BookmarkListItem({
) : null} ) : null}
</div> </div>
</div> </div>
); )
} }
function OpenBookmarkPreviewButton() { function OpenBookmarkPreviewButton() {
const isBookmarkPreviewOpened = useBookmarkPageStore( const isBookmarkPreviewOpened = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
(state) => state.isBookmarkPreviewOpened, const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
); const setBookmarkItemExpanded = useBookmarkPageStore((state) => state.setBookmarkItemExpanded)
const setBookmarkPreviewOpened = useBookmarkPageStore(
(state) => state.setBookmarkPreviewOpened,
);
const setBookmarkItemExpanded = useBookmarkPageStore(
(state) => state.setBookmarkItemExpanded,
);
useEffect(() => { useEffect(() => {
function onKeyDown(event: KeyboardEvent) { function onKeyDown(event: KeyboardEvent) {
if (isBookmarkPreviewOpened && event.key === "c") { if (isBookmarkPreviewOpened && event.key === "c") {
closePreview(); closePreview()
} else if (!isBookmarkPreviewOpened && event.key === "o") { } else if (!isBookmarkPreviewOpened && event.key === "o") {
openPreview(); openPreview()
} }
} }
window.addEventListener("keydown", onKeyDown); window.addEventListener("keydown", onKeyDown)
return () => { return () => {
window.removeEventListener("keydown", onKeyDown); window.removeEventListener("keydown", onKeyDown)
}; }
}, [isBookmarkPreviewOpened]); }, [isBookmarkPreviewOpened])
function closePreview() { function closePreview() {
setBookmarkPreviewOpened(false); setBookmarkPreviewOpened(false)
setBookmarkItemExpanded(false); setBookmarkItemExpanded(false)
} }
function openPreview() { function openPreview() {
setBookmarkPreviewOpened(true); setBookmarkPreviewOpened(true)
} }
return ( return (
@@ -315,9 +282,9 @@ function OpenBookmarkPreviewButton() {
className="text-sm" className="text-sm"
onClick={() => { onClick={() => {
if (isBookmarkPreviewOpened) { if (isBookmarkPreviewOpened) {
closePreview(); closePreview()
} else { } else {
openPreview(); openPreview()
} }
}} }}
> >
@@ -331,9 +298,9 @@ function OpenBookmarkPreviewButton() {
</> </>
)} )}
</Button> </Button>
); )
} }
export const Route = createFileRoute("/$username/bookmarks")({ export const Route = createFileRoute("/bookmarks")({
component: Page, component: Page,
}); })

View File

@@ -1,5 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"; import { DEMO_USER } from "@markone/core/user"
import { Link } from "~/components/link"; import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { useLogin } from "~/auth"
import { Link } from "~/components/link"
function Index() { function Index() {
return ( return (
@@ -13,7 +15,7 @@ function Index() {
<div className="flex flex-col text-lg"> <div className="flex flex-col text-lg">
<Link>LOGIN</Link> <Link>LOGIN</Link>
<Link>SIGN UP</Link> <Link>SIGN UP</Link>
<Link href="/demo-user/bookmarks">DEMO</Link> <DemoButton />
</div> </div>
</div> </div>
<div> <div>
@@ -23,10 +25,7 @@ function Index() {
MARKONE is a local-first, self-hostable bookmark manager. MARKONE is a local-first, self-hostable bookmark manager.
</p> </p>
<ul className="px-2 pt-2"> <ul className="px-2 pt-2">
<li> <li>* Curate interesting websites you find online, and let MARKONE archive them.</li>
* Curate interesting websites you find online, and let MARKONE
archive them.
</li>
<li>* Reference your saved bookmarks anytime, even when offline.</li> <li>* Reference your saved bookmarks anytime, even when offline.</li>
<li>* Share your collections to others with a permalink.</li> <li>* Share your collections to others with a permalink.</li>
</ul> </ul>
@@ -40,15 +39,37 @@ function Index() {
<p> <p>
<strong>TECHNICAL STUFF</strong> <strong>TECHNICAL STUFF</strong>
<br /> <br />
Source code, as well as hosting guide for MARKONE is{" "} Source code, as well as hosting guide for MARKONE is <Link href="https://github.com/">available here</Link>.
<Link href="https://github.com/">available here</Link>.
<br /> <br />
</p> </p>
</div> </div>
</main> </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("/")({ export const Route = createFileRoute("/")({
component: Index, component: Index,
}); })

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

14
packages/web/src/auth.ts Normal file
View 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 }

View File

@@ -1,12 +1,9 @@
import clsx from "clsx"; import clsx from "clsx"
function Button({ function Button({
className, className,
...props ...props
}: React.DetailedHTMLProps< }: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>) {
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
>) {
return ( return (
<button <button
className={clsx( className={clsx(
@@ -15,7 +12,7 @@ function Button({
)} )}
{...props} {...props}
/> />
); )
} }
export { Button }; export { Button }

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

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

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

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

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

View File

@@ -1,9 +1,9 @@
import { VitePWA } from "vite-plugin-pwa"; import path from "node:path"
import { defineConfig } from "vite"; import tailwindcss from "@tailwindcss/vite"
import path from "path"; import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react"
import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "vite"
import { TanStackRouterVite } from "@tanstack/router-plugin/vite"; import { VitePWA } from "vite-plugin-pwa"
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
@@ -54,4 +54,4 @@ export default defineConfig({
}), }),
tailwindcss(), tailwindcss(),
], ],
}); })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

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