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

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

50
packages/web/README.md Normal file
View File

@@ -0,0 +1,50 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```

13
packages/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MarkOne + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

42
packages/web/package.json Normal file
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

@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="410"
height="404"
viewBox="0 0 410 404"
fill="none"
version="1.1"
id="svg20"
sodipodi:docname="favicon.svg"
inkscape:version="1.1 (c68e22c387, 2021-05-23)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata
id="metadata24">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1001"
id="namedview22"
showgrid="false"
inkscape:zoom="0.51361386"
inkscape:cx="-374.79518"
inkscape:cy="145.0506"
inkscape:window-x="-9"
inkscape:window-y="-9"
inkscape:window-maximized="1"
inkscape:current-layer="g8"
inkscape:document-rotation="0"
inkscape:pagecheckerboard="0" />
<path
d="M399.641 59.5246L215.643 388.545C211.844 395.338 202.084 395.378 198.228 388.618L10.5817 59.5563C6.38087 52.1896 12.6802 43.2665 21.0281 44.7586L205.223 77.6824C206.398 77.8924 207.601 77.8904 208.776 77.6763L389.119 44.8058C397.439 43.2894 403.768 52.1434 399.641 59.5246Z"
fill="url(#paint0_linear)"
id="path2" />
<defs
id="defs18">
<linearGradient
id="paint0_linear"
x1="6.00017"
y1="32.9999"
x2="235"
y2="344"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#41D1FF"
id="stop6" />
<stop
offset="1"
stop-color="#BD34FE"
id="stop8" />
</linearGradient>
<linearGradient
id="paint1_linear"
x1="194.651"
y1="8.81818"
x2="236.076"
y2="292.989"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#FFEA83"
id="stop11" />
<stop
offset="0.0833333"
stop-color="#FFDD35"
id="stop13" />
<stop
offset="1"
stop-color="#FFA800"
id="stop15" />
</linearGradient>
</defs>
<path
d="M292.965 1.5744L156.801 28.2552C154.563 28.6937 152.906 30.5903 152.771 32.8664L144.395 174.33C144.198 177.662 147.258 180.248 150.51 179.498L188.42 170.749C191.967 169.931 195.172 173.055 194.443 176.622L183.18 231.775C182.422 235.487 185.907 238.661 189.532 237.56L212.947 230.446C216.577 229.344 220.065 232.527 219.297 236.242L201.398 322.875C200.278 328.294 207.486 331.249 210.492 326.603L212.5 323.5L323.454 102.072C325.312 98.3645 322.108 94.137 318.036 94.9228L279.014 102.454C275.347 103.161 272.227 99.746 273.262 96.1583L298.731 7.86689C299.767 4.27314 296.636 0.855181 292.965 1.5744Z"
fill="url(#paint1_linear)"
id="path4" />
<g
inkscape:groupmode="layer"
id="layer1"
inkscape:label="PWA">
<g
id="g8"
transform="matrix(0.15789659,0,0,0.15890333,54.892928,275.21638)">
<path
fill="#3d3d3d"
fill-opacity="1"
stroke-width="0.2"
stroke-linejoin="round"
d="m 1436.62,603.304 56.39,-142.599 h 162.82 L 1578.56,244.39 1675.2,5.28336e-4 1952,734.933 h -204.13 l -47.3,-131.629 z"
id="path2-1"
style="fill:#3e3e3e;fill-opacity:1" />
<path
fill="#5a0fc8"
fill-opacity="1"
stroke-width="0.2"
stroke-linejoin="round"
d="M 1262.47,734.935 1558.79,0.00156593 1362.34,0.0025425 1159.64,474.933 1015.5,0.00351906 H 864.499 L 709.731,474.933 600.585,258.517 501.812,562.819 602.096,734.935 h 193.331 l 139.857,-425.91 133.346,425.91 z"
id="path4-4"
style="fill:#2e859c;fill-opacity:1" />
<path
fill="#3d3d3d"
fill-opacity="1"
stroke-width="0.2"
stroke-linejoin="round"
d="m 186.476,482.643 h 121.003 c 36.654,0 69.293,-4.091 97.917,-12.273 l 31.293,-96.408 87.459,-269.446 C 517.484,93.9535 509.876,83.9667 501.324,74.5569 456.419,24.852 390.719,4.06265e-4 304.222,4.06265e-4 H -3.8147e-6 V 734.933 H 186.476 Z M 346.642,169.079 c 17.54,17.653 26.309,41.276 26.309,70.871 0,29.822 -7.713,53.474 -23.138,70.956 -16.91,19.425 -48.047,29.137 -93.409,29.137 H 186.476 V 142.598 h 70.442 c 42.277,0 72.185,8.827 89.724,26.481 z"
id="path6"
style="fill:#3e3e3e;fill-opacity:1" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

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

@@ -0,0 +1,111 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
// Import Routes
import { Route as rootRoute } from "./__root"
import { Route as BookmarksImport } from "./bookmarks"
import { Route as IndexImport } from "./index"
// Create/Update Routes
const BookmarksRoute = BookmarksImport.update({
id: "/bookmarks",
path: "/bookmarks",
getParentRoute: () => rootRoute,
} as any)
const IndexRoute = IndexImport.update({
id: "/",
path: "/",
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module "@tanstack/react-router" {
interface FileRoutesByPath {
"/": {
id: "/"
path: "/"
fullPath: "/"
preLoaderRoute: typeof IndexImport
parentRoute: typeof rootRoute
}
"/bookmarks": {
id: "/bookmarks"
path: "/bookmarks"
fullPath: "/bookmarks"
preLoaderRoute: typeof BookmarksImport
parentRoute: typeof rootRoute
}
}
}
// Create and export the route tree
export interface FileRoutesByFullPath {
"/": typeof IndexRoute
"/bookmarks": typeof BookmarksRoute
}
export interface FileRoutesByTo {
"/": typeof IndexRoute
"/bookmarks": typeof BookmarksRoute
}
export interface FileRoutesById {
__root__: typeof rootRoute
"/": typeof IndexRoute
"/bookmarks": typeof BookmarksRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: "/" | "/bookmarks"
fileRoutesByTo: FileRoutesByTo
to: "/" | "/bookmarks"
id: "__root__" | "/" | "/bookmarks"
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
BookmarksRoute: typeof BookmarksRoute
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
BookmarksRoute: BookmarksRoute,
}
export const routeTree = rootRoute
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
/* ROUTE_MANIFEST_START
{
"routes": {
"__root__": {
"filePath": "__root.tsx",
"children": [
"/",
"/bookmarks"
]
},
"/": {
"filePath": "index.tsx"
},
"/bookmarks": {
"filePath": "bookmarks.tsx"
}
}
}
ROUTE_MANIFEST_END */

View File

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

View File

@@ -0,0 +1,306 @@
import type { LinkBookmark } from "@markone/core/bookmark"
import { createFileRoute } from "@tanstack/react-router"
import clsx from "clsx"
import { useEffect } from "react"
import { create } from "zustand"
import { Button } from "~/components/button"
const testBookmarks: LinkBookmark[] = [
{
kind: "link",
id: "1",
title: "Running a Docker container as a non-root user",
url: "https://test.website.com/article/123",
},
{
kind: "link",
id: "2",
title: "Running a Docker container as a non-root user",
url: "https://test.website.com/article/123",
},
]
const LAYOUT_MODE = {
popup: "popup",
sideBySide: "side-by-side",
} as const
type LayoutMode = (typeof LAYOUT_MODE)[keyof typeof LAYOUT_MODE]
interface BookmarkPageState {
bookmarks: LinkBookmark[]
selectedBookmarkIndex: number
isBookmarkItemExpanded: boolean
isBookmarkPreviewOpened: boolean
layoutMode: LayoutMode
setBookmarkItemExpanded: (isExpanded: boolean) => void
setBookmarkPreviewOpened: (isOpened: boolean) => void
setLayoutMode: (mode: LayoutMode) => void
selectBookmarkAt: (index: number) => void
}
const useBookmarkPageStore = create<BookmarkPageState>()((set, get) => ({
bookmarks: testBookmarks,
selectedBookmarkIndex: 0,
isBookmarkItemExpanded: false,
isBookmarkPreviewOpened: false,
layoutMode: LAYOUT_MODE.popup,
setBookmarkItemExpanded(isExpanded: boolean) {
set({ isBookmarkItemExpanded: isExpanded })
},
setBookmarkPreviewOpened(isOpened: boolean) {
set({ isBookmarkPreviewOpened: isOpened })
},
setLayoutMode(mode: LayoutMode) {
set({ layoutMode: mode })
},
selectBookmarkAt(index: number) {
const bookmarks = get().bookmarks
if (index >= 0 && index < bookmarks.length) {
set({ selectedBookmarkIndex: index })
}
},
}))
function Page() {
const setLayoutMode = useBookmarkPageStore((state) => state.setLayoutMode)
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
const state = useBookmarkPageStore.getState()
switch (event.key) {
case "ArrowDown":
state.selectBookmarkAt(state.selectedBookmarkIndex + 1)
break
case "ArrowUp":
state.selectBookmarkAt(state.selectedBookmarkIndex - 1)
break
case "ArrowLeft":
state.setBookmarkItemExpanded(false)
break
case "ArrowRight":
state.setBookmarkItemExpanded(true)
break
default:
break
}
}
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("keydown", onKeyDown)
}
}, [])
useEffect(() => {
function mediaQueryListener(this: MediaQueryList) {
if (this.matches) {
setLayoutMode(LAYOUT_MODE.sideBySide)
} else {
setLayoutMode(LAYOUT_MODE.popup)
}
}
const q = window.matchMedia("(width >= 64rem)")
q.addEventListener("change", mediaQueryListener)
mediaQueryListener.call(q)
return () => {
q.removeEventListener("change", mediaQueryListener)
}
}, [setLayoutMode])
return (
<div className="flex justify-center h-full">
<Main>
<div className="flex flex-col md:flex-row justify-center py-16 lg:py-32 ">
<header className="mb-4 md:mb-0 md:mr-16 text-start">
<h1 className="font-bold text-start">
<span className="invisible md:hidden">&gt;&nbsp;</span>
YOUR BOOKMARKS
</h1>
</header>
<div className="flex flex-col container max-w-2xl -mt-2">
{testBookmarks.map((bookmark, i) => (
<BookmarkListItem key={bookmark.id} index={i} bookmark={bookmark} />
))}
</div>
</div>
<BookmarkPreview />
</Main>
</div>
)
}
function Main({ children }: React.PropsWithChildren) {
const isPreviewOpened = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
return (
<main
className={clsx(
"px-4 lg:px-8 2xl:px-0 grid flex justify-center relative w-full",
isPreviewOpened && layoutMode === LAYOUT_MODE.sideBySide ? "grid-cols-2" : "grid-cols-1",
)}
>
{children}
</main>
)
}
function BookmarkPreview() {
const isVisible = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
const layoutMode = useBookmarkPageStore((state) => state.layoutMode)
if (!isVisible) {
return null
}
return (
<div
className={clsx(
"h-screen flex justify-center items-center border-l border-stone-700 dark:border-stone-300 flex dark:bg-stone-900",
{
"absolute inset-0 border-l-0": layoutMode === LAYOUT_MODE.popup,
},
)}
>
<p>Content here</p>
</div>
)
}
function BookmarkListItem({ bookmark, index }: { bookmark: LinkBookmark; index: number }) {
const url = new URL(bookmark.url)
const selectedBookmark = useBookmarkPageStore((state) => state.bookmarks[state.selectedBookmarkIndex])
const isSelected = selectedBookmark.id === bookmark.id
const isBookmarkItemExpanded = useBookmarkPageStore((state) => state.isBookmarkItemExpanded)
const setBookmarkItemExpanded = useBookmarkPageStore((state) => state.setBookmarkItemExpanded)
const selectBookmarkAt = useBookmarkPageStore((state) => state.selectBookmarkAt)
const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
function expandOrOpenPreview() {
setBookmarkItemExpanded(true)
if (useBookmarkPageStore.getState().layoutMode === LAYOUT_MODE.sideBySide) {
console.log(useBookmarkPageStore.getState().layoutMode)
setBookmarkPreviewOpened(true)
}
}
return (
<div
className={clsx("group flex flex-row justify-start py-2", {
"bg-teal-600 text-stone-100": isBookmarkItemExpanded && isSelected,
"text-teal-600": isSelected && !isBookmarkItemExpanded,
})}
onMouseEnter={() => {
if (!isBookmarkItemExpanded) {
selectBookmarkAt(index)
}
}}
>
<button
type="button"
disabled={!isSelected}
className={clsx("select-none flex items-start font-bold hover:bg-teal-600 hover:text-stone-100", {
invisible: !isSelected,
})}
onClick={() => {
setBookmarkItemExpanded(!isBookmarkItemExpanded)
setBookmarkPreviewOpened(false)
}}
>
<span className="sr-only">Options for this bookmark</span>
<span>&nbsp;</span>
<span className={isBookmarkItemExpanded ? "rotate-90" : ""}>&gt;</span>
<span>&nbsp;</span>
</button>
<div className="flex flex-col w-full">
<button type="button" className="text-start font-bold" onClick={expandOrOpenPreview}>
{bookmark.title}
</button>
<p className="opacity-80 text-sm">{url.host}</p>
{isBookmarkItemExpanded && isSelected ? (
<div className="flex flex-col space-y-1 md:flex-row md:space-y-0 md:space-x-2 items-end justify-between pt-2">
<p className="text-sm">#dev #devops #devops #devops #devops #devops #devops</p>
<div className="flex space-x-2">
<OpenBookmarkPreviewButton />
<Button className="text-sm">
<span className="underline">E</span>dit
</Button>
<Button className="text-sm">
<span className="underline">D</span>elete
</Button>
<span className="-ml-2">&nbsp;</span>
</div>
</div>
) : null}
</div>
</div>
)
}
function OpenBookmarkPreviewButton() {
const isBookmarkPreviewOpened = useBookmarkPageStore((state) => state.isBookmarkPreviewOpened)
const setBookmarkPreviewOpened = useBookmarkPageStore((state) => state.setBookmarkPreviewOpened)
const setBookmarkItemExpanded = useBookmarkPageStore((state) => state.setBookmarkItemExpanded)
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (isBookmarkPreviewOpened && event.key === "c") {
closePreview()
} else if (!isBookmarkPreviewOpened && event.key === "o") {
openPreview()
}
}
window.addEventListener("keydown", onKeyDown)
return () => {
window.removeEventListener("keydown", onKeyDown)
}
}, [isBookmarkPreviewOpened])
function closePreview() {
setBookmarkPreviewOpened(false)
setBookmarkItemExpanded(false)
}
function openPreview() {
setBookmarkPreviewOpened(true)
}
return (
<Button
className="text-sm"
onClick={() => {
if (isBookmarkPreviewOpened) {
closePreview()
} else {
openPreview()
}
}}
>
{isBookmarkPreviewOpened ? (
<>
<span className="underline">C</span>lose
</>
) : (
<>
<span className="underline">O</span>pen
</>
)}
</Button>
)
}
export const Route = createFileRoute("/bookmarks")({
component: Page,
})

View File

@@ -0,0 +1,75 @@
import { DEMO_USER } from "@markone/core/user"
import { createFileRoute, useNavigate } from "@tanstack/react-router"
import { useLogin } from "~/auth"
import { Link } from "~/components/link"
function Index() {
return (
<main className="p-48 flex flex-row items-start justify-center space-x-24">
<div className="flex flex-col items-start">
<h1 className="text-2xl">
<span className="font-bold">MARKONE</span>
<br />
</h1>
<p className="pb-4 text-2xl uppercase">BOOKMARK MANAGER</p>
<div className="flex flex-col text-lg">
<Link>LOGIN</Link>
<Link>SIGN UP</Link>
<DemoButton />
</div>
</div>
<div>
<p>
<strong>WHAT IS MARKONE?</strong>
<br />
MARKONE is a local-first, self-hostable bookmark manager.
</p>
<ul className="px-2 pt-2">
<li>* Curate interesting websites you find online, and let MARKONE archive them.</li>
<li>* Reference your saved bookmarks anytime, even when offline.</li>
<li>* Share your collections to others with a permalink.</li>
</ul>
<br />
<p>
<strong>WHERE IS MARKONE?</strong>
<br />
MARKONE is available as a web app and a browser extension for now.
</p>
<br />
<p>
<strong>TECHNICAL STUFF</strong>
<br />
Source code, as well as hosting guide for MARKONE is <Link href="https://github.com/">available here</Link>.
<br />
</p>
</div>
</main>
)
}
function DemoButton() {
const loginMutation = useLogin()
const navigate = useNavigate()
async function startDemo() {
await loginMutation.mutateAsync({
username: DEMO_USER.username,
password: DEMO_USER.unhashedPassword,
})
navigate({ to: "/bookmarks" })
}
return (
<button
type="button"
onClick={startDemo}
className="underline active:bg-stone-700 dark:active:bg-stone-200 dark:active:text-stone-800 active:text-stone-200 text-left"
>
DEMO
</button>
)
}
export const Route = createFileRoute("/")({
component: Index,
})

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

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

@@ -0,0 +1,18 @@
import clsx from "clsx"
function Button({
className,
...props
}: React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>) {
return (
<button
className={clsx(
"px-4 font-bold border border-2 border-b-4 border-text-inherit active:bg-stone-700 active:text-stone-200 active:border-b-1 active:translate-y-0.5",
className,
)}
{...props}
/>
)
}
export { Button }

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

2
packages/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/react" />

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

@@ -0,0 +1,57 @@
import path from "node:path"
import tailwindcss from "@tailwindcss/vite"
import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
import react from "@vitejs/plugin-react"
import { defineConfig } from "vite"
import { VitePWA } from "vite-plugin-pwa"
// https://vitejs.dev/config/
export default defineConfig({
resolve: {
alias: {
"~": path.resolve(__dirname, "./src"),
},
},
plugins: [
TanStackRouterVite({
target: "react",
autoCodeSplitting: false,
routesDirectory: "./src/app",
generatedRouteTree: "./src/app/-route-tree.gen.ts",
routeFileIgnorePrefix: "-",
quoteStyle: "double",
}),
react(),
VitePWA({
registerType: "prompt",
injectRegister: false,
pwaAssets: {
disabled: false,
config: true,
},
manifest: {
name: "MarkOne",
short_name: "MarkOne",
description: "A minimal bookmark manager",
theme_color: "#ffffff",
},
workbox: {
globPatterns: ["**/*.{js,css,html,svg,png,ico}"],
cleanupOutdatedCaches: true,
clientsClaim: true,
},
devOptions: {
enabled: false,
navigateFallback: "index.html",
suppressWarnings: true,
type: "module",
},
}),
tailwindcss(),
],
})