Phase 1 & 2: full-stack family dashboard scaffold

- pnpm monorepo (apps/client + apps/server)
- Server: Express + node:sqlite with numbered migration runner,
  REST API for all 9 features (members, events, chores, shopping,
  meals, messages, countdowns, photos, settings)
- Client: React 18 + Vite + TypeScript + Tailwind + Framer Motion + Zustand
- Theme system: dark/light + 5 accent colors, CSS custom properties,
  anti-FOUC script, ThemeToggle on every surface
- AppShell: collapsible sidebar, animated route transitions, mobile drawer
- Phase 2 features: Calendar (custom month grid, event chips, add/edit modal),
  Chores (card grid, complete/reset, member filter, streaks),
  Shopping (multi-list tabs, animated check-off, quick-add bar, member assign)
- Family member CRUD with avatar, color picker
- Settings page: theme/accent, photo folder, slideshow, weather, date/time
- Docker: multi-stage Dockerfile, docker-compose.yml, entrypoint with PUID/PGID
- Unraid: CA XML template, CLI install script, UNRAID.md guide
- .gitignore covering node_modules, dist, db files, secrets, build artifacts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:56:30 -05:00
parent 6e44883365
commit 35ed5223a0
58 changed files with 6224 additions and 0 deletions

39
apps/server/src/db/db.ts Normal file
View File

@@ -0,0 +1,39 @@
import { DatabaseSync, type StatementSync } from 'node:sqlite';
import path from 'path';
import fs from 'fs';
const DATA_DIR = process.env.DATA_DIR ?? path.join(__dirname, '../../data');
if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
const _db = new DatabaseSync(path.join(DATA_DIR, 'family.db'));
_db.exec('PRAGMA journal_mode = WAL');
_db.exec('PRAGMA foreign_keys = ON');
/**
* Transaction wrapper matching better-sqlite3's API:
* const fn = db.transaction((args) => { ... });
* fn(args); // runs inside BEGIN / COMMIT, rolls back on throw
*/
function transaction<TArgs extends unknown[], TReturn>(
fn: (...args: TArgs) => TReturn
): (...args: TArgs) => TReturn {
return (...args: TArgs): TReturn => {
_db.exec('BEGIN');
try {
const result = fn(...args);
_db.exec('COMMIT');
return result;
} catch (err) {
try { _db.exec('ROLLBACK'); } catch { /* ignore rollback errors */ }
throw err;
}
};
}
// Re-export db with the transaction helper attached, preserving full DatabaseSync interface
const db = Object.assign(_db, { transaction });
export type Db = typeof db;
export type Statement = StatementSync;
export default db;

View File

@@ -0,0 +1,120 @@
export const id = '001_initial';
export const up = `
-- ─── Family Members ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6366f1',
avatar TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── App Settings (key/value store) ───────────────────────────────
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- ─── Calendar Events ──────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
start_at TEXT NOT NULL,
end_at TEXT NOT NULL,
all_day INTEGER NOT NULL DEFAULT 0,
recurrence TEXT,
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
color TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Shopping Lists ───────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS shopping_lists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS shopping_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
name TEXT NOT NULL,
quantity TEXT,
checked INTEGER NOT NULL DEFAULT 0,
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Chores ───────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS chores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
recurrence TEXT NOT NULL DEFAULT 'none',
status TEXT NOT NULL DEFAULT 'pending',
due_date TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS chore_completions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
chore_id INTEGER NOT NULL REFERENCES chores(id) ON DELETE CASCADE,
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
completed_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Meal Planner (one meal per day — dinner) ─────────────────────
CREATE TABLE IF NOT EXISTS meals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT,
recipe_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(date)
);
-- ─── Message Board ────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
member_id INTEGER REFERENCES members(id) ON DELETE SET NULL,
body TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#fef08a',
emoji TEXT,
pinned INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Countdowns ───────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS countdowns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
target_date TEXT NOT NULL,
emoji TEXT,
color TEXT NOT NULL DEFAULT '#6366f1',
show_on_dashboard INTEGER NOT NULL DEFAULT 1,
event_id INTEGER REFERENCES events(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ─── Seed default settings ────────────────────────────────────────
INSERT OR IGNORE INTO settings (key, value) VALUES
('theme', 'light'),
('accent', 'indigo'),
('photo_folder', ''),
('slideshow_speed', '6000'),
('slideshow_order', 'random'),
('idle_timeout', '120000'),
('time_format', '12h'),
('date_format', 'MM/DD/YYYY'),
('weather_api_key', ''),
('weather_location',''),
('weather_units', 'imperial');
INSERT OR IGNORE INTO shopping_lists (id, name) VALUES (1, 'Groceries');
`;

View File

@@ -0,0 +1,123 @@
/**
* Migration runner
*
* How it works:
* 1. Creates a `_migrations` table on first run.
* 2. Loads all migration modules from ./migrations/ in filename order.
* 3. Skips any migration whose `id` is already recorded in `_migrations`.
* 4. Executes pending migrations inside individual transactions.
* 5. Records each successful migration with a timestamp.
*
* Adding a new migration:
* - Create `apps/server/src/db/migrations/NNN_description.ts`
* - Export `id` (string, matches filename) and `up` (SQL string).
* - Optionally export `down` (SQL string) for rollback support.
* - The runner picks it up automatically on next startup.
*/
import db from './db';
import path from 'path';
import fs from 'fs';
interface Migration {
id: string;
up: string;
down?: string;
}
function bootstrap() {
db.exec(`
CREATE TABLE IF NOT EXISTS _migrations (
id TEXT PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`);
}
function loadMigrations(): Migration[] {
const dir = path.join(__dirname, 'migrations');
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter((f) => f.endsWith('.ts') || f.endsWith('.js'))
.sort()
.map((file) => {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const mod = require(path.join(dir, file)) as Migration;
if (!mod.id || !mod.up) {
throw new Error(`Migration ${file} must export 'id' and 'up'`);
}
return mod;
});
}
function getApplied(): Set<string> {
const rows = db.prepare('SELECT id FROM _migrations').all() as { id: string }[];
return new Set(rows.map((r) => r.id));
}
export function runMigrations() {
bootstrap();
const migrations = loadMigrations();
const applied = getApplied();
const pending = migrations.filter((m) => !applied.has(m.id));
if (pending.length === 0) {
console.log('[db] All migrations up to date.');
return;
}
console.log(`[db] Running ${pending.length} pending migration(s)...`);
for (const migration of pending) {
const apply = db.transaction(() => {
db.exec(migration.up);
db.prepare('INSERT INTO _migrations (id) VALUES (?)').run(migration.id);
});
try {
apply();
console.log(`[db] ✓ Applied: ${migration.id}`);
} catch (err) {
console.error(`[db] ✗ Failed: ${migration.id}`, err);
throw err; // Abort startup on migration failure
}
}
console.log('[db] Migrations complete.');
}
export function rollback(targetId: string) {
const migrations = loadMigrations();
const applied = getApplied();
// Find all applied migrations after targetId in reverse order
const toRollback = migrations
.filter((m) => applied.has(m.id) && m.id > targetId)
.reverse();
if (toRollback.length === 0) {
console.log('[db] Nothing to roll back.');
return;
}
for (const migration of toRollback) {
if (!migration.down) {
console.warn(`[db] ⚠ No down migration for: ${migration.id} — skipping`);
continue;
}
const revert = db.transaction(() => {
db.exec(migration.down!);
db.prepare('DELETE FROM _migrations WHERE id = ?').run(migration.id);
});
try {
revert();
console.log(`[db] ✓ Rolled back: ${migration.id}`);
} catch (err) {
console.error(`[db] ✗ Rollback failed: ${migration.id}`, err);
throw err;
}
}
}

45
apps/server/src/index.ts Normal file
View File

@@ -0,0 +1,45 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import { runMigrations } from './db/runner';
import membersRouter from './routes/members';
import settingsRouter from './routes/settings';
import eventsRouter from './routes/events';
import shoppingRouter from './routes/shopping';
import choresRouter from './routes/chores';
import mealsRouter from './routes/meals';
import messagesRouter from './routes/messages';
import countdownsRouter from './routes/countdowns';
import photosRouter from './routes/photos';
// Run DB migrations on startup — aborts if any migration fails
runMigrations();
const app = express();
const PORT = process.env.PORT ?? 3001;
const CLIENT_ORIGIN = process.env.CLIENT_ORIGIN ?? 'http://localhost:5173';
app.use(cors({ origin: CLIENT_ORIGIN }));
app.use(express.json());
app.use('/api/members', membersRouter);
app.use('/api/settings', settingsRouter);
app.use('/api/events', eventsRouter);
app.use('/api/shopping', shoppingRouter);
app.use('/api/chores', choresRouter);
app.use('/api/meals', mealsRouter);
app.use('/api/messages', messagesRouter);
app.use('/api/countdowns', countdownsRouter);
app.use('/api/photos', photosRouter);
// Serve built client — in Docker the client dist is copied here at build time
const CLIENT_DIST = path.join(__dirname, '../../client/dist');
app.use(express.static(CLIENT_DIST));
app.get('*', (_req, res) => {
res.sendFile(path.join(CLIENT_DIST, 'index.html'));
});
app.listen(PORT, () => {
console.log(`Family Planner running on http://0.0.0.0:${PORT}`);
});

View File

@@ -0,0 +1,71 @@
import { Router } from 'express';
import db from '../db/db';
const router = Router();
router.get('/', (req, res) => {
const { member_id } = req.query;
let query = `
SELECT c.*, m.name as member_name, m.color as member_color,
(SELECT COUNT(*) FROM chore_completions cc WHERE cc.chore_id = c.id) as completion_count
FROM chores c
LEFT JOIN members m ON c.member_id = m.id
`;
if (member_id) {
query += ' WHERE c.member_id = ?';
return res.json(db.prepare(query + ' ORDER BY c.due_date ASC').all(member_id as string));
}
res.json(db.prepare(query + ' ORDER BY c.due_date ASC').all());
});
router.post('/', (req, res) => {
const { title, description, member_id, recurrence, due_date } = req.body;
if (!title?.trim()) return res.status(400).json({ error: 'Title is required' });
const result = db
.prepare('INSERT INTO chores (title, description, member_id, recurrence, due_date) VALUES (?, ?, ?, ?, ?)')
.run(title.trim(), description ?? null, member_id ?? null, recurrence ?? 'none', due_date ?? null);
res.status(201).json(db.prepare('SELECT * FROM chores WHERE id = ?').get(result.lastInsertRowid));
});
router.put('/:id', (req, res) => {
const existing = db.prepare('SELECT * FROM chores WHERE id = ?').get(req.params.id) as any;
if (!existing) return res.status(404).json({ error: 'Chore not found' });
const { title, description, member_id, recurrence, status, due_date } = req.body;
db.prepare('UPDATE chores SET title=?, description=?, member_id=?, recurrence=?, status=?, due_date=? WHERE id=?').run(
title?.trim() ?? existing.title,
description !== undefined ? description : existing.description,
member_id !== undefined ? member_id : existing.member_id,
recurrence ?? existing.recurrence,
status ?? existing.status,
due_date !== undefined ? due_date : existing.due_date,
req.params.id
);
res.json(db.prepare('SELECT * FROM chores WHERE id = ?').get(req.params.id));
});
router.delete('/:id', (req, res) => {
const result = db.prepare('DELETE FROM chores WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Chore not found' });
res.status(204).end();
});
router.post('/:id/complete', (req, res) => {
const { member_id } = req.body;
const chore = db.prepare('SELECT * FROM chores WHERE id = ?').get(req.params.id) as any;
if (!chore) return res.status(404).json({ error: 'Chore not found' });
db.prepare('INSERT INTO chore_completions (chore_id, member_id) VALUES (?, ?)').run(req.params.id, member_id ?? chore.member_id);
db.prepare("UPDATE chores SET status = 'done' WHERE id = ?").run(req.params.id);
res.json({ success: true });
});
router.get('/:id/completions', (req, res) => {
res.json(
db.prepare(`
SELECT cc.*, m.name as member_name FROM chore_completions cc
LEFT JOIN members m ON cc.member_id = m.id
WHERE cc.chore_id = ? ORDER BY cc.completed_at DESC
`).all(req.params.id)
);
});
export default router;

View File

@@ -0,0 +1,48 @@
import { Router } from 'express';
import db from '../db/db';
const router = Router();
router.get('/', (_req, res) => {
res.json(
db.prepare(`
SELECT c.*, e.title as event_title
FROM countdowns c
LEFT JOIN events e ON c.event_id = e.id
WHERE c.target_date >= date('now')
ORDER BY c.target_date ASC
`).all()
);
});
router.post('/', (req, res) => {
const { title, target_date, emoji, color, show_on_dashboard, event_id } = req.body;
if (!title?.trim() || !target_date) return res.status(400).json({ error: 'title and target_date are required' });
const result = db
.prepare('INSERT INTO countdowns (title, target_date, emoji, color, show_on_dashboard, event_id) VALUES (?, ?, ?, ?, ?, ?)')
.run(title.trim(), target_date, emoji ?? null, color ?? '#6366f1', show_on_dashboard !== false ? 1 : 0, event_id ?? null);
res.status(201).json(db.prepare('SELECT * FROM countdowns WHERE id = ?').get(result.lastInsertRowid));
});
router.put('/:id', (req, res) => {
const existing = db.prepare('SELECT * FROM countdowns WHERE id = ?').get(req.params.id) as any;
if (!existing) return res.status(404).json({ error: 'Countdown not found' });
const { title, target_date, emoji, color, show_on_dashboard } = req.body;
db.prepare('UPDATE countdowns SET title=?, target_date=?, emoji=?, color=?, show_on_dashboard=? WHERE id=?').run(
title?.trim() ?? existing.title,
target_date ?? existing.target_date,
emoji !== undefined ? emoji : existing.emoji,
color ?? existing.color,
show_on_dashboard !== undefined ? (show_on_dashboard ? 1 : 0) : existing.show_on_dashboard,
req.params.id
);
res.json(db.prepare('SELECT * FROM countdowns WHERE id = ?').get(req.params.id));
});
router.delete('/:id', (req, res) => {
const result = db.prepare('DELETE FROM countdowns WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Countdown not found' });
res.status(204).end();
});
export default router;

View File

@@ -0,0 +1,60 @@
import { Router } from 'express';
import db from '../db/db';
const router = Router();
router.get('/', (req, res) => {
const { start, end } = req.query as { start?: string; end?: string };
let query = 'SELECT * FROM events';
const params: string[] = [];
if (start && end) {
query += ' WHERE start_at >= ? AND start_at <= ?';
params.push(start, end);
}
query += ' ORDER BY start_at ASC';
res.json(db.prepare(query).all(...params));
});
router.get('/:id', (req, res) => {
const row = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Event not found' });
res.json(row);
});
router.post('/', (req, res) => {
const { title, description, start_at, end_at, all_day, recurrence, member_id, color } = req.body;
if (!title?.trim() || !start_at || !end_at)
return res.status(400).json({ error: 'title, start_at, and end_at are required' });
const result = db
.prepare(`INSERT INTO events (title, description, start_at, end_at, all_day, recurrence, member_id, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
.run(title.trim(), description ?? null, start_at, end_at, all_day ? 1 : 0, recurrence ?? null, member_id ?? null, color ?? null);
res.status(201).json(db.prepare('SELECT * FROM events WHERE id = ?').get(result.lastInsertRowid));
});
router.put('/:id', (req, res) => {
const existing = db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id) as any;
if (!existing) return res.status(404).json({ error: 'Event not found' });
const { title, description, start_at, end_at, all_day, recurrence, member_id, color } = req.body;
db.prepare(`UPDATE events SET title=?, description=?, start_at=?, end_at=?, all_day=?, recurrence=?, member_id=?, color=? WHERE id=?`)
.run(
title?.trim() ?? existing.title,
description !== undefined ? description : existing.description,
start_at ?? existing.start_at,
end_at ?? existing.end_at,
all_day !== undefined ? (all_day ? 1 : 0) : existing.all_day,
recurrence !== undefined ? recurrence : existing.recurrence,
member_id !== undefined ? member_id : existing.member_id,
color !== undefined ? color : existing.color,
req.params.id
);
res.json(db.prepare('SELECT * FROM events WHERE id = ?').get(req.params.id));
});
router.delete('/:id', (req, res) => {
const result = db.prepare('DELETE FROM events WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Event not found' });
res.status(204).end();
});
export default router;

View File

@@ -0,0 +1,37 @@
import { Router } from 'express';
import db from '../db/db';
const router = Router();
// Get meals for a date range (e.g. ?start=2024-01-01&end=2024-01-31)
router.get('/', (req, res) => {
const { start, end } = req.query as { start?: string; end?: string };
if (start && end) {
return res.json(db.prepare('SELECT * FROM meals WHERE date >= ? AND date <= ? ORDER BY date ASC').all(start, end));
}
res.json(db.prepare('SELECT * FROM meals ORDER BY date ASC').all());
});
router.get('/:date', (req, res) => {
const row = db.prepare('SELECT * FROM meals WHERE date = ?').get(req.params.date);
if (!row) return res.status(404).json({ error: 'No meal for this date' });
res.json(row);
});
router.put('/:date', (req, res) => {
const { title, description, recipe_url } = req.body as { title: string; description?: string; recipe_url?: string };
if (!title?.trim()) return res.status(400).json({ error: 'Title is required' });
db.prepare(`
INSERT INTO meals (date, title, description, recipe_url) VALUES (?, ?, ?, ?)
ON CONFLICT(date) DO UPDATE SET title=excluded.title, description=excluded.description, recipe_url=excluded.recipe_url
`).run(req.params.date, title.trim(), description ?? null, recipe_url ?? null);
res.json(db.prepare('SELECT * FROM meals WHERE date = ?').get(req.params.date));
});
router.delete('/:date', (req, res) => {
const result = db.prepare('DELETE FROM meals WHERE date = ?').run(req.params.date);
if (result.changes === 0) return res.status(404).json({ error: 'No meal for this date' });
res.status(204).end();
});
export default router;

View File

@@ -0,0 +1,47 @@
import { Router } from 'express';
import db from '../db/db';
const router = Router();
router.get('/', (_req, res) => {
const rows = db.prepare('SELECT * FROM members ORDER BY name ASC').all();
res.json(rows);
});
router.get('/:id', (req, res) => {
const row = db.prepare('SELECT * FROM members WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'Member not found' });
res.json(row);
});
router.post('/', (req, res) => {
const { name, color, avatar } = req.body as { name: string; color?: string; avatar?: string };
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
const result = db
.prepare('INSERT INTO members (name, color, avatar) VALUES (?, ?, ?)')
.run(name.trim(), color ?? '#6366f1', avatar ?? null);
const created = db.prepare('SELECT * FROM members WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(created);
});
router.put('/:id', (req, res) => {
const { name, color, avatar } = req.body as { name?: string; color?: string; avatar?: string };
const existing = db.prepare('SELECT * FROM members WHERE id = ?').get(req.params.id) as any;
if (!existing) return res.status(404).json({ error: 'Member not found' });
db.prepare('UPDATE members SET name = ?, color = ?, avatar = ? WHERE id = ?').run(
name?.trim() ?? existing.name,
color ?? existing.color,
avatar !== undefined ? avatar : existing.avatar,
req.params.id
);
const updated = db.prepare('SELECT * FROM members WHERE id = ?').get(req.params.id);
res.json(updated);
});
router.delete('/:id', (req, res) => {
const result = db.prepare('DELETE FROM members WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Member not found' });
res.status(204).end();
});
export default router;

View File

@@ -0,0 +1,48 @@
import { Router } from 'express';
import db from '../db/db';
const router = Router();
router.get('/', (_req, res) => {
res.json(
db.prepare(`
SELECT msg.*, m.name as member_name, m.color as member_color
FROM messages msg
LEFT JOIN members m ON msg.member_id = m.id
WHERE msg.expires_at IS NULL OR msg.expires_at > datetime('now')
ORDER BY msg.pinned DESC, msg.created_at DESC
`).all()
);
});
router.post('/', (req, res) => {
const { member_id, body, color, emoji, pinned, expires_at } = req.body;
if (!body?.trim()) return res.status(400).json({ error: 'Body is required' });
const result = db
.prepare('INSERT INTO messages (member_id, body, color, emoji, pinned, expires_at) VALUES (?, ?, ?, ?, ?, ?)')
.run(member_id ?? null, body.trim(), color ?? '#fef08a', emoji ?? null, pinned ? 1 : 0, expires_at ?? null);
res.status(201).json(db.prepare('SELECT * FROM messages WHERE id = ?').get(result.lastInsertRowid));
});
router.patch('/:id', (req, res) => {
const existing = db.prepare('SELECT * FROM messages WHERE id = ?').get(req.params.id) as any;
if (!existing) return res.status(404).json({ error: 'Message not found' });
const { body, color, emoji, pinned, expires_at } = req.body;
db.prepare('UPDATE messages SET body=?, color=?, emoji=?, pinned=?, expires_at=? WHERE id=?').run(
body?.trim() ?? existing.body,
color ?? existing.color,
emoji !== undefined ? emoji : existing.emoji,
pinned !== undefined ? (pinned ? 1 : 0) : existing.pinned,
expires_at !== undefined ? expires_at : existing.expires_at,
req.params.id
);
res.json(db.prepare('SELECT * FROM messages WHERE id = ?').get(req.params.id));
});
router.delete('/:id', (req, res) => {
const result = db.prepare('DELETE FROM messages WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Message not found' });
res.status(204).end();
});
export default router;

View File

@@ -0,0 +1,61 @@
import { Router } from 'express';
import db from '../db/db';
import fs from 'fs';
import path from 'path';
const router = Router();
const IMAGE_EXTS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif', '.bmp']);
function scanDir(dir: string): string[] {
if (!dir || !fs.existsSync(dir)) return [];
const results: string[] = [];
function recurse(current: string) {
let entries: fs.Dirent[];
try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return; }
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) recurse(full);
else if (IMAGE_EXTS.has(path.extname(entry.name).toLowerCase())) results.push(full);
}
}
recurse(dir);
return results;
}
// PHOTOS_DIR env var (set in Docker) overrides the DB setting.
// This lets Unraid users bind-mount their photo library to /photos
// without having to change the settings in the UI.
function resolvePhotoFolder(): string {
if (process.env.PHOTOS_DIR) return process.env.PHOTOS_DIR;
const row = db.prepare("SELECT value FROM settings WHERE key = 'photo_folder'").get() as any;
return row?.value ?? '';
}
router.get('/', (_req, res) => {
const folder = resolvePhotoFolder();
const files = scanDir(folder);
res.json({ folder, count: files.length, files: files.map((f) => path.basename(f)) });
});
router.get('/file/:filename', (req, res) => {
const folder = resolvePhotoFolder();
if (!folder) return res.status(404).json({ error: 'Photo folder not configured' });
// Security: prevent path traversal
const filename = path.basename(req.params.filename);
const filepath = path.join(folder, filename);
if (!filepath.startsWith(folder)) return res.status(403).json({ error: 'Forbidden' });
if (!fs.existsSync(filepath)) return res.status(404).json({ error: 'File not found' });
res.sendFile(filepath);
});
// Return all photos as a flat list with their relative paths for the slideshow
router.get('/slideshow', (_req, res) => {
const folder = resolvePhotoFolder();
const files = scanDir(folder);
const urls = files.map((f) => `/api/photos/file/${encodeURIComponent(path.relative(folder, f).replace(/\\/g, '/'))}`);
res.json({ count: urls.length, urls });
});
export default router;

View File

@@ -0,0 +1,23 @@
import { Router } from 'express';
import db from '../db/db';
const router = Router();
router.get('/', (_req, res) => {
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
const settings = Object.fromEntries(rows.map((r) => [r.key, r.value]));
res.json(settings);
});
router.patch('/', (req, res) => {
const updates = req.body as Record<string, string>;
const upsert = db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value');
const updateMany = db.transaction((pairs: [string, string][]) => {
for (const [k, v] of pairs) upsert.run(k, String(v));
});
updateMany(Object.entries(updates));
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[];
res.json(Object.fromEntries(rows.map((r) => [r.key, r.value])));
});
export default router;

View File

@@ -0,0 +1,67 @@
import { Router } from 'express';
import db from '../db/db';
const router = Router();
// ── Lists ──────────────────────────────────────────────────────────────────
router.get('/lists', (_req, res) => {
res.json(db.prepare('SELECT * FROM shopping_lists ORDER BY name ASC').all());
});
router.post('/lists', (req, res) => {
const { name } = req.body as { name: string };
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
const result = db.prepare('INSERT INTO shopping_lists (name) VALUES (?)').run(name.trim());
res.status(201).json(db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(result.lastInsertRowid));
});
router.delete('/lists/:id', (req, res) => {
const result = db.prepare('DELETE FROM shopping_lists WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'List not found' });
res.status(204).end();
});
// ── Items ──────────────────────────────────────────────────────────────────
router.get('/lists/:listId/items', (req, res) => {
res.json(
db.prepare('SELECT * FROM shopping_items WHERE list_id = ? ORDER BY sort_order ASC, id ASC').all(req.params.listId)
);
});
router.post('/lists/:listId/items', (req, res) => {
const { name, quantity, member_id } = req.body as { name: string; quantity?: string; member_id?: number };
if (!name?.trim()) return res.status(400).json({ error: 'Name is required' });
const maxOrder = (db.prepare('SELECT MAX(sort_order) as m FROM shopping_items WHERE list_id = ?').get(req.params.listId) as any)?.m ?? 0;
const result = db
.prepare('INSERT INTO shopping_items (list_id, name, quantity, member_id, sort_order) VALUES (?, ?, ?, ?, ?)')
.run(req.params.listId, name.trim(), quantity ?? null, member_id ?? null, maxOrder + 1);
res.status(201).json(db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(result.lastInsertRowid));
});
router.patch('/items/:id', (req, res) => {
const existing = db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(req.params.id) as any;
if (!existing) return res.status(404).json({ error: 'Item not found' });
const { name, quantity, checked, member_id, sort_order } = req.body;
db.prepare('UPDATE shopping_items SET name=?, quantity=?, checked=?, member_id=?, sort_order=? WHERE id=?').run(
name?.trim() ?? existing.name,
quantity !== undefined ? quantity : existing.quantity,
checked !== undefined ? (checked ? 1 : 0) : existing.checked,
member_id !== undefined ? member_id : existing.member_id,
sort_order !== undefined ? sort_order : existing.sort_order,
req.params.id
);
res.json(db.prepare('SELECT * FROM shopping_items WHERE id = ?').get(req.params.id));
});
router.delete('/items/:id', (req, res) => {
const result = db.prepare('DELETE FROM shopping_items WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Item not found' });
res.status(204).end();
});
router.delete('/lists/:listId/items/checked', (req, res) => {
db.prepare('DELETE FROM shopping_items WHERE list_id = ? AND checked = 1').run(req.params.listId);
res.status(204).end();
});
export default router;