phase0: wallabag-compat API + PWA frontend
- Wallabag v2 API compatible (OAuth2, entries CRUD, tags) - Express + SQLite (better-sqlite3), zero extra deps - Gated web UI with session auth - PWA: service worker, manifest, offline support - Mobile-first design, dark mode, FAB + modal
This commit is contained in:
commit
1adf88d195
15
.env.example
Normal file
15
.env.example
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
PORT=3000
|
||||||
|
DATABASE_PATH=./data/bagg.sqlite
|
||||||
|
|
||||||
|
# Initial admin user (created on first run if not exists)
|
||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=changeme
|
||||||
|
|
||||||
|
# OAuth2 client credentials for iOS/Safari Wallabag app
|
||||||
|
# Set these in the iOS app settings:
|
||||||
|
# Server URL: https://your-domain
|
||||||
|
# Username/Password: your credentials above
|
||||||
|
# Client ID: bagg_ios
|
||||||
|
# Client Secret: bagg_secret
|
||||||
|
ADMIN_CLIENT_ID=bagg_ios
|
||||||
|
ADMIN_CLIENT_SECRET=bagg_secret
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite-wal
|
||||||
|
*.sqlite-shm
|
||||||
|
.env
|
||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
FROM node:22-bookworm-slim
|
||||||
|
|
||||||
|
# Build tools for better-sqlite3 native compilation
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /data
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV DATABASE_PATH=/data/bagg.sqlite
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
383
db.js
Normal file
383
db.js
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const DB_PATH = process.env.DATABASE_PATH || path.join(__dirname, 'data', 'bagg.sqlite');
|
||||||
|
|
||||||
|
// Ensure data dir exists
|
||||||
|
const fs = require('fs');
|
||||||
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||||
|
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
// ── Schema ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
salt TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS web_sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
client_id TEXT NOT NULL UNIQUE,
|
||||||
|
client_secret TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
access_token TEXT NOT NULL UNIQUE,
|
||||||
|
refresh_token TEXT UNIQUE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS entries (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
hashed_url TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
content TEXT,
|
||||||
|
domain_name TEXT,
|
||||||
|
preview_picture TEXT,
|
||||||
|
language TEXT,
|
||||||
|
reading_time INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_archived INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_starred INTEGER NOT NULL DEFAULT 0,
|
||||||
|
published_at INTEGER,
|
||||||
|
archived_at INTEGER,
|
||||||
|
starred_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entries_user_id ON entries(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entries_is_archived ON entries(user_id, is_archived);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entries_is_starred ON entries(user_id, is_starred);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entries_hashed_url ON entries(user_id, hashed_url);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tags (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
UNIQUE(user_id, slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS entries_tags (
|
||||||
|
entry_id INTEGER NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
|
||||||
|
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (entry_id, tag_id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Seed: create default user + client from env ───────────────────────────────
|
||||||
|
|
||||||
|
function hashPassword(password, salt) {
|
||||||
|
return crypto.scryptSync(password, salt, 64).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyPassword(password, hash, salt) {
|
||||||
|
return crypto.timingSafeEqual(
|
||||||
|
Buffer.from(hashPassword(password, salt), 'hex'),
|
||||||
|
Buffer.from(hash, 'hex')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomToken() {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(str) {
|
||||||
|
return str.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed admin user if ADMIN_USERNAME/ADMIN_PASSWORD set and no users exist
|
||||||
|
const adminUsername = process.env.ADMIN_USERNAME;
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||||
|
|
||||||
|
if (adminUsername && adminPassword) {
|
||||||
|
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(adminUsername);
|
||||||
|
if (!existing) {
|
||||||
|
const salt = crypto.randomBytes(16).toString('hex');
|
||||||
|
const hash = hashPassword(adminPassword, salt);
|
||||||
|
db.prepare('INSERT OR IGNORE INTO users (username, password_hash, salt) VALUES (?, ?, ?)')
|
||||||
|
.run(adminUsername, hash, salt);
|
||||||
|
console.log(`[db] Created admin user: ${adminUsername}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed default OAuth2 client
|
||||||
|
const clientId = process.env.ADMIN_CLIENT_ID || 'bagg_ios';
|
||||||
|
const clientSecret = process.env.ADMIN_CLIENT_SECRET || 'bagg_secret';
|
||||||
|
db.prepare('INSERT OR IGNORE INTO oauth_clients (client_id, client_secret) VALUES (?, ?)')
|
||||||
|
.run(clientId, clientSecret);
|
||||||
|
|
||||||
|
// ── User queries ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getUserByUsername(username) {
|
||||||
|
return db.prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserById(id) {
|
||||||
|
return db.prepare('SELECT id, username, created_at FROM users WHERE id = ?').get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Web session queries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createWebSession(userId) {
|
||||||
|
const token = randomToken();
|
||||||
|
const expiresAt = Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 30; // 30 days
|
||||||
|
db.prepare('INSERT INTO web_sessions (token, user_id, expires_at) VALUES (?, ?, ?)')
|
||||||
|
.run(token, userId, expiresAt);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWebSession(token) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return db.prepare(
|
||||||
|
'SELECT * FROM web_sessions WHERE token = ? AND expires_at > ?'
|
||||||
|
).get(token, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteWebSession(token) {
|
||||||
|
db.prepare('DELETE FROM web_sessions WHERE token = ?').run(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OAuth client queries ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getOAuthClient(clientId, clientSecret) {
|
||||||
|
return db.prepare(
|
||||||
|
'SELECT * FROM oauth_clients WHERE client_id = ? AND client_secret = ?'
|
||||||
|
).get(clientId, clientSecret);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── OAuth token queries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function createOAuthToken(accessToken, refreshToken, userId) {
|
||||||
|
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO oauth_tokens (access_token, refresh_token, user_id, expires_at) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(accessToken, refreshToken, userId, expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenByAccessToken(token) {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
return db.prepare(
|
||||||
|
'SELECT * FROM oauth_tokens WHERE access_token = ? AND expires_at > ?'
|
||||||
|
).get(token, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTokenByRefreshToken(token) {
|
||||||
|
return db.prepare(
|
||||||
|
'SELECT * FROM oauth_tokens WHERE refresh_token = ?'
|
||||||
|
).get(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateOAuthToken(oldId, newAccess, newRefresh) {
|
||||||
|
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
|
||||||
|
db.prepare('DELETE FROM oauth_tokens WHERE id = ?').run(oldId);
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO oauth_tokens (access_token, refresh_token, user_id, expires_at) SELECT ?, ?, user_id, ? FROM oauth_tokens WHERE id = ?'
|
||||||
|
).run(newAccess, newRefresh, expiresAt, oldId);
|
||||||
|
// oldId is gone, re-insert with the user_id from the deleted row -- use a different approach
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix: keep user_id available before deletion
|
||||||
|
function rotateOAuthTokenFix(oldToken) {
|
||||||
|
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
|
||||||
|
const newAccess = randomToken();
|
||||||
|
const newRefresh = randomToken();
|
||||||
|
db.prepare('DELETE FROM oauth_tokens WHERE id = ?').run(oldToken.id);
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO oauth_tokens (access_token, refresh_token, user_id, expires_at) VALUES (?, ?, ?, ?)'
|
||||||
|
).run(newAccess, newRefresh, oldToken.user_id, expiresAt);
|
||||||
|
return { access_token: newAccess, refresh_token: newRefresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entry queries ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function hashUrl(url) {
|
||||||
|
return crypto.createHash('sha1').update(url).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDomain(url) {
|
||||||
|
try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const GET_ENTRIES = db.prepare(`
|
||||||
|
SELECT * FROM entries
|
||||||
|
WHERE user_id = :userId
|
||||||
|
AND (:archive IS NULL OR is_archived = :archive)
|
||||||
|
AND (:starred IS NULL OR is_starred = :starred)
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN :sort = 'created' AND :order = 'desc' THEN created_at END DESC,
|
||||||
|
CASE WHEN :sort = 'created' AND :order = 'asc' THEN created_at END ASC,
|
||||||
|
CASE WHEN :sort = 'updated' AND :order = 'desc' THEN updated_at END DESC,
|
||||||
|
CASE WHEN :sort = 'updated' AND :order = 'asc' THEN updated_at END ASC,
|
||||||
|
CASE WHEN :sort = 'id' AND :order = 'desc' THEN id END DESC,
|
||||||
|
CASE WHEN :sort = 'id' AND :order = 'asc' THEN id END ASC,
|
||||||
|
created_at DESC
|
||||||
|
LIMIT :limit OFFSET :offset
|
||||||
|
`);
|
||||||
|
|
||||||
|
const COUNT_ENTRIES = db.prepare(`
|
||||||
|
SELECT COUNT(*) as count FROM entries
|
||||||
|
WHERE user_id = :userId
|
||||||
|
AND (:archive IS NULL OR is_archived = :archive)
|
||||||
|
AND (:starred IS NULL OR is_starred = :starred)
|
||||||
|
`);
|
||||||
|
|
||||||
|
function getEntries(userId, { page = 1, perPage = 30, archive, starred, sort = 'created', order = 'desc' } = {}) {
|
||||||
|
const archiveVal = archive !== undefined ? (archive === '1' || archive === 1 ? 1 : 0) : null;
|
||||||
|
const starredVal = starred !== undefined ? (starred === '1' || starred === 1 ? 1 : 0) : null;
|
||||||
|
const params = {
|
||||||
|
userId,
|
||||||
|
archive: archiveVal,
|
||||||
|
starred: starredVal,
|
||||||
|
sort,
|
||||||
|
order,
|
||||||
|
limit: +perPage,
|
||||||
|
offset: (+page - 1) * +perPage
|
||||||
|
};
|
||||||
|
const items = GET_ENTRIES.all(params);
|
||||||
|
const { count } = COUNT_ENTRIES.get(params);
|
||||||
|
return { items, total: count };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEntryById(id, userId) {
|
||||||
|
return db.prepare('SELECT * FROM entries WHERE id = ? AND user_id = ?').get(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryExistsByUrl(url, userId) {
|
||||||
|
const hash = hashUrl(url);
|
||||||
|
return db.prepare('SELECT id FROM entries WHERE hashed_url = ? AND user_id = ?').get(hash, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEntry(userId, { url, title, content, is_archived = 0, is_starred = 0, tags }) {
|
||||||
|
const domain = extractDomain(url);
|
||||||
|
const hash = hashUrl(url);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const result = db.prepare(`
|
||||||
|
INSERT INTO entries (user_id, url, hashed_url, title, content, domain_name, is_archived, is_starred, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(userId, url, hash, title || null, content || null, domain, +!!is_archived, +!!is_starred, now, now);
|
||||||
|
return getEntryById(result.lastInsertRowid, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEntry(id, userId, fields) {
|
||||||
|
const entry = getEntryById(id, userId);
|
||||||
|
if (!entry) return null;
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const updates = [];
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
if (fields.title !== undefined) { updates.push('title = ?'); values.push(fields.title); }
|
||||||
|
if (fields.content !== undefined) { updates.push('content = ?'); values.push(fields.content); }
|
||||||
|
if (fields.language !== undefined) { updates.push('language = ?'); values.push(fields.language); }
|
||||||
|
if (fields.is_archived !== undefined) {
|
||||||
|
updates.push('is_archived = ?');
|
||||||
|
values.push(+!!fields.is_archived);
|
||||||
|
updates.push('archived_at = ?');
|
||||||
|
values.push(fields.is_archived ? now : null);
|
||||||
|
}
|
||||||
|
if (fields.is_starred !== undefined) {
|
||||||
|
updates.push('is_starred = ?');
|
||||||
|
values.push(+!!fields.is_starred);
|
||||||
|
updates.push('starred_at = ?');
|
||||||
|
values.push(fields.is_starred ? now : null);
|
||||||
|
}
|
||||||
|
if (updates.length === 0) return entry;
|
||||||
|
|
||||||
|
updates.push('updated_at = ?');
|
||||||
|
values.push(now);
|
||||||
|
values.push(id, userId);
|
||||||
|
|
||||||
|
db.prepare(`UPDATE entries SET ${updates.join(', ')} WHERE id = ? AND user_id = ?`).run(...values);
|
||||||
|
return getEntryById(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteEntry(id, userId) {
|
||||||
|
return db.prepare('DELETE FROM entries WHERE id = ? AND user_id = ?').run(id, userId).changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tag queries ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getTagsForEntry(entryId) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT t.* FROM tags t
|
||||||
|
JOIN entries_tags et ON et.tag_id = t.id
|
||||||
|
WHERE et.entry_id = ?
|
||||||
|
`).all(entryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllTags(userId) {
|
||||||
|
return db.prepare('SELECT * FROM tags WHERE user_id = ? ORDER BY label').all(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrCreateTag(userId, label) {
|
||||||
|
const slug = slugify(label);
|
||||||
|
const existing = db.prepare('SELECT * FROM tags WHERE user_id = ? AND slug = ?').get(userId, slug);
|
||||||
|
if (existing) return existing;
|
||||||
|
const result = db.prepare('INSERT INTO tags (user_id, label, slug) VALUES (?, ?, ?)').run(userId, label, slug);
|
||||||
|
return { id: result.lastInsertRowid, user_id: userId, label, slug };
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTagToEntry(entryId, tagId) {
|
||||||
|
db.prepare('INSERT OR IGNORE INTO entries_tags (entry_id, tag_id) VALUES (?, ?)').run(entryId, tagId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTagFromEntry(entryId, tagId) {
|
||||||
|
return db.prepare('DELETE FROM entries_tags WHERE entry_id = ? AND tag_id = ?').run(entryId, tagId).changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Exports ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
randomToken,
|
||||||
|
getUserByUsername,
|
||||||
|
getUserById,
|
||||||
|
createWebSession,
|
||||||
|
getWebSession,
|
||||||
|
deleteWebSession,
|
||||||
|
getOAuthClient,
|
||||||
|
createOAuthToken,
|
||||||
|
getTokenByAccessToken,
|
||||||
|
getTokenByRefreshToken,
|
||||||
|
rotateOAuthTokenFix,
|
||||||
|
getEntries,
|
||||||
|
getEntryById,
|
||||||
|
entryExistsByUrl,
|
||||||
|
createEntry,
|
||||||
|
updateEntry,
|
||||||
|
deleteEntry,
|
||||||
|
getTagsForEntry,
|
||||||
|
getAllTags,
|
||||||
|
getOrCreateTag,
|
||||||
|
addTagToEntry,
|
||||||
|
removeTagFromEntry,
|
||||||
|
hashUrl,
|
||||||
|
};
|
||||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: node:22-bookworm-slim
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
PORT: "3000"
|
||||||
|
DATABASE_PATH: /data/bagg.sqlite
|
||||||
|
NODE_ENV: production
|
||||||
|
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
|
||||||
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-changeme_please}
|
||||||
|
ADMIN_CLIENT_ID: ${ADMIN_CLIENT_ID:-bagg_ios}
|
||||||
|
ADMIN_CLIENT_SECRET: ${ADMIN_CLIENT_SECRET:-bagg_secret}
|
||||||
|
labels:
|
||||||
|
fibe.gg/repo_url: https://git-next.fibe.live/viktorvsk/bagg
|
||||||
|
fibe.gg/source_mount: /app
|
||||||
|
fibe.gg/start_command: >
|
||||||
|
sh -c "apt-get update -qq && apt-get install -y --no-install-recommends python3 make g++ 2>/dev/null;
|
||||||
|
npm install --omit=dev --silent &&
|
||||||
|
node server.js"
|
||||||
|
fibe.gg/expose: external:3000
|
||||||
|
fibe.gg/production: "false"
|
||||||
|
fibe.gg/healthcheck_path: /api/v1/version
|
||||||
|
fibe.gg/healthcheck_interval: 15s
|
||||||
|
fibe.gg/healthcheck_timeout: 5s
|
||||||
|
fibe.gg/healthcheck_retries: "5"
|
||||||
|
fibe.gg/healthcheck_start_period: 90s
|
||||||
|
volumes:
|
||||||
|
- bagg-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
bagg-data:
|
||||||
|
|
||||||
|
x-fibe.gg:
|
||||||
|
metadata:
|
||||||
|
description: Wallabag-compatible read-later service with PWA frontend
|
||||||
|
category: Web
|
||||||
14
package.json
Normal file
14
package.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "bagg",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Wallabag-compatible read-later service",
|
||||||
|
"main": "server.js",
|
||||||
|
"engines": { "node": ">=20" },
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^9.6.0",
|
||||||
|
"express": "^4.19.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
383
public/app.js
Normal file
383
public/app.js
Normal file
@ -0,0 +1,383 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ── Theme ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const savedTheme = localStorage.getItem('bagg_theme');
|
||||||
|
if (savedTheme) document.documentElement.setAttribute('data-theme', savedTheme);
|
||||||
|
|
||||||
|
function isDark() {
|
||||||
|
const t = document.documentElement.getAttribute('data-theme');
|
||||||
|
if (t === 'dark') return true;
|
||||||
|
if (t === 'light') return false;
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThemeIcons() {
|
||||||
|
const dark = isDark();
|
||||||
|
document.getElementById('icon-sun').style.display = dark ? 'block' : 'none';
|
||||||
|
document.getElementById('icon-moon').style.display = dark ? 'none' : 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-theme').addEventListener('click', () => {
|
||||||
|
const next = isDark() ? 'light' : 'dark';
|
||||||
|
document.documentElement.setAttribute('data-theme', next);
|
||||||
|
localStorage.setItem('bagg_theme', next);
|
||||||
|
applyThemeIcons();
|
||||||
|
});
|
||||||
|
|
||||||
|
applyThemeIcons();
|
||||||
|
|
||||||
|
// ── Offline detection ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const offlineBanner = document.getElementById('offline-banner');
|
||||||
|
function updateOnlineStatus() {
|
||||||
|
offlineBanner.classList.toggle('show', !navigator.onLine);
|
||||||
|
}
|
||||||
|
window.addEventListener('online', updateOnlineStatus);
|
||||||
|
window.addEventListener('offline', updateOnlineStatus);
|
||||||
|
updateOnlineStatus();
|
||||||
|
|
||||||
|
// ── Toast ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showToast(msg, duration = 2500) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'toast';
|
||||||
|
el.textContent = msg;
|
||||||
|
document.getElementById('toasts').appendChild(el);
|
||||||
|
setTimeout(() => el.remove(), duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
filter: 'all', // all | unread | starred | archived
|
||||||
|
page: 1,
|
||||||
|
pages: 1,
|
||||||
|
total: 0,
|
||||||
|
entries: [],
|
||||||
|
loading: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Logout ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.getElementById('btn-logout').addEventListener('click', async () => {
|
||||||
|
await fetch('/web/logout', { method: 'POST' });
|
||||||
|
location.href = '/login.html';
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Tab switching ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
state.filter = btn.dataset.filter;
|
||||||
|
state.page = 1;
|
||||||
|
loadEntries();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── API helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function apiFetch(path, opts = {}) {
|
||||||
|
const r = await fetch(path, {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
if (r.status === 204) return null;
|
||||||
|
const data = await r.json();
|
||||||
|
if (!r.ok) throw new Error(data.error || `HTTP ${r.status}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load entries ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadEntries() {
|
||||||
|
if (state.loading) return;
|
||||||
|
state.loading = true;
|
||||||
|
renderEntries();
|
||||||
|
|
||||||
|
const params = new URLSearchParams({ page: state.page, perPage: 50 });
|
||||||
|
if (state.filter === 'unread') { params.set('archive', '0'); }
|
||||||
|
if (state.filter === 'archived') { params.set('archive', '1'); }
|
||||||
|
if (state.filter === 'starred') { params.set('starred', '1'); }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiFetch(`/web/api/entries?${params}`);
|
||||||
|
state.entries = data.items;
|
||||||
|
state.page = data.page;
|
||||||
|
state.pages = data.pages;
|
||||||
|
state.total = data.total;
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Failed to load entries');
|
||||||
|
}
|
||||||
|
|
||||||
|
state.loading = false;
|
||||||
|
renderEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function timeAgo(isoStr) {
|
||||||
|
const secs = Math.floor((Date.now() - new Date(isoStr)) / 1000);
|
||||||
|
if (secs < 60) return 'just now';
|
||||||
|
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
|
||||||
|
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
|
||||||
|
if (secs < 86400 * 30) return `${Math.floor(secs / 86400)}d ago`;
|
||||||
|
return new Date(isoStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function faviconUrl(domain) {
|
||||||
|
if (!domain) return null;
|
||||||
|
return `https://www.google.com/s2/favicons?sz=32&domain=${encodeURIComponent(domain)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryHTML(e) {
|
||||||
|
const domain = e.domain_name || '';
|
||||||
|
const title = e.title || e.url;
|
||||||
|
const fav = faviconUrl(domain);
|
||||||
|
const favHTML = fav
|
||||||
|
? `<img src="${fav}" alt="" loading="lazy" onerror="this.style.display='none'">`
|
||||||
|
: domain.slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="entry-card ${e.is_archived ? 'archived' : ''}" data-id="${e.id}">
|
||||||
|
<div class="entry-favicon">${favHTML}</div>
|
||||||
|
<div class="entry-body">
|
||||||
|
<div class="entry-title"
|
||||||
|
title="${escHtml(title)}"
|
||||||
|
onclick="window.open('${escHtml(e.url)}','_blank','noopener')"
|
||||||
|
style="cursor:pointer"
|
||||||
|
>${escHtml(title)}</div>
|
||||||
|
<div class="entry-meta">
|
||||||
|
${domain ? `<span class="entry-domain">${escHtml(domain)}</span>` : ''}
|
||||||
|
<span>${timeAgo(e.created_at)}</span>
|
||||||
|
${e.reading_time > 0 ? `<span>${e.reading_time} min</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="entry-actions">
|
||||||
|
<button class="action-btn ${e.is_starred ? 'active-star' : ''}"
|
||||||
|
data-action="star" data-id="${e.id}"
|
||||||
|
title="${e.is_starred ? 'Unstar' : 'Star'}"
|
||||||
|
aria-label="${e.is_starred ? 'Unstar' : 'Star'}">
|
||||||
|
<svg width="15" height="15" fill="${e.is_starred ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn ${e.is_archived ? 'active-archive' : ''}"
|
||||||
|
data-action="archive" data-id="${e.id}"
|
||||||
|
title="${e.is_archived ? 'Unarchive' : 'Archive'}"
|
||||||
|
aria-label="${e.is_archived ? 'Unarchive' : 'Archive'}">
|
||||||
|
<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<polyline points="21 8 21 21 3 21 3 8"/><rect x="1" y="3" width="22" height="5"/><line x1="10" y1="12" x2="14" y2="12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn danger"
|
||||||
|
data-action="delete" data-id="${e.id}"
|
||||||
|
title="Delete" aria-label="Delete">
|
||||||
|
<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
|
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEntries() {
|
||||||
|
const container = document.getElementById('entries-container');
|
||||||
|
|
||||||
|
if (state.loading) {
|
||||||
|
container.innerHTML = `<div class="loading"><div class="spinner"></div><p>Loading…</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.entries.length === 0) {
|
||||||
|
const msgs = {
|
||||||
|
all: ['No links saved yet.', 'Tap + to save your first link.'],
|
||||||
|
unread: ['Nothing to read.', 'Links you haven\'t archived appear here.'],
|
||||||
|
starred: ['No starred links.', 'Star important links to find them quickly.'],
|
||||||
|
archived: ['Nothing archived.', 'Archived links live here.'],
|
||||||
|
};
|
||||||
|
const [title, sub] = msgs[state.filter] || msgs.all;
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<svg width="48" height="48" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||||
|
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
|
||||||
|
</svg>
|
||||||
|
<p>${title}</p>
|
||||||
|
<small>${sub}</small>
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="entries-list">' + state.entries.map(entryHTML).join('') + '</div>';
|
||||||
|
|
||||||
|
if (state.pages > 1) {
|
||||||
|
html += `
|
||||||
|
<div class="pagination">
|
||||||
|
<button class="page-btn" id="btn-prev" ${state.page <= 1 ? 'disabled' : ''}>Prev</button>
|
||||||
|
<span class="page-info">${state.page} / ${state.pages}</span>
|
||||||
|
<button class="page-btn" id="btn-next" ${state.page >= state.pages ? 'disabled' : ''}>Next</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
container.querySelector('#btn-prev')?.addEventListener('click', () => { state.page--; loadEntries(); });
|
||||||
|
container.querySelector('#btn-next')?.addEventListener('click', () => { state.page++; loadEntries(); });
|
||||||
|
|
||||||
|
// Entry actions (event delegation)
|
||||||
|
container.addEventListener('click', handleEntryAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entry actions ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleEntryAction(e) {
|
||||||
|
const btn = e.target.closest('[data-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
const id = +btn.dataset.id;
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
const entry = state.entries.find(en => en.id === id);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
if (action === 'star') {
|
||||||
|
try {
|
||||||
|
const updated = await apiFetch(`/web/api/entries/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ is_starred: entry.is_starred ? 0 : 1 }),
|
||||||
|
});
|
||||||
|
Object.assign(entry, updated);
|
||||||
|
renderEntries();
|
||||||
|
} catch { showToast('Failed to update'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'archive') {
|
||||||
|
try {
|
||||||
|
const updated = await apiFetch(`/web/api/entries/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ is_archived: entry.is_archived ? 0 : 1 }),
|
||||||
|
});
|
||||||
|
Object.assign(entry, updated);
|
||||||
|
// Remove from current view if filter doesn't match
|
||||||
|
if (state.filter === 'unread' && updated.is_archived) state.entries = state.entries.filter(en => en.id !== id);
|
||||||
|
if (state.filter === 'archived' && !updated.is_archived) state.entries = state.entries.filter(en => en.id !== id);
|
||||||
|
renderEntries();
|
||||||
|
showToast(updated.is_archived ? 'Archived' : 'Moved to unread');
|
||||||
|
} catch { showToast('Failed to update'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'delete') {
|
||||||
|
if (!confirm('Delete this link?')) return;
|
||||||
|
try {
|
||||||
|
await apiFetch(`/web/api/entries/${id}`, { method: 'DELETE' });
|
||||||
|
state.entries = state.entries.filter(en => en.id !== id);
|
||||||
|
renderEntries();
|
||||||
|
showToast('Deleted');
|
||||||
|
} catch { showToast('Failed to delete'); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add link modal ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const modalAdd = document.getElementById('modal-add');
|
||||||
|
const inputUrl = document.getElementById('input-url');
|
||||||
|
const inputTitle = document.getElementById('input-title');
|
||||||
|
|
||||||
|
document.getElementById('btn-add').addEventListener('click', () => {
|
||||||
|
modalAdd.classList.add('open');
|
||||||
|
inputUrl.value = '';
|
||||||
|
inputTitle.value = '';
|
||||||
|
setTimeout(() => inputUrl.focus(), 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-modal-cancel').addEventListener('click', closeModal);
|
||||||
|
|
||||||
|
modalAdd.addEventListener('click', e => {
|
||||||
|
if (e.target === modalAdd) closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Escape') closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modalAdd.classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-modal-save').addEventListener('click', saveLink);
|
||||||
|
|
||||||
|
inputUrl.addEventListener('keydown', e => { if (e.key === 'Enter') saveLink(); });
|
||||||
|
|
||||||
|
async function saveLink() {
|
||||||
|
const url = inputUrl.value.trim();
|
||||||
|
const title = inputTitle.value.trim();
|
||||||
|
if (!url) { inputUrl.focus(); return; }
|
||||||
|
|
||||||
|
// Basic URL sanity
|
||||||
|
try { new URL(url); } catch {
|
||||||
|
showToast('Invalid URL');
|
||||||
|
inputUrl.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('btn-modal-save');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Saving…';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entry = await apiFetch('/web/api/entries', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ url, title: title || undefined }),
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
showToast('Saved!');
|
||||||
|
// Prepend to list if on 'all' or 'unread' filter
|
||||||
|
if (state.filter === 'all' || state.filter === 'unread') {
|
||||||
|
state.entries.unshift(entry);
|
||||||
|
state.total++;
|
||||||
|
renderEntries();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message?.includes('Already')) {
|
||||||
|
showToast('Already saved');
|
||||||
|
closeModal();
|
||||||
|
} else {
|
||||||
|
showToast('Failed to save');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Save';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Service Worker ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PWA install prompt ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let deferredPrompt = null;
|
||||||
|
window.addEventListener('beforeinstallprompt', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
deferredPrompt = e;
|
||||||
|
// Show subtle install hint after a delay
|
||||||
|
setTimeout(() => {
|
||||||
|
if (deferredPrompt) showToast('Tip: add bagg to your home screen', 4000);
|
||||||
|
}, 8000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
loadEntries();
|
||||||
89
public/index.html
Normal file
89
public/index.html
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
|
<title>bagg</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<meta name="theme-color" content="#2563eb" id="theme-color-meta">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="bagg">
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="offline-banner" id="offline-banner">You're offline — showing cached data</div>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="navbar-inner">
|
||||||
|
<span class="nav-brand">bagg</span>
|
||||||
|
<button class="nav-btn" id="btn-theme" title="Toggle theme" aria-label="Toggle theme">
|
||||||
|
<svg id="icon-sun" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||||
|
<svg id="icon-moon" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="display:none"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" id="btn-logout" title="Sign out" aria-label="Sign out">
|
||||||
|
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<div class="tabs-inner">
|
||||||
|
<button class="tab-btn active" data-filter="all">All</button>
|
||||||
|
<button class="tab-btn" data-filter="unread">Unread</button>
|
||||||
|
<button class="tab-btn" data-filter="starred">Starred</button>
|
||||||
|
<button class="tab-btn" data-filter="archived">Archived</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<div id="entries-container">
|
||||||
|
<div class="loading">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FAB -->
|
||||||
|
<button class="fab" id="btn-add" title="Save a link" aria-label="Save a link">
|
||||||
|
<svg width="22" height="22" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Add link modal -->
|
||||||
|
<div class="modal-overlay" id="modal-add" role="dialog" aria-modal="true" aria-label="Save a link">
|
||||||
|
<div class="modal">
|
||||||
|
<h2>Save a link</h2>
|
||||||
|
<label for="input-url">URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="input-url"
|
||||||
|
placeholder="https://example.com/article"
|
||||||
|
inputmode="url"
|
||||||
|
autocomplete="url"
|
||||||
|
autocapitalize="none"
|
||||||
|
autocorrect="off"
|
||||||
|
>
|
||||||
|
<label for="input-title">Title <span style="color:var(--text3);font-weight:400">(optional)</span></label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="input-title"
|
||||||
|
placeholder="Leave empty to use page title"
|
||||||
|
>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn-secondary" id="btn-modal-cancel">Cancel</button>
|
||||||
|
<button class="btn-primary-sm" id="btn-modal-save">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast container -->
|
||||||
|
<div class="toast-container" id="toasts"></div>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
61
public/login.html
Normal file
61
public/login.html
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Sign in — bagg</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<meta name="theme-color" content="#2563eb">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="bagg">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<h1>bagg</h1>
|
||||||
|
<p class="tagline">Read later, actually.</p>
|
||||||
|
|
||||||
|
<div id="error-msg" class="login-error" style="display:none">
|
||||||
|
Wrong username or password.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/web/login">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
autocomplete="username"
|
||||||
|
autocapitalize="none"
|
||||||
|
autocorrect="off"
|
||||||
|
spellcheck="false"
|
||||||
|
required
|
||||||
|
autofocus
|
||||||
|
>
|
||||||
|
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
required
|
||||||
|
>
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary">Sign in</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
if (location.search.includes('error=1')) {
|
||||||
|
document.getElementById('error-msg').style.display = 'block';
|
||||||
|
}
|
||||||
|
// Detect dark mode pref stored in localStorage
|
||||||
|
const theme = localStorage.getItem('bagg_theme');
|
||||||
|
if (theme) document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
33
public/manifest.json
Normal file
33
public/manifest.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "bagg",
|
||||||
|
"short_name": "bagg",
|
||||||
|
"description": "Read later, actually.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"background_color": "#f8f8f6",
|
||||||
|
"theme_color": "#2563eb",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%232563eb'/%3E%3Cpath d='M30 20h40a5 5 0 0 1 5 5v50a5 5 0 0 1-5 5H30a5 5 0 0 1-5-5V25a5 5 0 0 1 5-5z' fill='white' opacity='.9'/%3E%3Cline x1='35' y1='38' x2='65' y2='38' stroke='%232563eb' stroke-width='4' stroke-linecap='round'/%3E%3Cline x1='35' y1='50' x2='65' y2='50' stroke='%232563eb' stroke-width='4' stroke-linecap='round'/%3E%3Cline x1='35' y1='62' x2='52' y2='62' stroke='%232563eb' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [],
|
||||||
|
"categories": ["productivity", "utilities"],
|
||||||
|
"prefer_related_applications": false
|
||||||
|
}
|
||||||
513
public/style.css
Normal file
513
public/style.css
Normal file
@ -0,0 +1,513 @@
|
|||||||
|
/* ── Variables ─────────────────────────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--bg: #f8f8f6;
|
||||||
|
--bg2: #ffffff;
|
||||||
|
--bg3: #eeecea;
|
||||||
|
--border: #e0ddd8;
|
||||||
|
--text: #1a1916;
|
||||||
|
--text2: #6b6860;
|
||||||
|
--text3: #9b9890;
|
||||||
|
--accent: #2563eb;
|
||||||
|
--accent-h: #1d4ed8;
|
||||||
|
--danger: #dc2626;
|
||||||
|
--star: #f59e0b;
|
||||||
|
--radius: 10px;
|
||||||
|
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.05);
|
||||||
|
--shadow-lg: 0 10px 25px rgba(0,0,0,.12);
|
||||||
|
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--transition: .15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--bg: #141413;
|
||||||
|
--bg2: #1e1e1c;
|
||||||
|
--bg3: #2a2a28;
|
||||||
|
--border: #333330;
|
||||||
|
--text: #f0ede8;
|
||||||
|
--text2: #9b9890;
|
||||||
|
--text3: #6b6860;
|
||||||
|
--shadow: 0 1px 3px rgba(0,0,0,.3), 0 1px 2px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
--bg: #141413;
|
||||||
|
--bg2: #1e1e1c;
|
||||||
|
--bg3: #2a2a28;
|
||||||
|
--border: #333330;
|
||||||
|
--text: #f0ede8;
|
||||||
|
--text2: #9b9890;
|
||||||
|
--text3: #6b6860;
|
||||||
|
--shadow: 0 1px 3px rgba(0,0,0,.3), 0 1px 2px rgba(0,0,0,.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Reset ─────────────────────────────────────────────────────────────────── */
|
||||||
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html { font-size: 16px; -webkit-text-size-adjust: 100%; }
|
||||||
|
body {
|
||||||
|
font-family: var(--font);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100dvh;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: var(--font);
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font-family: var(--font);
|
||||||
|
font-size: 1rem;
|
||||||
|
background: var(--bg2);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .5rem .75rem;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color var(--transition);
|
||||||
|
}
|
||||||
|
input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Login Page ────────────────────────────────────────────────────────────── */
|
||||||
|
.login-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: 2.5rem 2rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -.03em;
|
||||||
|
margin-bottom: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card .tagline {
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: .9rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card label {
|
||||||
|
display: block;
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text2);
|
||||||
|
margin-bottom: .35rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card label:first-of-type { margin-top: 0; }
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .65rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
transition: background var(--transition);
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
.btn-primary:hover { background: var(--accent-h); }
|
||||||
|
|
||||||
|
.login-error {
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
color: var(--danger);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .6rem .85rem;
|
||||||
|
font-size: .875rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .login-error {
|
||||||
|
background: #3b1515;
|
||||||
|
border-color: #7f1d1d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── App Layout ────────────────────────────────────────────────────────────── */
|
||||||
|
#app { display: flex; flex-direction: column; min-height: 100dvh; }
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
background: var(--bg2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-inner {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: 52px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-brand {
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
letter-spacing: -.03em;
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text2);
|
||||||
|
transition: background var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
.nav-btn:hover { background: var(--bg3); color: var(--text); }
|
||||||
|
|
||||||
|
/* ── Filter tabs ───────────────────────────────────────────────────────────── */
|
||||||
|
.tabs {
|
||||||
|
background: var(--bg2);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.tabs::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.tabs-inner {
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: .65rem 1rem;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text2);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: color var(--transition), border-color var(--transition);
|
||||||
|
}
|
||||||
|
.tab-btn:hover { color: var(--text); }
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Entry list ────────────────────────────────────────────────────────────── */
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: .75rem 1rem;
|
||||||
|
max-width: 720px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entries-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-card {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .85rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: .75rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: box-shadow var(--transition);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.entry-card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.1); }
|
||||||
|
.entry-card.archived { opacity: .55; }
|
||||||
|
|
||||||
|
.entry-favicon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
background: var(--bg3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: .65rem;
|
||||||
|
color: var(--text3);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.entry-favicon img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
|
.entry-body { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
.entry-title {
|
||||||
|
font-size: .9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.35;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-bottom: .2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-meta {
|
||||||
|
font-size: .78rem;
|
||||||
|
color: var(--text3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-domain { color: var(--accent); font-weight: 500; }
|
||||||
|
|
||||||
|
.entry-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .15rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 7px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text3);
|
||||||
|
transition: background var(--transition), color var(--transition);
|
||||||
|
font-size: .8rem;
|
||||||
|
}
|
||||||
|
.action-btn:hover { background: var(--bg3); color: var(--text); }
|
||||||
|
.action-btn.active-star { color: var(--star); }
|
||||||
|
.action-btn.active-archive { color: var(--accent); }
|
||||||
|
.action-btn.danger:hover { color: var(--danger); background: #fef2f2; }
|
||||||
|
|
||||||
|
[data-theme="dark"] .action-btn.danger:hover { background: #3b1515; }
|
||||||
|
|
||||||
|
/* ── Empty state ───────────────────────────────────────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 1rem;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
.empty-state svg { margin-bottom: 1rem; opacity: .4; }
|
||||||
|
.empty-state p { font-size: 1rem; }
|
||||||
|
.empty-state small { font-size: .85rem; }
|
||||||
|
|
||||||
|
/* ── Loading ───────────────────────────────────────────────────────────────── */
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
border: 3px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin .7s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* ── FAB ───────────────────────────────────────────────────────────────────── */
|
||||||
|
.fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(1.5rem + env(safe-area-inset-bottom));
|
||||||
|
right: 1.5rem;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 4px 14px rgba(37,99,235,.4);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
transition: background var(--transition), transform var(--transition);
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.fab:hover { background: var(--accent-h); transform: scale(1.05); }
|
||||||
|
.fab:active { transform: scale(.96); }
|
||||||
|
|
||||||
|
/* ── Modal ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,.45);
|
||||||
|
z-index: 300;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
.modal-overlay.open { display: flex; }
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: var(--bg2);
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
padding: 1.5rem 1.25rem;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 540px;
|
||||||
|
animation: slide-up .22s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 540px) {
|
||||||
|
.modal-overlay { align-items: center; padding: 1rem; }
|
||||||
|
.modal { border-radius: 16px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { transform: translateY(24px); opacity: 0; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: .75rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
flex: 1;
|
||||||
|
padding: .6rem 1rem;
|
||||||
|
background: var(--bg3);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: .9375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.btn-secondary:hover { background: var(--border); }
|
||||||
|
|
||||||
|
.btn-primary-sm {
|
||||||
|
flex: 1;
|
||||||
|
padding: .6rem 1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: .9375rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
.btn-primary-sm:hover { background: var(--accent-h); }
|
||||||
|
|
||||||
|
.modal label {
|
||||||
|
display: block;
|
||||||
|
font-size: .85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text2);
|
||||||
|
margin-bottom: .4rem;
|
||||||
|
margin-top: .9rem;
|
||||||
|
}
|
||||||
|
.modal label:first-of-type { margin-top: 0; }
|
||||||
|
|
||||||
|
/* ── Toast ─────────────────────────────────────────────────────────────────── */
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
bottom: calc(5rem + env(safe-area-inset-bottom));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 500;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
background: var(--text);
|
||||||
|
color: var(--bg);
|
||||||
|
padding: .6rem 1.1rem;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
animation: toast-in .2s ease;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes toast-in {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Offline banner ────────────────────────────────────────────────────────── */
|
||||||
|
.offline-banner {
|
||||||
|
display: none;
|
||||||
|
background: var(--text);
|
||||||
|
color: var(--bg);
|
||||||
|
text-align: center;
|
||||||
|
font-size: .8rem;
|
||||||
|
padding: .4rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.offline-banner.show { display: block; }
|
||||||
|
|
||||||
|
/* ── Pagination ────────────────────────────────────────────────────────────── */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: .5rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-btn {
|
||||||
|
padding: .45rem .9rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: .875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text2);
|
||||||
|
transition: background var(--transition), color var(--transition);
|
||||||
|
}
|
||||||
|
.page-btn:hover:not(:disabled) { background: var(--bg3); color: var(--text); }
|
||||||
|
.page-btn:disabled { opacity: .4; cursor: default; }
|
||||||
|
.page-info { font-size: .8rem; color: var(--text3); }
|
||||||
72
public/sw.js
Normal file
72
public/sw.js
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
const CACHE_NAME = 'bagg-v1';
|
||||||
|
const STATIC_ASSETS = [
|
||||||
|
'/login.html',
|
||||||
|
'/style.css',
|
||||||
|
'/app.js',
|
||||||
|
'/manifest.json',
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Install: cache static assets ─────────────────────────────────────────────
|
||||||
|
self.addEventListener('install', e => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Activate: clean old caches ────────────────────────────────────────────────
|
||||||
|
self.addEventListener('activate', e => {
|
||||||
|
e.waitUntil(
|
||||||
|
caches.keys().then(keys =>
|
||||||
|
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Fetch strategy ────────────────────────────────────────────────────────────
|
||||||
|
self.addEventListener('fetch', e => {
|
||||||
|
const { request } = e;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Skip non-GET and cross-origin requests
|
||||||
|
if (request.method !== 'GET' || url.origin !== location.origin) return;
|
||||||
|
|
||||||
|
// API requests: network-first, no cache
|
||||||
|
if (url.pathname.startsWith('/web/api/') || url.pathname.startsWith('/api/')) {
|
||||||
|
e.respondWith(fetch(request).catch(() => new Response('{"error":"offline"}', {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets: cache-first
|
||||||
|
if (STATIC_ASSETS.includes(url.pathname) || url.pathname.startsWith('/icons/')) {
|
||||||
|
e.respondWith(
|
||||||
|
caches.match(request).then(cached => cached || fetch(request).then(res => {
|
||||||
|
if (res.ok) {
|
||||||
|
const clone = res.clone();
|
||||||
|
caches.open(CACHE_NAME).then(c => c.put(request, clone));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation (HTML pages): network-first, fallback to cache
|
||||||
|
if (request.mode === 'navigate') {
|
||||||
|
e.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then(res => {
|
||||||
|
if (res.ok) {
|
||||||
|
const clone = res.clone();
|
||||||
|
caches.open(CACHE_NAME).then(c => c.put(request, clone));
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(request).then(cached => cached || caches.match('/login.html')))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
297
server.js
Normal file
297
server.js
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const db = require('./db');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// ── Middleware ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function parseCookies(header = '') {
|
||||||
|
return Object.fromEntries(
|
||||||
|
header.split(';')
|
||||||
|
.map(c => c.trim().split('='))
|
||||||
|
.filter(p => p.length === 2)
|
||||||
|
.map(([k, v]) => [k.trim(), decodeURIComponent(v.trim())])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEntry(row) {
|
||||||
|
const tags = db.getTagsForEntry(row.id);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
uid: null,
|
||||||
|
title: row.title || row.url,
|
||||||
|
url: row.url,
|
||||||
|
hashed_url: row.hashed_url,
|
||||||
|
origin_url: null,
|
||||||
|
is_archived: row.is_archived,
|
||||||
|
is_starred: row.is_starred,
|
||||||
|
content: row.content,
|
||||||
|
created_at: new Date(row.created_at * 1000).toISOString(),
|
||||||
|
updated_at: new Date(row.updated_at * 1000).toISOString(),
|
||||||
|
published_at: row.published_at ? new Date(row.published_at * 1000).toISOString() : null,
|
||||||
|
published_by: null,
|
||||||
|
starred_at: row.starred_at ? new Date(row.starred_at * 1000).toISOString() : null,
|
||||||
|
archived_at: row.archived_at ? new Date(row.archived_at * 1000).toISOString() : null,
|
||||||
|
annotations: [],
|
||||||
|
mimetype: null,
|
||||||
|
language: row.language || null,
|
||||||
|
reading_time: row.reading_time || 0,
|
||||||
|
domain_name: row.domain_name || null,
|
||||||
|
preview_picture: row.preview_picture || null,
|
||||||
|
tags: tags.map(t => ({ id: t.id, label: t.label, slug: t.slug })),
|
||||||
|
_links: { self: { href: `/api/v1/entries/${row.id}` } }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API auth middleware ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function apiAuth(req, res, next) {
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
if (!auth.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'unauthorized', error_description: 'Missing or invalid token' });
|
||||||
|
}
|
||||||
|
const tokenData = db.getTokenByAccessToken(auth.slice(7));
|
||||||
|
if (!tokenData) {
|
||||||
|
return res.status(401).json({ error: 'unauthorized', error_description: 'Token invalid or expired' });
|
||||||
|
}
|
||||||
|
req.userId = tokenData.user_id;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Web session auth middleware ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function webAuth(req, res, next) {
|
||||||
|
const cookies = parseCookies(req.headers.cookie);
|
||||||
|
const session = cookies.bagg_session ? db.getWebSession(cookies.bagg_session) : null;
|
||||||
|
if (!session) {
|
||||||
|
return res.redirect('/login.html');
|
||||||
|
}
|
||||||
|
req.userId = session.user_id;
|
||||||
|
req.user = db.getUserById(session.user_id);
|
||||||
|
req.sessionToken = cookies.bagg_session;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wallabag API: auth ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/v1/version', (req, res) => {
|
||||||
|
res.json({ major: 2, minor: 6, patch: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/oauth/v2/token', (req, res) => {
|
||||||
|
const { grant_type, username, password, client_id, client_secret, refresh_token } = req.body;
|
||||||
|
|
||||||
|
if (grant_type === 'password') {
|
||||||
|
const user = username ? db.getUserByUsername(username) : null;
|
||||||
|
if (!user || !db.verifyPassword(password, user.password_hash, user.salt)) {
|
||||||
|
return res.status(401).json({ error: 'invalid_credentials', error_description: 'Bad credentials' });
|
||||||
|
}
|
||||||
|
const client = db.getOAuthClient(client_id, client_secret);
|
||||||
|
if (!client) {
|
||||||
|
return res.status(401).json({ error: 'invalid_client', error_description: 'Unknown client' });
|
||||||
|
}
|
||||||
|
const access = crypto.randomBytes(32).toString('hex');
|
||||||
|
const refresh = crypto.randomBytes(32).toString('hex');
|
||||||
|
db.createOAuthToken(access, refresh, user.id);
|
||||||
|
return res.json({ access_token: access, expires_in: 3600, token_type: 'bearer', scope: 'deposit', refresh_token: refresh });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grant_type === 'refresh_token') {
|
||||||
|
const old = refresh_token ? db.getTokenByRefreshToken(refresh_token) : null;
|
||||||
|
if (!old) {
|
||||||
|
return res.status(401).json({ error: 'invalid_grant', error_description: 'Refresh token not found' });
|
||||||
|
}
|
||||||
|
const { access_token, refresh_token: new_refresh } = db.rotateOAuthTokenFix(old);
|
||||||
|
return res.json({ access_token, expires_in: 3600, token_type: 'bearer', scope: 'deposit', refresh_token: new_refresh });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(400).json({ error: 'unsupported_grant_type' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Wallabag API: entries ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/v1/entries', apiAuth, (req, res) => {
|
||||||
|
const { page = 1, perPage = 30, archive, starred, sort = 'created', order = 'desc' } = req.query;
|
||||||
|
const { items, total } = db.getEntries(req.userId, { page, perPage, archive, starred, sort, order });
|
||||||
|
const pages = Math.max(1, Math.ceil(total / +perPage));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
_embedded: { items: items.map(formatEntry) },
|
||||||
|
_links: {
|
||||||
|
self: { href: `/api/v1/entries?page=${page}` },
|
||||||
|
first: { href: `/api/v1/entries?page=1` },
|
||||||
|
last: { href: `/api/v1/entries?page=${pages}` },
|
||||||
|
...(+page < pages ? { next: { href: `/api/v1/entries?page=${+page + 1}` } } : {}),
|
||||||
|
...(+page > 1 ? { prev: { href: `/api/v1/entries?page=${+page - 1}` } } : {})
|
||||||
|
},
|
||||||
|
page: +page,
|
||||||
|
limit: +perPage,
|
||||||
|
pages,
|
||||||
|
total
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Must be before /:id to avoid "exists" being parsed as an ID
|
||||||
|
app.get('/api/v1/entries/exists', apiAuth, (req, res) => {
|
||||||
|
const { url, urls } = req.query;
|
||||||
|
if (urls) {
|
||||||
|
// Batch check
|
||||||
|
const list = Array.isArray(urls) ? urls : [urls];
|
||||||
|
const result = {};
|
||||||
|
for (const u of list) {
|
||||||
|
const exists = db.entryExistsByUrl(u, req.userId);
|
||||||
|
result[u] = exists ? { id: exists.id } : false;
|
||||||
|
}
|
||||||
|
return res.json(result);
|
||||||
|
}
|
||||||
|
if (!url) return res.status(400).json({ error: 'Missing url parameter' });
|
||||||
|
const exists = db.entryExistsByUrl(url, req.userId);
|
||||||
|
res.json(exists ? { id: exists.id } : false);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/v1/entries/:id', apiAuth, (req, res) => {
|
||||||
|
const entry = db.getEntryById(+req.params.id, req.userId);
|
||||||
|
if (!entry) return res.status(404).json({ error: 'Not Found' });
|
||||||
|
res.json(formatEntry(entry));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/entries', apiAuth, (req, res) => {
|
||||||
|
const { url, title, content, tags, archive, starred } = req.body;
|
||||||
|
if (!url) return res.status(400).json({ error: 'url is required' });
|
||||||
|
|
||||||
|
const entry = db.createEntry(req.userId, { url, title, content, is_archived: archive, is_starred: starred });
|
||||||
|
|
||||||
|
// Handle tags
|
||||||
|
if (tags && Array.isArray(tags)) {
|
||||||
|
for (const label of tags) {
|
||||||
|
if (typeof label === 'string' && label.trim()) {
|
||||||
|
const tag = db.getOrCreateTag(req.userId, label.trim());
|
||||||
|
db.addTagToEntry(entry.id, tag.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json(formatEntry(db.getEntryById(entry.id, req.userId)));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/v1/entries/:id', apiAuth, (req, res) => {
|
||||||
|
const updated = db.updateEntry(+req.params.id, req.userId, req.body);
|
||||||
|
if (!updated) return res.status(404).json({ error: 'Not Found' });
|
||||||
|
res.json(formatEntry(updated));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/entries/:id', apiAuth, (req, res) => {
|
||||||
|
const deleted = db.deleteEntry(+req.params.id, req.userId);
|
||||||
|
if (!deleted) return res.status(404).json({ error: 'Not Found' });
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Wallabag API: tags ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/api/v1/tags', apiAuth, (req, res) => {
|
||||||
|
const tags = db.getAllTags(req.userId);
|
||||||
|
res.json(tags.map(t => ({ id: t.id, label: t.label, slug: t.slug })));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/v1/entry/:id/tags', apiAuth, (req, res) => {
|
||||||
|
const entry = db.getEntryById(+req.params.id, req.userId);
|
||||||
|
if (!entry) return res.status(404).json({ error: 'Not Found' });
|
||||||
|
const { tags } = req.body;
|
||||||
|
const created = [];
|
||||||
|
if (Array.isArray(tags)) {
|
||||||
|
for (const label of tags) {
|
||||||
|
if (typeof label === 'string' && label.trim()) {
|
||||||
|
const tag = db.getOrCreateTag(req.userId, label.trim());
|
||||||
|
db.addTagToEntry(entry.id, tag.id);
|
||||||
|
created.push({ id: tag.id, label: tag.label, slug: tag.slug });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json(created);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/v1/entry/:id/tag/:tagId', apiAuth, (req, res) => {
|
||||||
|
const removed = db.removeTagFromEntry(+req.params.id, +req.params.tagId);
|
||||||
|
if (!removed) return res.status(404).json({ error: 'Not Found' });
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Web UI: auth endpoints ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.post('/web/login', express.urlencoded({ extended: false }), (req, res) => {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
const user = username ? db.getUserByUsername(username) : null;
|
||||||
|
if (!user || !db.verifyPassword(password, user.password_hash, user.salt)) {
|
||||||
|
return res.redirect('/login.html?error=1');
|
||||||
|
}
|
||||||
|
const token = db.createWebSession(user.id);
|
||||||
|
res.setHeader('Set-Cookie', `bagg_session=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${60 * 60 * 24 * 30}`);
|
||||||
|
res.redirect('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/web/logout', webAuth, (req, res) => {
|
||||||
|
db.deleteWebSession(req.sessionToken);
|
||||||
|
res.setHeader('Set-Cookie', 'bagg_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0');
|
||||||
|
res.redirect('/login.html');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Web UI: protected API (for frontend JS) ───────────────────────────────────
|
||||||
|
|
||||||
|
app.get('/web/api/me', webAuth, (req, res) => {
|
||||||
|
res.json({ id: req.user.id, username: req.user.username });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/web/api/entries', webAuth, (req, res) => {
|
||||||
|
const { page = 1, perPage = 50, archive, starred, sort = 'created', order = 'desc' } = req.query;
|
||||||
|
const { items, total } = db.getEntries(req.userId, { page, perPage, archive, starred, sort, order });
|
||||||
|
const pages = Math.max(1, Math.ceil(total / +perPage));
|
||||||
|
res.json({
|
||||||
|
items: items.map(formatEntry),
|
||||||
|
page: +page, pages, total, perPage: +perPage
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/web/api/entries', webAuth, express.json(), (req, res) => {
|
||||||
|
const { url, title } = req.body;
|
||||||
|
if (!url) return res.status(400).json({ error: 'url is required' });
|
||||||
|
const existing = db.entryExistsByUrl(url, req.userId);
|
||||||
|
if (existing) return res.status(409).json({ error: 'Already saved', id: existing.id });
|
||||||
|
const entry = db.createEntry(req.userId, { url, title });
|
||||||
|
res.status(201).json(formatEntry(entry));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/web/api/entries/:id', webAuth, express.json(), (req, res) => {
|
||||||
|
const updated = db.updateEntry(+req.params.id, req.userId, req.body);
|
||||||
|
if (!updated) return res.status(404).json({ error: 'Not Found' });
|
||||||
|
res.json(formatEntry(updated));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/web/api/entries/:id', webAuth, (req, res) => {
|
||||||
|
const deleted = db.deleteEntry(+req.params.id, req.userId);
|
||||||
|
if (!deleted) return res.status(404).json({ error: 'Not Found' });
|
||||||
|
res.status(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Web UI: serve index for client-side routing ───────────────────────────────
|
||||||
|
|
||||||
|
app.get('/', webAuth, (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`[bagg] listening on :${PORT}`);
|
||||||
|
console.log(`[bagg] client_id=${process.env.ADMIN_CLIENT_ID || 'bagg_ios'}`);
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user