Compare commits

..
2 Commits
Author SHA1 Message Date
x3 fb396d313a feat(frontend): settings window, i18n, admin UI, books, tasks, calendar, timer
ci / frontend (push) Canceled after 0s
ci / backend (push) Canceled after 0s
- Settings window with tabs: profile (name, email, avatar, password),
  appearance (light/dark/system), language (de/en/zh), background
  (gradient presets, custom URL, blur toggle); persisted server-side
- i18n dictionaries (de/en/zh) applied across nav, sidebar, tasks,
  calendar, time tracking, admin, invites and comments
- Greeting header with time-of-day message, live clock and date; cooler
  theme, cards, kanban, calendar grid and data-table styling
- Shared sidebar: nav, collapsible books with per-book pages, top-level
  pages, search; user chip opens settings
- Tasks kanban with priorities, overdue flags and book/page links;
  calendar month view fed by task deadlines; time view with entries,
  durations and totals; top-right live timer with optional book link
- User discovery autocomplete in the invite card; editor gains tables
  and checklists; standalone theme buttons removed
2026-09-22 13:56:26 +02:00
x3 b852583499 feat(backend): books, tasks, calendar feed, time tracking, profiles, admin API
- Schema: books, tasks (book/page links, status, priority, due_at),
  time_entries (open entry = running timer), user profile columns
  (avatar, locale, theme, bg_image, bg_blur)
- Books CRUD; pages can live in a book (book_id, validated same-workspace)
- Tasks CRUD with status filter and due_at range query for the calendar
- Time API: start (409 if running), stop (computes duration), list with
  open timer, update, delete
- Users: fuzzy discovery for invites, PATCH /me profile/appearance,
  password change; members list now includes avatars
- Admin API (is_admin guard): overview counts, user list, grant/revoke
  admin, delete user (self-demote/delete blocked)
2026-09-22 13:56:25 +02:00
41 changed files with 2287 additions and 204 deletions
+10 -2
View File
@@ -12,8 +12,16 @@ light/dark mode. Self-hostable via Node.js or Docker Compose.
- **Auth** — Register / login with JWT (access token), bcrypt password hashing. - **Auth** — Register / login with JWT (access token), bcrypt password hashing.
- **Workspaces** — Create workspaces, invite members by email (`owner` / `admin` / - **Workspaces** — Create workspaces, invite members by email (`owner` / `admin` /
`member` / `viewer`), change roles, remove members. `member` / `viewer`), change roles, remove members.
- **Pages** — Nested pages (`parent_id`), icon + cover, ordering, TipTap rich-text - **Pages** — Nested pages (`parent_id`) in workspaces or **books**, icon + cover,
content stored as JSON + rendered HTML. ordering, TipTap rich-text with **tables + checklists**, stored as JSON + HTML.
- **Books** 📚 — Independent containers for pages, collapsible in the sidebar.
- **Tasks** ✅ — Kanban (todo/doing/done), priorities, deadlines, links to books/pages.
- **Calendar** 🗓️ — Deadlines from tasks appear automatically.
- **Time tracking** ⏱️ — Top-right timer, entries linkable to books, history list.
- **Settings window** ⚙️ — Profile (name, email, avatar, password), appearance
(light/dark/system), language (**Deutsch, English, 中文**), background image + blur.
- **Admin dashboard** 🛡️ — Stats, user management (admin flag, delete).
- **User discovery** 🔎 — Fuzzy search to find and invite collaborators.
- **Real-time collaboration** — Socket.IO rooms per page: live content broadcast, - **Real-time collaboration** — Socket.IO rooms per page: live content broadcast,
presence (who is viewing), typing indicators. presence (who is viewing), typing indicators.
- **Comments** — Threaded comments per page. - **Comments** — Threaded comments per page.
+65
View File
@@ -76,6 +76,63 @@ CREATE TABLE IF NOT EXISTS app_settings (
-- Global admin flag (first user created via web installer) -- Global admin flag (first user created via web installer)
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE users ADD COLUMN IF NOT EXISTS is_admin BOOLEAN NOT NULL DEFAULT FALSE;
-- Profile / appearance / locale settings (edited via settings window)
ALTER TABLE users ADD COLUMN IF NOT EXISTS avatar TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS locale TEXT NOT NULL DEFAULT 'en';
ALTER TABLE users ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system';
ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_image TEXT NOT NULL DEFAULT '';
ALTER TABLE users ADD COLUMN IF NOT EXISTS bg_blur BOOLEAN NOT NULL DEFAULT TRUE;
-- Books: independent containers that hold pages (collapsible in sidebar)
CREATE TABLE IF NOT EXISTS books (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT 'Untitled book' CHECK (char_length(title) BETWEEN 1 AND 200),
icon TEXT NOT NULL DEFAULT '📚',
position DOUBLE PRECISION NOT NULL DEFAULT 0,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_books_workspace ON books(workspace_id);
-- Pages can optionally live inside a book (book_id NULL = top-level page)
ALTER TABLE pages ADD COLUMN IF NOT EXISTS book_id UUID REFERENCES books(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_pages_book ON pages(book_id);
-- Tasks: can link to a book and/or a page, optional deadline (feeds the calendar)
CREATE TABLE IF NOT EXISTS tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
book_id UUID REFERENCES books(id) ON DELETE SET NULL,
page_id UUID REFERENCES pages(id) ON DELETE SET NULL,
title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 300),
status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo','doing','done')),
priority TEXT NOT NULL DEFAULT 'normal' CHECK (priority IN ('low','normal','high')),
due_at TIMESTAMPTZ NULL,
position DOUBLE PRECISION NOT NULL DEFAULT 0,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_tasks_workspace ON tasks(workspace_id);
CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(workspace_id, due_at);
-- Time tracking entries (open entry = running timer, ended_at IS NULL)
CREATE TABLE IF NOT EXISTS time_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
book_id UUID REFERENCES books(id) ON DELETE SET NULL,
page_id UUID REFERENCES pages(id) ON DELETE SET NULL,
description TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ended_at TIMESTAMPTZ NULL,
duration_seconds INTEGER NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_time_ws_user ON time_entries(workspace_id, user_id);
-- Ensure workspace owner is always a member with role=owner -- Ensure workspace owner is always a member with role=owner
CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$ CREATE OR REPLACE FUNCTION ensure_owner_membership() RETURNS trigger AS $$
BEGIN BEGIN
@@ -110,3 +167,11 @@ FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
DROP TRIGGER IF EXISTS trg_pages_touch ON pages; DROP TRIGGER IF EXISTS trg_pages_touch ON pages;
CREATE TRIGGER trg_pages_touch BEFORE UPDATE ON pages CREATE TRIGGER trg_pages_touch BEFORE UPDATE ON pages
FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
DROP TRIGGER IF EXISTS trg_books_touch ON books;
CREATE TRIGGER trg_books_touch BEFORE UPDATE ON books
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
DROP TRIGGER IF EXISTS trg_tasks_touch ON tasks;
CREATE TRIGGER trg_tasks_touch BEFORE UPDATE ON tasks
FOR EACH ROW EXECUTE FUNCTION touch_updated_at();
+8
View File
@@ -11,6 +11,10 @@ import setupRoutes, { isInstalled } from './routes/setup.js';
import workspaceRoutes from './routes/workspaces.js'; import workspaceRoutes from './routes/workspaces.js';
import pageRoutes from './routes/pages.js'; import pageRoutes from './routes/pages.js';
import userRoutes from './routes/users.js'; import userRoutes from './routes/users.js';
import bookRoutes from './routes/books.js';
import taskRoutes from './routes/tasks.js';
import timeRoutes from './routes/time.js';
import adminRoutes from './routes/admin.js';
import { initSocket } from './socket.js'; import { initSocket } from './socket.js';
const app = express(); const app = express();
@@ -43,6 +47,10 @@ app.use('/api/auth', rateLimit({ windowMs: 15 * 60 * 1000, max: 100 }), authRout
app.use('/api/workspaces', workspaceRoutes); app.use('/api/workspaces', workspaceRoutes);
app.use('/api/pages', pageRoutes); app.use('/api/pages', pageRoutes);
app.use('/api/users', userRoutes); app.use('/api/users', userRoutes);
app.use('/api/books', bookRoutes);
app.use('/api/tasks', taskRoutes);
app.use('/api/time', timeRoutes);
app.use('/api/admin', adminRoutes);
app.get('/api/health', async (_req, res) => { app.get('/api/health', async (_req, res) => {
try { try {
+62
View File
@@ -0,0 +1,62 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { requireAdmin } from './guards.js';
const router = Router();
router.use(requireAuth, requireAdmin);
// GET /api/admin/overview — counts + recent users
router.get('/overview', async (_req, res) => {
const [[u], [w], [b], [p], [t], [e]] = await Promise.all(
['users', 'workspaces', 'books', 'pages', 'tasks', 'time_entries'].map(
(tbl) => query(`SELECT COUNT(*)::int AS c FROM ${tbl}`).then((r) => r.rows),
),
);
const { rows: recent } = await query(
'SELECT id, name, email, is_admin, created_at FROM users ORDER BY created_at DESC LIMIT 8',
);
return res.json({
counts: {
users: u.c, workspaces: w.c, books: b.c, pages: p.c, tasks: t.c, timeEntries: e.c,
},
recentUsers: recent,
});
});
// GET /api/admin/users
router.get('/users', async (_req, res) => {
const { rows } = await query(
`SELECT u.id, u.name, u.email, u.is_admin, u.locale, u.created_at,
(SELECT COUNT(*)::int FROM workspace_members m WHERE m.user_id = u.id) AS workspaces
FROM users u ORDER BY u.created_at`,
);
return res.json({ users: rows });
});
// PATCH /api/admin/users/:id { is_admin }
router.patch('/users/:id', async (req, res) => {
const { is_admin } = req.body ?? {};
if (typeof is_admin !== 'boolean') return res.status(400).json({ error: 'is_admin boolean required' });
if (req.params.id === req.user.id && is_admin === false) {
return res.status(400).json({ error: 'You cannot demote yourself' });
}
const { rows } = await query(
'UPDATE users SET is_admin = $1 WHERE id = $2 RETURNING id, name, email, is_admin',
[is_admin, req.params.id],
);
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
return res.json({ user: rows[0] });
});
// DELETE /api/admin/users/:id
router.delete('/users/:id', async (req, res) => {
if (req.params.id === req.user.id) {
return res.status(400).json({ error: 'You cannot delete yourself' });
}
const { rowCount } = await query('DELETE FROM users WHERE id = $1', [req.params.id]);
if (!rowCount) return res.status(404).json({ error: 'User not found' });
return res.status(204).end();
});
export default router;
+14 -3
View File
@@ -7,7 +7,18 @@ const router = Router();
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/; const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
function publicUser(row) { function publicUser(row) {
return { id: row.id, email: row.email, name: row.name, isAdmin: !!row.is_admin, createdAt: row.created_at }; return {
id: row.id,
email: row.email,
name: row.name,
isAdmin: !!row.is_admin,
avatar: row.avatar || '',
locale: row.locale || 'en',
theme: row.theme || 'system',
bgImage: row.bg_image || '',
bgBlur: row.bg_blur !== false,
createdAt: row.created_at,
};
} }
// POST /api/auth/register { email, name, password } // POST /api/auth/register { email, name, password }
@@ -27,7 +38,7 @@ router.post('/register', async (req, res) => {
if (existing.rowCount > 0) return res.status(409).json({ error: 'Email already registered' }); if (existing.rowCount > 0) return res.status(409).json({ error: 'Email already registered' });
const hash = await bcrypt.hash(String(password), 12); const hash = await bcrypt.hash(String(password), 12);
const { rows } = await query( const { rows } = await query(
'INSERT INTO users (email, name, password_hash) VALUES ($1,$2,$3) RETURNING id, email, name, created_at', 'INSERT INTO users (email, name, password_hash) VALUES ($1,$2,$3) RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, created_at',
[String(email).toLowerCase(), String(name).trim(), hash], [String(email).toLowerCase(), String(name).trim(), hash],
); );
const token = signToken(rows[0]); const token = signToken(rows[0]);
@@ -57,7 +68,7 @@ router.post('/login', async (req, res) => {
// GET /api/auth/me // GET /api/auth/me
router.get('/me', requireAuth, async (req, res) => { router.get('/me', requireAuth, async (req, res) => {
const { rows } = await query('SELECT id, email, name, created_at FROM users WHERE id = $1', [req.user.id]); const { rows } = await query('SELECT id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, created_at FROM users WHERE id = $1', [req.user.id]);
if (!rows[0]) return res.status(404).json({ error: 'User not found' }); if (!rows[0]) return res.status(404).json({ error: 'User not found' });
return res.json({ user: publicUser(rows[0]) }); return res.json({ user: publicUser(rows[0]) });
}); });
+71
View File
@@ -0,0 +1,71 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole } from './guards.js';
const router = Router();
router.use(requireAuth);
// GET /api/books/by-workspace/:workspaceId
router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { rows } = await query(
'SELECT * FROM books WHERE workspace_id = $1 ORDER BY position, updated_at DESC',
[req.params.workspaceId],
);
return res.json({ books: rows });
});
// POST /api/books { workspace_id, title?, icon? } (member+)
router.post('/', async (req, res) => {
const { workspace_id, title = 'Untitled book', icon = '📚' } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
const role = await getRole(workspace_id, req.user.id);
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
return res.status(403).json({ error: 'Viewers cannot create books' });
}
const { rows } = await query(
'INSERT INTO books (workspace_id, title, icon, created_by) VALUES ($1,$2,$3,$4) RETURNING *',
[workspace_id, String(title).slice(0, 200) || 'Untitled book', String(icon).slice(0, 20) || '📚', req.user.id],
);
return res.status(201).json({ book: rows[0] });
});
// PATCH /api/books/:id { title?, icon?, position? } (member+)
router.patch('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM books WHERE id = $1', [req.params.id]);
const book = found[0];
if (!book) return res.status(404).json({ error: 'Not found' });
const role = await getRole(book.workspace_id, req.user.id);
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
return res.status(403).json({ error: 'Viewers cannot edit' });
}
const { title, icon, position } = req.body ?? {};
const { rows } = await query(
`UPDATE books SET title = COALESCE($1, title), icon = COALESCE($2, icon),
position = COALESCE($3, position) WHERE id = $4 RETURNING *`,
[
title !== undefined ? String(title).slice(0, 200) : null,
icon !== undefined ? String(icon).slice(0, 20) : null,
position !== undefined ? Number(position) : null,
book.id,
],
);
return res.json({ book: rows[0] });
});
// DELETE /api/books/:id (member+; pages inside are removed via FK cascade)
router.delete('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM books WHERE id = $1', [req.params.id]);
const book = found[0];
if (!book) return res.status(404).json({ error: 'Not found' });
const role = await getRole(book.workspace_id, req.user.id);
if (role !== 'owner' && role !== 'admin' && role !== 'member') {
return res.status(403).json({ error: 'Viewers cannot delete' });
}
await query('DELETE FROM books WHERE id = $1', [book.id]);
return res.status(204).end();
});
export default router;
+7
View File
@@ -32,3 +32,10 @@ export async function getPageWithWorkspace(pageId) {
const { rows } = await query('SELECT * FROM pages WHERE id = $1', [pageId]); const { rows } = await query('SELECT * FROM pages WHERE id = $1', [pageId]);
return rows[0] || null; return rows[0] || null;
} }
/** Require global admin flag (set via web installer / admin dashboard). */
export async function requireAdmin(req, res, next) {
const { rows } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
if (!rows[0]?.is_admin) return res.status(403).json({ error: 'Admin only' });
return next();
}
+20 -6
View File
@@ -30,24 +30,31 @@ router.get('/by-workspace/:workspaceId', async (req, res) => {
return res.json({ pages: rows, role }); return res.json({ pages: rows, role });
}); });
// POST /api/pages { workspace_id, parent_id?, title? } // POST /api/pages { workspace_id, parent_id?, book_id?, title? }
router.post('/', async (req, res) => { router.post('/', async (req, res) => {
const { workspace_id, parent_id = null, title = 'Untitled' } = req.body ?? {}; const { workspace_id, parent_id = null, book_id = null, title = 'Untitled' } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' }); if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
const role = await getRole(workspace_id, req.user.id); const role = await getRole(workspace_id, req.user.id);
if (role !== 'owner' && role !== 'admin' && role !== 'member') { if (role !== 'owner' && role !== 'admin' && role !== 'member') {
return res.status(403).json({ error: 'Viewers cannot create pages' }); return res.status(403).json({ error: 'Viewers cannot create pages' });
} }
if (book_id) {
const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, workspace_id]);
if (!rows[0]) return res.status(400).json({ error: 'Invalid book_id' });
}
if (parent_id) { if (parent_id) {
const parent = await getPageWithWorkspace(parent_id); const parent = await getPageWithWorkspace(parent_id);
if (!parent || parent.workspace_id !== workspace_id) { if (!parent || parent.workspace_id !== workspace_id) {
return res.status(400).json({ error: 'Invalid parent_id' }); return res.status(400).json({ error: 'Invalid parent_id' });
} }
if ((parent.book_id || null) !== (book_id || null)) {
return res.status(400).json({ error: 'Parent lives in a different book' });
}
} }
const { rows } = await query( const { rows } = await query(
`INSERT INTO pages (workspace_id, parent_id, title, created_by) `INSERT INTO pages (workspace_id, parent_id, book_id, title, created_by)
VALUES ($1,$2,$3,$4) RETURNING *`, VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[workspace_id, parent_id, String(title).slice(0, 300) || 'Untitled', req.user.id], [workspace_id, parent_id, book_id, String(title).slice(0, 300) || 'Untitled', req.user.id],
); );
return res.status(201).json({ page: rows[0] }); return res.status(201).json({ page: rows[0] });
}); });
@@ -71,7 +78,7 @@ router.patch('/:id', async (req, res) => {
const page = await getPageWithWorkspace(req.params.id); const page = await getPageWithWorkspace(req.params.id);
if (!page) return res.status(404).json({ error: 'Not found' }); if (!page) return res.status(404).json({ error: 'Not found' });
if (!(await canWrite(req.user.id, page))) return res.status(403).json({ error: 'Viewers cannot edit' }); if (!(await canWrite(req.user.id, page))) return res.status(403).json({ error: 'Viewers cannot edit' });
const { title, icon, cover, content_json, content_html, position, parent_id, is_archived } = req.body ?? {}; const { title, icon, cover, content_json, content_html, position, parent_id, book_id, is_archived } = req.body ?? {};
if (parent_id !== undefined && parent_id !== null) { if (parent_id !== undefined && parent_id !== null) {
if (parent_id === page.id) return res.status(400).json({ error: 'Page cannot be its own parent' }); if (parent_id === page.id) return res.status(400).json({ error: 'Page cannot be its own parent' });
const parent = await getPageWithWorkspace(parent_id); const parent = await getPageWithWorkspace(parent_id);
@@ -79,6 +86,10 @@ router.patch('/:id', async (req, res) => {
return res.status(400).json({ error: 'Invalid parent_id' }); return res.status(400).json({ error: 'Invalid parent_id' });
} }
} }
if (book_id !== undefined && book_id !== null) {
const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, page.workspace_id]);
if (!rows[0]) return res.status(400).json({ error: 'Invalid book_id' });
}
const { rows } = await query( const { rows } = await query(
`UPDATE pages SET `UPDATE pages SET
title = COALESCE($1, title), title = COALESCE($1, title),
@@ -88,6 +99,7 @@ router.patch('/:id', async (req, res) => {
content_html = COALESCE($5, content_html), content_html = COALESCE($5, content_html),
position = COALESCE($6, position), position = COALESCE($6, position),
parent_id = CASE WHEN $8 THEN $7 ELSE parent_id END, parent_id = CASE WHEN $8 THEN $7 ELSE parent_id END,
book_id = CASE WHEN $12 THEN $11 ELSE book_id END,
is_archived = COALESCE($9, is_archived) is_archived = COALESCE($9, is_archived)
WHERE id = $10 RETURNING *`, WHERE id = $10 RETURNING *`,
[ [
@@ -101,6 +113,8 @@ router.patch('/:id', async (req, res) => {
parent_id !== undefined, parent_id !== undefined,
is_archived !== undefined ? Boolean(is_archived) : null, is_archived !== undefined ? Boolean(is_archived) : null,
page.id, page.id,
book_id !== undefined ? book_id : null,
book_id !== undefined,
], ],
); );
const updated = rows[0]; const updated = rows[0];
+128
View File
@@ -0,0 +1,128 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole } from './guards.js';
const router = Router();
router.use(requireAuth);
const STATUSES = ['todo', 'doing', 'done'];
const PRIORITIES = ['low', 'normal', 'high'];
async function canWrite(userId, workspaceId) {
const role = await getRole(workspaceId, userId);
return role === 'owner' || role === 'admin' || role === 'member';
}
async function checkLinks(workspaceId, book_id, page_id) {
if (book_id) {
const { rows } = await query('SELECT id FROM books WHERE id = $1 AND workspace_id = $2', [book_id, workspaceId]);
if (!rows[0]) return 'Invalid book_id for this workspace';
}
if (page_id) {
const { rows } = await query('SELECT id FROM pages WHERE id = $1 AND workspace_id = $2', [page_id, workspaceId]);
if (!rows[0]) return 'Invalid page_id for this workspace';
}
return null;
}
// GET /api/tasks/by-workspace/:workspaceId?status=&from=&to=
// from/to filter due_at (used by the calendar).
router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { status, from, to } = req.query;
const conds = ['t.workspace_id = $1'];
const params = [req.params.workspaceId];
if (status && STATUSES.includes(String(status))) {
params.push(String(status));
conds.push(`t.status = $${params.length}`);
}
if (from) {
params.push(new Date(String(from)));
conds.push(`t.due_at >= $${params.length}`);
}
if (to) {
params.push(new Date(String(to)));
conds.push(`t.due_at <= $${params.length}`);
}
const { rows } = await query(
`SELECT t.*, b.title AS book_title, p.title AS page_title
FROM tasks t LEFT JOIN books b ON b.id = t.book_id LEFT JOIN pages p ON p.id = t.page_id
WHERE ${conds.join(' AND ')} ORDER BY t.due_at NULLS LAST, t.position, t.created_at`,
params,
);
return res.json({ tasks: rows });
});
// POST /api/tasks { workspace_id, book_id?, page_id?, title, status?, priority?, due_at? }
router.post('/', async (req, res) => {
const { workspace_id, book_id = null, page_id = null, title = '', status = 'todo', priority = 'normal', due_at = null } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
if (!(await canWrite(req.user.id, workspace_id))) {
return res.status(403).json({ error: 'Viewers cannot create tasks' });
}
if (String(title).trim().length < 1 || String(title).length > 300) {
return res.status(400).json({ error: 'Title must be 1-300 chars' });
}
if (!STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' });
if (!PRIORITIES.includes(priority)) return res.status(400).json({ error: 'Invalid priority' });
const linkErr = await checkLinks(workspace_id, book_id, page_id);
if (linkErr) return res.status(400).json({ error: linkErr });
const { rows } = await query(
`INSERT INTO tasks (workspace_id, book_id, page_id, title, status, priority, due_at, created_by)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[workspace_id, book_id, page_id, String(title).trim(), status, priority, due_at ? new Date(due_at) : null, req.user.id],
);
return res.status(201).json({ task: rows[0] });
});
// PATCH /api/tasks/:id
router.patch('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM tasks WHERE id = $1', [req.params.id]);
const task = found[0];
if (!task) return res.status(404).json({ error: 'Not found' });
if (!(await canWrite(req.user.id, task.workspace_id))) {
return res.status(403).json({ error: 'Viewers cannot edit' });
}
const { title, status, priority, due_at, book_id, page_id, position } = req.body ?? {};
if (status !== undefined && !STATUSES.includes(status)) return res.status(400).json({ error: 'Invalid status' });
if (priority !== undefined && !PRIORITIES.includes(priority)) return res.status(400).json({ error: 'Invalid priority' });
if (title !== undefined && (String(title).trim().length < 1 || String(title).length > 300)) {
return res.status(400).json({ error: 'Title must be 1-300 chars' });
}
const nextBook = book_id !== undefined ? book_id : task.book_id;
const nextPage = page_id !== undefined ? page_id : task.page_id;
const linkErr = await checkLinks(task.workspace_id, nextBook, nextPage);
if (linkErr) return res.status(400).json({ error: linkErr });
const sets = [];
const params = [];
const push = (col, val) => { params.push(val); sets.push(`${col} = $${params.length}`); };
if (title !== undefined) push('title', String(title).trim());
if (status !== undefined) push('status', status);
if (priority !== undefined) push('priority', priority);
if (due_at !== undefined) push('due_at', due_at ? new Date(due_at) : null);
if (book_id !== undefined) push('book_id', book_id);
if (page_id !== undefined) push('page_id', page_id);
if (position !== undefined) push('position', Number(position));
if (sets.length === 0) return res.json({ task });
params.push(task.id);
const { rows } = await query(
`UPDATE tasks SET ${sets.join(', ')} WHERE id = $${params.length} RETURNING *`,
params,
);
return res.json({ task: rows[0] });
});
// DELETE /api/tasks/:id
router.delete('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM tasks WHERE id = $1', [req.params.id]);
const task = found[0];
if (!task) return res.status(404).json({ error: 'Not found' });
if (!(await canWrite(req.user.id, task.workspace_id))) {
return res.status(403).json({ error: 'Viewers cannot delete' });
}
await query('DELETE FROM tasks WHERE id = $1', [task.id]);
return res.status(204).end();
});
export default router;
+116
View File
@@ -0,0 +1,116 @@
import { Router } from 'express';
import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js';
import { getRole } from './guards.js';
const router = Router();
router.use(requireAuth);
function serialize(row) {
if (!row) return row;
return row;
}
// GET /api/time/by-workspace/:workspaceId — entries + currently running timer
router.get('/by-workspace/:workspaceId', async (req, res) => {
const role = await getRole(req.params.workspaceId, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const mine = req.query.mine === '1';
const params = [req.params.workspaceId];
let cond = 'e.workspace_id = $1';
if (mine) {
params.push(req.user.id);
cond += ` AND e.user_id = $${params.length}`;
}
const { rows } = await query(
`SELECT e.*, b.title AS book_title, p.title AS page_title, u.name AS user_name
FROM time_entries e
LEFT JOIN books b ON b.id = e.book_id
LEFT JOIN pages p ON p.id = e.page_id
LEFT JOIN users u ON u.id = e.user_id
WHERE ${cond} ORDER BY e.started_at DESC LIMIT 200`,
params,
);
const open = rows.find((r) => r.user_id === req.user.id && !r.ended_at) || null;
return res.json({ entries: rows, open });
});
// POST /api/time/start { workspace_id, book_id?, page_id?, description? }
router.post('/start', async (req, res) => {
const { workspace_id, book_id = null, page_id = null, description = '' } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
const role = await getRole(workspace_id, req.user.id);
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
const { rows: running } = await query(
'SELECT id FROM time_entries WHERE workspace_id = $1 AND user_id = $2 AND ended_at IS NULL',
[workspace_id, req.user.id],
);
if (running[0]) return res.status(409).json({ error: 'Timer already running', entry: running[0] });
const { rows } = await query(
`INSERT INTO time_entries (workspace_id, user_id, book_id, page_id, description)
VALUES ($1,$2,$3,$4,$5) RETURNING *`,
[workspace_id, req.user.id, book_id, page_id, String(description).slice(0, 500)],
);
return res.status(201).json({ entry: serialize(rows[0]) });
});
// POST /api/time/stop { workspace_id } — stops my running timer
router.post('/stop', async (req, res) => {
const { workspace_id } = req.body ?? {};
if (!workspace_id) return res.status(400).json({ error: 'workspace_id required' });
const { rows } = await query(
`UPDATE time_entries SET ended_at = now(),
duration_seconds = EXTRACT(EPOCH FROM (now() - started_at))::int
WHERE id = (
SELECT id FROM time_entries
WHERE workspace_id = $1 AND user_id = $2 AND ended_at IS NULL
ORDER BY started_at DESC LIMIT 1
) RETURNING *`,
[workspace_id, req.user.id],
);
if (!rows[0]) return res.status(404).json({ error: 'No running timer' });
return res.json({ entry: rows[0] });
});
// PATCH /api/time/:id { description?, book_id?, page_id? } (own or admin+)
router.patch('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM time_entries WHERE id = $1', [req.params.id]);
const entry = found[0];
if (!entry) return res.status(404).json({ error: 'Not found' });
const role = await getRole(entry.workspace_id, req.user.id);
const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
if (entry.user_id !== req.user.id && !me[0]?.is_admin && role !== 'owner' && role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
const { description, book_id, page_id } = req.body ?? {};
const { rows } = await query(
`UPDATE time_entries SET description = COALESCE($1, description),
book_id = CASE WHEN $4 THEN $2 ELSE book_id END,
page_id = CASE WHEN $5 THEN $3 ELSE page_id END
WHERE id = $6 RETURNING *`,
[
description !== undefined ? String(description).slice(0, 500) : null,
book_id !== undefined ? book_id : null,
page_id !== undefined ? page_id : null,
book_id !== undefined, page_id !== undefined,
entry.id,
],
);
return res.json({ entry: rows[0] });
});
// DELETE /api/time/:id (own or admin+)
router.delete('/:id', async (req, res) => {
const { rows: found } = await query('SELECT * FROM time_entries WHERE id = $1', [req.params.id]);
const entry = found[0];
if (!entry) return res.status(404).json({ error: 'Not found' });
const role = await getRole(entry.workspace_id, req.user.id);
const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
if (entry.user_id !== req.user.id && !me[0]?.is_admin && role !== 'owner' && role !== 'admin') {
return res.status(403).json({ error: 'Forbidden' });
}
await query('DELETE FROM time_entries WHERE id = $1', [entry.id]);
return res.status(204).end();
});
export default router;
+97 -4
View File
@@ -1,18 +1,111 @@
import { Router } from 'express'; import { Router } from 'express';
import bcrypt from 'bcryptjs';
import { query } from '../config/db.js'; import { query } from '../config/db.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
const router = Router(); const router = Router();
router.use(requireAuth); router.use(requireAuth);
// GET /api/users/search?email= — find user by email (for invites) const LOCALES = ['de', 'en', 'zh'];
const THEMES = ['light', 'dark', 'system'];
// GET /api/users/search?email= — find user by exact email (for invites)
router.get('/search', async (req, res) => { router.get('/search', async (req, res) => {
const { email = '' } = req.query; const { email = '' } = req.query;
if (!email) return res.status(400).json({ error: 'email required' }); if (!email) return res.status(400).json({ error: 'email required' });
const { rows } = await query('SELECT id, email, name FROM users WHERE email = $1', [ const { rows } = await query(
String(email).toLowerCase(), 'SELECT id, email, name, avatar FROM users WHERE email = $1',
]); [String(email).toLowerCase()],
);
return res.json({ user: rows[0] || null }); return res.json({ user: rows[0] || null });
}); });
// GET /api/users/discover?q= — fuzzy user discovery for invites/mentions.
// Returns id, name, email, avatar (never password hashes).
router.get('/discover', async (req, res) => {
const q = String(req.query.q || '').trim().slice(0, 60);
if (q.length < 2) return res.json({ users: [] });
const { rows } = await query(
`SELECT id, name, email, avatar FROM users
WHERE name ILIKE $1 OR email ILIKE $1
ORDER BY name LIMIT 10`,
[`%${q}%`],
);
return res.json({ users: rows });
});
// PATCH /api/users/me — profile + appearance settings
router.patch('/me', async (req, res) => {
const { name, email, avatar, locale, theme, bg_image, bg_blur } = req.body ?? {};
if (name !== undefined && (String(name).trim().length < 1 || String(name).length > 100)) {
return res.status(400).json({ error: 'Name must be 1-100 chars' });
}
if (email !== undefined && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(email))) {
return res.status(400).json({ error: 'Valid email required' });
}
if (locale !== undefined && !LOCALES.includes(locale)) {
return res.status(400).json({ error: 'Locale must be de, en or zh' });
}
if (theme !== undefined && !THEMES.includes(theme)) {
return res.status(400).json({ error: 'Theme must be light, dark or system' });
}
if (avatar !== undefined && String(avatar).length > 500) {
return res.status(400).json({ error: 'Avatar too long (max 500 chars, emoji or URL)' });
}
if (bg_image !== undefined && String(bg_image).length > 2000) {
return res.status(400).json({ error: 'Background URL too long' });
}
try {
const { rows } = await query(
`UPDATE users SET
name = COALESCE($1, name),
email = COALESCE($2, email),
avatar = COALESCE($3, avatar),
locale = COALESCE($4, locale),
theme = COALESCE($5, theme),
bg_image = COALESCE($6, bg_image),
bg_blur = COALESCE($7, bg_blur)
WHERE id = $8
RETURNING id, email, name, is_admin, avatar, locale, theme, bg_image, bg_blur, created_at`,
[
name !== undefined ? String(name).trim() : null,
email !== undefined ? String(email).toLowerCase() : null,
avatar !== undefined ? String(avatar).slice(0, 500) : null,
locale !== undefined ? locale : null,
theme !== undefined ? theme : null,
bg_image !== undefined ? String(bg_image).slice(0, 2000) : null,
bg_blur !== undefined ? Boolean(bg_blur) : null,
req.user.id,
],
);
const u = rows[0];
return res.json({
user: {
id: u.id, email: u.email, name: u.name, isAdmin: !!u.is_admin,
avatar: u.avatar || '', locale: u.locale, theme: u.theme,
bgImage: u.bg_image || '', bgBlur: u.bg_blur !== false, createdAt: u.created_at,
},
});
} catch (err) {
if (err.code === '23505') return res.status(409).json({ error: 'Email already taken' });
console.error('[users/me]', err.message);
return res.status(500).json({ error: 'Update failed' });
}
});
// PATCH /api/users/me/password { currentPassword, newPassword }
router.patch('/me/password', async (req, res) => {
const { currentPassword = '', newPassword = '' } = req.body ?? {};
if (String(newPassword).length < 8) {
return res.status(400).json({ error: 'New password must be at least 8 chars' });
}
const { rows } = await query('SELECT password_hash FROM users WHERE id = $1', [req.user.id]);
if (!rows[0]) return res.status(404).json({ error: 'User not found' });
const ok = await bcrypt.compare(String(currentPassword), rows[0].password_hash);
if (!ok) return res.status(401).json({ error: 'Current password is wrong' });
const hash = await bcrypt.hash(String(newPassword), 12);
await query('UPDATE users SET password_hash = $1 WHERE id = $2', [hash, req.user.id]);
return res.json({ ok: true });
});
export default router; export default router;
+1 -1
View File
@@ -59,7 +59,7 @@ router.delete('/:id', requireWorkspace('owner'), async (req, res) => {
// GET /api/workspaces/:id/members // GET /api/workspaces/:id/members
router.get('/:id/members', requireWorkspace('viewer'), async (req, res) => { router.get('/:id/members', requireWorkspace('viewer'), async (req, res) => {
const { rows } = await query( const { rows } = await query(
`SELECT u.id, u.email, u.name, m.role, m.created_at `SELECT u.id, u.email, u.name, u.avatar, m.role, m.created_at
FROM workspace_members m JOIN users u ON u.id = m.user_id FROM workspace_members m JOIN users u ON u.id = m.user_id
WHERE m.workspace_id = $1 ORDER BY m.created_at`, WHERE m.workspace_id = $1 ORDER BY m.created_at`,
[req.params.id], [req.params.id],
+26
View File
@@ -59,6 +59,32 @@ Content fields: `content_json` (TipTap JSON), `content_html` (rendered HTML).
## Users ## Users
- `GET /users/search?email=` — lookup for invites (auth required). - `GET /users/search?email=` — lookup for invites (auth required).
- `GET /users/discover?q=` — fuzzy discovery (min 2 chars, max 10 hits, no hashes).
- `PATCH /users/me` — profile/appearance (`name, email, avatar, locale, theme, bg_image, bg_blur`).
- `PATCH /users/me/password` — `{ currentPassword, newPassword }`.
## Books
- `GET /books/by-workspace/:workspaceId`, `POST /books {workspace_id,title?,icon?}`
- `PATCH /books/:id`, `DELETE /books/:id` (pages inside cascade).
## Tasks
- `GET /tasks/by-workspace/:workspaceId?status=&from=&to=` (calendar range on `due_at`)
- `POST /tasks {workspace_id, book_id?, page_id?, title, status?, priority?, due_at?}`
- `PATCH /tasks/:id`, `DELETE /tasks/:id`
## Time tracking
- `GET /time/by-workspace/:workspaceId` → `{ entries, open }`
- `POST /time/start {workspace_id, book_id?, page_id?, description?}` (409 if running)
- `POST /time/stop {workspace_id}` → sets `ended_at` + `duration_seconds`
- `PATCH /time/:id`, `DELETE /time/:id` (own or admin+)
## Admin (requires `is_admin`)
- `GET /admin/overview` → counts + recent users
- `GET /admin/users`, `PATCH /admin/users/:id {is_admin}`, `DELETE /admin/users/:id`
## Health ## Health
+86
View File
@@ -9,6 +9,12 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@tiptap/core": "^2.4.0", "@tiptap/core": "^2.4.0",
"@tiptap/extension-table": "^2.4.0",
"@tiptap/extension-table-cell": "^2.4.0",
"@tiptap/extension-table-header": "^2.4.0",
"@tiptap/extension-table-row": "^2.4.0",
"@tiptap/extension-task-item": "^2.4.0",
"@tiptap/extension-task-list": "^2.4.0",
"@tiptap/pm": "^2.4.0", "@tiptap/pm": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0", "@tiptap/starter-kit": "^2.4.0",
"@tiptap/vue-3": "^2.4.0", "@tiptap/vue-3": "^2.4.0",
@@ -1128,6 +1134,86 @@
"@tiptap/core": "^2.7.0" "@tiptap/core": "^2.7.0"
} }
}, },
"node_modules/@tiptap/extension-table": {
"version": "2.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.27.3.tgz",
"integrity": "sha512-xBDaT/ixkmrHKuCUlJNbzRolxjf0fFkHxybyeqfcGNDbVwOb6pOGZly6mGjBG1TXrx3U37y0jmFpwW6WU1zokA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-cell": {
"version": "2.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.27.3.tgz",
"integrity": "sha512-f697x9RDiva8EFZvLBxEp6Nx6sY5KRbHdNMhbCnqC1PrkRKNCTOib9ddWpqIERzs3HhmqZWL7XCPzvE/t8C+IQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-header": {
"version": "2.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.27.3.tgz",
"integrity": "sha512-x//GuYJlTzM3GavsDxBRDjGHMY9bbnmglQ0EAPVwQM5QHXpjwKzZN7wvDMM/RwxmxzYWd6Z+Fp7Zk8RYckpzhg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-table-row": {
"version": "2.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.27.3.tgz",
"integrity": "sha512-0ekXiw6aAESlcrKFmI90ar/j7XJmfdxeJEDeGZuMwOAQs+qq5RAB7ug5WPCZjG0Kqa+Kyxzb1jMw9pSkuTYp+w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-task-item": {
"version": "2.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.27.3.tgz",
"integrity": "sha512-7ES+qMoMgjb2iXI8iRzgegMeM1pE4zQAekT/5lQyuTv6oEyZw5s3TUZWeTYCq5TI/MezYGu3X2sV5PQnfBAsdA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-task-list": {
"version": "2.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.27.3.tgz",
"integrity": "sha512-T8Y3S95ZzYlh82KGd0H81QOsJZVWweUVps6l7wkiOReNsJxhkb4qW/smNE0TeNhvbU307emiPL0CH5e+eHxjEg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text": { "node_modules/@tiptap/extension-text": {
"version": "2.27.3", "version": "2.27.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.3.tgz", "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.3.tgz",
+6
View File
@@ -10,6 +10,12 @@
}, },
"dependencies": { "dependencies": {
"@tiptap/core": "^2.4.0", "@tiptap/core": "^2.4.0",
"@tiptap/extension-table": "^2.4.0",
"@tiptap/extension-table-cell": "^2.4.0",
"@tiptap/extension-table-header": "^2.4.0",
"@tiptap/extension-table-row": "^2.4.0",
"@tiptap/extension-task-item": "^2.4.0",
"@tiptap/extension-task-list": "^2.4.0",
"@tiptap/pm": "^2.4.0", "@tiptap/pm": "^2.4.0",
"@tiptap/starter-kit": "^2.4.0", "@tiptap/starter-kit": "^2.4.0",
"@tiptap/vue-3": "^2.4.0", "@tiptap/vue-3": "^2.4.0",
+28 -5
View File
@@ -1,17 +1,40 @@
<script setup> <script setup>
import { onMounted } from 'vue'; import { computed, onMounted } from 'vue';
import { useAuthStore } from './stores/auth';
import { useThemeStore } from './stores/theme'; import { useThemeStore } from './stores/theme';
import ThemeToggle from './components/ThemeToggle.vue'; import { useLocaleStore } from './stores/locale';
import { useUiStore } from './stores/ui';
import SettingsWindow from './components/SettingsWindow.vue';
import TimeTrackerButton from './components/TimeTrackerButton.vue';
const auth = useAuthStore();
const theme = useThemeStore(); const theme = useThemeStore();
onMounted(() => theme.apply()); const locale = useLocaleStore();
const ui = useUiStore();
const bgStyle = computed(() => {
const bg = auth.user?.bgImage || '';
if (!bg) return {};
const style = { backgroundImage: bg.startsWith('http') ? `url("${bg}")` : bg };
if (auth.user?.bgBlur !== false) style.filter = 'blur(14px) brightness(0.85)';
return style;
});
onMounted(() => {
locale.set(locale.locale);
theme.apply();
auth.applyPrefs();
});
</script> </script>
<template> <template>
<div> <div>
<div style="position: fixed; top: 12px; right: 12px; z-index: 50"> <div v-if="bgStyle.backgroundImage" class="bg-layer" :style="bgStyle"></div>
<ThemeToggle /> <div v-if="auth.isLoggedIn" class="top-right">
<TimeTrackerButton />
<button class="ghost icon-btn" @click="ui.openSettings()" title="⚙️">⚙️</button>
</div> </div>
<router-view /> <router-view />
<SettingsWindow />
</div> </div>
</template> </template>
+214 -60
View File
@@ -1,93 +1,247 @@
:root { :root {
--bg: #ffffff; --bg: #f6f4ef;
--bg-soft: #f7f7f5; --bg-soft: #efece4;
--sidebar: #fbfbfa; --sidebar: linear-gradient(180deg, #fdfcf9 0%, #f3f0e8 100%);
--text: #111111; --text: #1c1a15;
--muted: #6b6b6b; --muted: #8a8474;
--border: #e6e6e2; --border: #e3ddcf;
--accent: #2383e2; --accent: #7c5cf0;
--accent-soft: #e8f3fd; --accent-2: #00c2a8;
--card: #ffffff; --accent-soft: #ece5fd;
--input: #ffffff; --card: #fffdf8;
--input: #fffdf8;
--shadow: 0 6px 24px rgba(80, 60, 140, 0.1);
--radius: 14px;
} }
[data-theme='dark'] { [data-theme='dark'] {
--bg: #191919; --bg: #14131a;
--bg-soft: #202020; --bg-soft: #1d1c26;
--sidebar: #202020; --sidebar: linear-gradient(180deg, #1d1c26 0%, #16151d 100%);
--text: #e8e8e8; --text: #f0ede6;
--muted: #9b9b9b; --muted: #9a94a8;
--border: #2e2e2e; --border: #2d2b3a;
--accent: #4fa3e3; --accent: #a78bfa;
--accent-soft: #1e3a52; --accent-2: #2dd4bf;
--card: #202020; --accent-soft: #2c2547;
--input: #262626; --card: #1e1d28;
--input: #262534;
--shadow: 0 6px 24px rgba(0, 0, 0, 0.45);
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
transition: background 0.25s, color 0.25s;
}
.bg-layer {
position: fixed; inset: -20px; z-index: -1;
background-size: cover; background-position: center;
} }
a { color: var(--accent); } a { color: var(--accent); }
h2, h3, h4 { letter-spacing: 0.2px; }
button { button {
background: var(--accent); background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff; color: #fff; border: none; border-radius: 10px;
border: none; padding: 8px 14px; cursor: pointer; font-size: 14px; font-weight: 600;
border-radius: 6px; box-shadow: var(--shadow);
padding: 8px 14px;
cursor: pointer;
font-size: 14px;
} }
button.ghost { button.ghost {
background: transparent; background: transparent; color: var(--text);
color: var(--text); border: 1px solid var(--border); box-shadow: none;
border: 1px solid var(--border);
} }
button.mini { padding: 2px 8px; font-size: 13px; border-radius: 8px; }
button:disabled { opacity: 0.5; cursor: not-allowed; } button:disabled { opacity: 0.5; cursor: not-allowed; }
.icon-btn { font-size: 18px; padding: 4px 8px; }
input, select, textarea { input, select, textarea {
background: var(--input); background: var(--input); color: var(--text);
color: var(--text); border: 1px solid var(--border); border-radius: 10px;
border: 1px solid var(--border); padding: 8px 10px; font-size: 14px; width: 100%;
border-radius: 6px;
padding: 8px 10px;
font-size: 14px;
width: 100%;
} }
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
.layout { display: flex; min-height: 100vh; } .layout { display: flex; min-height: 100vh; }
.sidebar { .sidebar {
width: 280px; width: 292px; min-width: 292px; background: var(--sidebar);
background: var(--sidebar);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
padding: 16px; padding: 16px; display: flex; flex-direction: column; gap: 10px;
display: flex; max-height: 100vh; overflow-y: auto; position: sticky; top: 0;
flex-direction: column;
gap: 12px;
} }
.main { flex: 1; padding: 24px; max-width: 960px; } .main { flex: 1; padding: 28px; max-width: 1060px; }
.card { .card {
background: var(--card); background: var(--card); border: 1px solid var(--border);
border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; box-shadow: var(--shadow);
border-radius: 10px;
padding: 16px;
} }
.auth-wrap { max-width: 380px; margin: 10vh auto; } .auth-wrap { max-width: 400px; margin: 10vh auto; }
.auth-wrap h1 { margin-bottom: 4px; } .auth-wrap h1 { margin-bottom: 4px; }
.muted { color: var(--muted); font-size: 13px; } .muted { color: var(--muted); font-size: 13px; }
.row { display: flex; gap: 8px; align-items: center; } .row { display: flex; gap: 8px; align-items: center; }
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } .row.wrap { flex-wrap: wrap; }
.tree-item { padding: 4px 8px; border-radius: 6px; cursor: pointer; } .topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; gap: 10px; }
.topbar.wrap { flex-wrap: wrap; }
.top-right { position: fixed; top: 12px; right: 12px; z-index: 60; display: flex; gap: 8px; align-items: flex-start; }
/* Greeting */
.greeting {
display: flex; justify-content: space-between; align-items: center; gap: 12px;
background: linear-gradient(135deg, var(--accent-soft), transparent);
border: 1px solid var(--border); border-radius: var(--radius);
padding: 16px 20px; margin-bottom: 16px; box-shadow: var(--shadow);
}
.clock {
font-size: 26px; font-weight: 800; font-variant-numeric: tabular-nums;
background: var(--card); border: 1px solid var(--border);
padding: 6px 14px; border-radius: 12px; white-space: nowrap;
}
/* Sidebar bits */
.user-chip {
display: flex; align-items: center; gap: 8px;
background: var(--card); border: 1px solid var(--border);
border-radius: 12px; padding: 8px 10px;
}
.side-nav { display: flex; flex-direction: column; gap: 2px; }
.side-nav a {
text-decoration: none; color: var(--text); font-weight: 600;
padding: 7px 10px; border-radius: 10px;
}
.side-nav a:hover { background: var(--bg-soft); }
.side-nav a.router-link-exact-active { background: var(--accent-soft); }
.side-label { font-size: 12px; font-weight: 800; text-transform: uppercase; color: var(--muted); margin-top: 6px; }
.book { margin-bottom: 2px; }
.book-head { display: flex; justify-content: space-between; align-items: center; font-weight: 700; }
.book-pages { margin-left: 8px; border-left: 2px solid var(--border); padding-left: 4px; }
.side-footer { margin-top: auto; display: flex; justify-content: space-between; align-items: center; padding-top: 10px; }
.tree-item {
padding: 5px 8px; border-radius: 8px; cursor: pointer;
display: flex; justify-content: space-between; align-items: center; gap: 4px;
}
.tree-item:hover { background: var(--bg-soft); } .tree-item:hover { background: var(--bg-soft); }
.tree-item.active { background: var(--accent-soft); } .tree-item.active { background: var(--accent-soft); }
.presence { display: flex; gap: 6px; flex-wrap: wrap; } .search-results { margin-top: -4px; }
.avatar { .pill {
width: 26px; height: 26px; border-radius: 50%; background: var(--accent-soft); border: 1px solid var(--border);
background: var(--accent); color: #fff; border-radius: 20px; padding: 2px 10px; font-size: 12px; font-weight: 700;
display: inline-flex; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700;
} }
.tiptap { min-height: 300px; border: 1px solid var(--border); border-radius: 8px; padding: 12px; } .member-row { padding: 6px 0; border-top: 1px solid var(--border); }
.presence { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.avatar {
border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent-2));
color: #fff; display: inline-flex; align-items: center; justify-content: center;
font-weight: 700; overflow: hidden; flex-shrink: 0;
}
.link { color: var(--accent); cursor: pointer; }
/* Settings window 🪟 */
.settings-overlay {
position: fixed; inset: 0; z-index: 100;
background: rgba(0, 0, 0, 0.45); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.settings-window {
width: 640px; max-width: 100%; max-height: 88vh; overflow: hidden;
background: var(--card); border: 1px solid var(--border);
border-radius: 18px; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
display: flex; flex-direction: column;
}
.settings-titlebar {
display: flex; align-items: center; gap: 12px;
padding: 12px 16px; border-bottom: 1px solid var(--border);
background: var(--bg-soft);
}
.settings-titlebar strong { flex: 1; }
.traffic { display: flex; gap: 6px; }
.dot { width: 12px; height: 12px; border-radius: 50%; cursor: pointer; }
.dot.red { background: #ff5f57; } .dot.yellow { background: #febc2e; } .dot.green { background: #28c840; }
.settings-body { display: flex; min-height: 0; }
.settings-tabs {
display: flex; flex-direction: column; gap: 4px; min-width: 150px;
padding: 14px 10px; border-right: 1px solid var(--border);
}
.settings-tabs button { background: transparent; color: var(--text); box-shadow: none; text-align: left; }
.settings-tabs button.active { background: var(--accent-soft); }
.settings-content { flex: 1; padding: 18px; overflow-y: auto; }
.settings-content label { font-size: 13px; font-weight: 700; }
.emoji-row { display: flex; gap: 4px; flex-wrap: wrap; font-size: 22px; }
.emoji-row span { cursor: pointer; padding: 2px; border-radius: 8px; }
.emoji-row span:hover { background: var(--accent-soft); }
.preset-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; }
.preset {
height: 52px; border-radius: 10px; cursor: pointer;
border: 2px solid var(--border); background: var(--bg-soft);
display: flex; align-items: center; justify-content: center; font-size: 20px;
}
.preset.active { border-color: var(--accent); }
/* Discovery */
.discover { position: relative; }
.discover-results {
position: absolute; z-index: 20; width: 100%; margin-top: 4px;
max-height: 220px; overflow-y: auto; padding: 6px;
}
.discover-row { display: flex; gap: 8px; align-items: center; padding: 6px; border-radius: 8px; cursor: pointer; }
.discover-row:hover { background: var(--accent-soft); }
/* Tasks kanban */
.new-task { display: grid; gap: 8px; margin-bottom: 14px; }
.kanban { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; align-items: start; }
.kanban-col { background: var(--bg-soft); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px; }
.kanban-col h3 { margin: 4px 4px 10px; }
.task-card { display: grid; gap: 6px; margin-bottom: 8px; }
.task-card.overdue { border-color: #ef4444; }
.overdue-tag { color: #ef4444; font-weight: 800; }
/* Calendar */
.calendar {
display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px;
}
.cal-head { font-weight: 800; text-align: center; color: var(--muted); padding: 6px; }
.cal-cell {
min-height: 96px; background: var(--card); border: 1px solid var(--border);
border-radius: 10px; padding: 6px; overflow: hidden;
}
.cal-cell.today { border: 2px solid var(--accent); }
.cal-day { font-weight: 800; font-size: 13px; color: var(--muted); }
.cal-task {
font-size: 12px; background: var(--accent-soft); border-radius: 6px;
padding: 2px 4px; margin-top: 3px; cursor: pointer;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cal-task.done { opacity: 0.55; text-decoration: line-through; }
/* Tables + stats */
.data-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.data-table th { text-align: left; color: var(--muted); font-size: 12px; text-transform: uppercase; padding: 8px; }
.data-table td { padding: 8px; border-top: 1px solid var(--border); }
.data-table tr.running td { background: var(--accent-soft); }
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin-bottom: 14px; }
.stat { font-size: 26px; display: grid; gap: 2px; text-align: center; }
.stat strong { font-size: 30px; }
.running-banner { border: 2px solid #ef4444; margin-bottom: 12px; }
/* Timer */
.timetracker { position: relative; }
.timer-btn.running { background: linear-gradient(135deg, #ef4444, #f59e0b); font-variant-numeric: tabular-nums; }
.timer-pop {
position: absolute; right: 0; top: 44px; width: 280px; z-index: 70;
display: grid; gap: 8px;
}
/* Editor */
.tiptap { min-height: 300px; border: 1px solid var(--border); border-radius: 12px; padding: 14px; background: var(--card); }
.tiptap:focus { outline: 2px solid var(--accent); } .tiptap:focus { outline: 2px solid var(--accent); }
.tiptap table { border-collapse: collapse; width: 100%; margin: 8px 0; }
.tiptap th, .tiptap td { border: 1px solid var(--border); padding: 6px 10px; }
.tiptap th { background: var(--accent-soft); }
.tiptap ul[data-type='taskList'] { list-style: none; padding-left: 4px; }
.tiptap ul[data-type='taskList'] li { display: flex; gap: 8px; align-items: flex-start; }
.toolbar { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; } .toolbar { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
.toolbar button { padding: 4px 8px; font-size: 13px; } .toolbar button { padding: 5px 10px; font-size: 13px; }
.comment { border-top: 1px solid var(--border); padding: 8px 0; } .comment { border-top: 1px solid var(--border); padding: 8px 0; }
@media (max-width: 900px) {
.kanban { grid-template-columns: 1fr; }
.sidebar { width: 240px; min-width: 240px; }
.settings-body { flex-direction: column; }
.settings-tabs { flex-direction: row; border-right: none; border-bottom: 1px solid var(--border); overflow-x: auto; }
}
+14
View File
@@ -0,0 +1,14 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({ name: String, avatar: String, size: { type: Number, default: 26 } });
const isUrl = computed(() => (props.avatar || '').startsWith('http'));
const letter = computed(() => ((props.name || props.avatar || '?')[0] || '?').toUpperCase());
</script>
<template>
<span class="avatar" :style="{ width: size + 'px', height: size + 'px', fontSize: Math.round(size * 0.5) + 'px' }" :title="name">
<img v-if="isUrl" :src="avatar" :alt="name" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover" />
<span v-else-if="avatar">{{ avatar }}</span>
<span v-else>{{ letter }}</span>
</span>
</template>
@@ -0,0 +1,41 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useLocaleStore } from '../stores/locale';
const props = defineProps({ name: String });
const L = useLocaleStore();
const now = ref(new Date());
let timer = null;
onMounted(() => { timer = setInterval(() => (now.value = new Date()), 1000); });
onUnmounted(() => clearInterval(timer));
const period = computed(() => {
const h = now.value.getHours();
if (h >= 5 && h < 11) return { key: 'greet.morning', emoji: '🌅' };
if (h >= 11 && h < 14) return { key: 'greet.midday', emoji: '🍱' };
if (h >= 14 && h < 18) return { key: 'greet.afternoon', emoji: '☀️' };
if (h >= 18 && h < 23) return { key: 'greet.evening', emoji: '🌇' };
return { key: 'greet.night', emoji: '🌙' };
});
const clock = computed(() =>
now.value.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
);
const date = computed(() =>
now.value.toLocaleDateString(
L.locale === 'zh' ? 'zh-CN' : L.locale === 'de' ? 'de-DE' : 'en-US',
{ weekday: 'long', day: 'numeric', month: 'long' },
),
);
</script>
<template>
<div class="greeting">
<div>
<h2 style="margin: 0">{{ period.emoji }} {{ L.t(period.key) }}{{ name ? `, ${name}` : '' }}!</h2>
<p class="muted" style="margin: 4px 0 0">📅 {{ date }}</p>
</div>
<div class="clock">🕒 {{ clock }}</div>
</div>
</template>
+20 -6
View File
@@ -1,21 +1,33 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
import api from '../api'; import api from '../api';
import { useLocaleStore } from '../stores/locale';
import UserSearch from './UserSearch.vue';
const props = defineProps({ workspaceId: String }); const props = defineProps({ workspaceId: String });
const emit = defineEmits(['invited']); const emit = defineEmits(['invited']);
const L = useLocaleStore();
const email = ref(''); const email = ref('');
const picked = ref(null);
const role = ref('member'); const role = ref('member');
const msg = ref(''); const msg = ref('');
const err = ref(''); const err = ref('');
function onPick(u) {
picked.value = u;
email.value = u.email;
}
async function invite() { async function invite() {
msg.value = ''; msg.value = '';
err.value = ''; err.value = '';
const target = email.value.trim();
if (!target) return;
try { try {
await api.post(`/workspaces/${props.workspaceId}/members`, { email: email.value, role: role.value }); await api.post(`/workspaces/${props.workspaceId}/members`, { email: target, role: role.value });
msg.value = `Invited ${email.value} as ${role.value}`; msg.value = `🎉 ${target} → ${role.value}`;
email.value = ''; email.value = '';
picked.value = null;
emit('invited'); emit('invited');
} catch (e) { } catch (e) {
err.value = e.response?.data?.error || 'Invite failed'; err.value = e.response?.data?.error || 'Invite failed';
@@ -25,17 +37,19 @@ async function invite() {
<template> <template>
<div class="card"> <div class="card">
<h4>Invite collaborator</h4> <h4>{{ L.t('inv.title') }}</h4>
<p class="muted">They must register first with the same email.</p> <p class="muted">{{ L.t('inv.hint') }}</p>
<div class="row"> <UserSearch @pick="onPick" />
<div class="row" style="margin-top: 8px">
<input v-model="email" placeholder="teammate@example.com" /> <input v-model="email" placeholder="teammate@example.com" />
<select v-model="role" style="max-width: 130px"> <select v-model="role" style="max-width: 130px">
<option value="viewer">viewer</option> <option value="viewer">viewer</option>
<option value="member">member</option> <option value="member">member</option>
<option value="admin">admin</option> <option value="admin">admin</option>
</select> </select>
<button @click="invite">Invite</button> <button @click="invite">✉️ {{ L.t('inv.btn') }}</button>
</div> </div>
<p v-if="picked" class="muted">🔎 {{ picked.name }} &lt;{{ picked.email }}&gt;</p>
<p v-if="msg" style="color: green">{{ msg }}</p> <p v-if="msg" style="color: green">{{ msg }}</p>
<p v-if="err" style="color: red">{{ err }}</p> <p v-if="err" style="color: red">{{ err }}</p>
</div> </div>
+204
View File
@@ -0,0 +1,204 @@
<script setup>
import { reactive, ref, watch } from 'vue';
import api from '../api';
import { useAuthStore } from '../stores/auth';
import { useLocaleStore } from '../stores/locale';
import { useThemeStore } from '../stores/theme';
import { useUiStore } from '../stores/ui';
import Avatar from './Avatar.vue';
const auth = useAuthStore();
const L = useLocaleStore();
const theme = useThemeStore();
const ui = useUiStore();
const form = reactive({ name: '', email: '', avatar: '' });
const curPw = ref('');
const newPw = ref('');
const msg = ref('');
const err = ref('');
const bgUrl = ref('');
const bgBlur = ref(true);
const bgPreset = ref('none');
const GRADIENTS = {
none: '',
sunset: 'linear-gradient(135deg,#ff9a56,#ff5e78 55%,#a855f7)',
ocean: 'linear-gradient(135deg,#0ea5e9,#6366f1 55%,#14b8a6)',
forest: 'linear-gradient(135deg,#166534,#4d7c0f 60%,#a3a380)',
midnight: 'linear-gradient(135deg,#0f172a,#4c1d95 60%,#0ea5e9)',
};
const EMOJIS = ['🦊', '🐼', '🐸', '🚀', '🌙', '🔥', '🍩', '🎧', '🌵', '⚡'];
watch(
() => ui.settingsOpen,
(open) => {
if (open && auth.user) {
form.name = auth.user.name || '';
form.email = auth.user.email || '';
form.avatar = auth.user.avatar || '';
bgUrl.value = auth.user.bgImage?.startsWith('http') ? auth.user.bgImage : '';
bgPreset.value = Object.entries(GRADIENTS).find(
([, v]) => v && v === auth.user.bgImage,
)?.[0] || (auth.user.bgImage ? 'custom' : 'none');
bgBlur.value = auth.user.bgBlur !== false;
msg.value = '';
err.value = '';
}
},
);
function bgValue() {
if (bgPreset.value === 'custom') return bgUrl.value.trim();
return GRADIENTS[bgPreset.value] || '';
}
function persistUser(user) {
auth.user = user;
localStorage.setItem('user', JSON.stringify(user));
auth.applyPrefs();
}
async function saveProfile() {
msg.value = '';
err.value = '';
try {
const { data } = await api.patch('/users/me', {
name: form.name.trim(), email: form.email.trim(), avatar: form.avatar.trim(),
});
persistUser(data.user);
msg.value = L.t('set.saved');
} catch (e) {
err.value = e.response?.data?.error || 'Error';
}
}
async function changePw() {
msg.value = '';
err.value = '';
try {
await api.patch('/users/me/password', { currentPassword: curPw.value, newPassword: newPw.value });
curPw.value = '';
newPw.value = '';
msg.value = L.t('set.pwChanged');
} catch (e) {
err.value = e.response?.data?.error || 'Error';
}
}
async function setTheme(mode) {
theme.set(mode);
try {
const { data } = await api.patch('/users/me', { theme: mode });
persistUser(data.user);
} catch { /* offline-friendly: theme still applies locally */ }
}
async function setLocale(l) {
L.set(l);
try {
const { data } = await api.patch('/users/me', { locale: l });
persistUser(data.user);
} catch { /* keep local */ }
}
async function saveBackground() {
msg.value = '';
err.value = '';
try {
const { data } = await api.patch('/users/me', { bg_image: bgValue(), bg_blur: bgBlur.value });
persistUser(data.user);
msg.value = L.t('set.saved');
} catch (e) {
err.value = e.response?.data?.error || 'Error';
}
}
</script>
<template>
<div v-if="ui.settingsOpen" class="settings-overlay" @click.self="ui.closeSettings()">
<div class="settings-window">
<div class="settings-titlebar">
<div class="traffic">
<span class="dot red" @click="ui.closeSettings()"></span>
<span class="dot yellow"></span>
<span class="dot green"></span>
</div>
<strong>⚙️ {{ L.t('set.title') }}</strong>
<button class="ghost" @click="ui.closeSettings()">✕ {{ L.t('set.close') }}</button>
</div>
<div class="settings-body">
<nav class="settings-tabs">
<button :class="{ active: ui.settingsTab === 'profile' }" @click="ui.settingsTab = 'profile'">{{ L.t('set.profile') }}</button>
<button :class="{ active: ui.settingsTab === 'appearance' }" @click="ui.settingsTab = 'appearance'">{{ L.t('set.appearance') }}</button>
<button :class="{ active: ui.settingsTab === 'language' }" @click="ui.settingsTab = 'language'">{{ L.t('set.language') }}</button>
<button :class="{ active: ui.settingsTab === 'background' }" @click="ui.settingsTab = 'background'">{{ L.t('set.background') }}</button>
</nav>
<section class="settings-content">
<div v-if="ui.settingsTab === 'profile'" style="display: grid; gap: 10px">
<div class="row">
<Avatar :name="form.name" :avatar="form.avatar" :size="48" />
<div>
<label>{{ L.t('set.avatar') }}</label>
<input v-model="form.avatar" :placeholder="L.t('set.avatarHint')" />
</div>
</div>
<div class="emoji-row">
<span v-for="e in EMOJIS" :key="e" @click="form.avatar = e">{{ e }}</span>
</div>
<label>{{ L.t('set.name') }}</label>
<input v-model="form.name" />
<label>{{ L.t('set.email') }}</label>
<input v-model="form.email" type="email" />
<button @click="saveProfile">{{ L.t('set.save') }}</button>
<hr />
<label>{{ L.t('set.curPw') }}</label>
<input v-model="curPw" type="password" />
<label>{{ L.t('set.newPw') }}</label>
<input v-model="newPw" type="password" />
<button class="ghost" @click="changePw">{{ L.t('set.changePw') }}</button>
</div>
<div v-if="ui.settingsTab === 'appearance'" style="display: grid; gap: 10px">
<label>{{ L.t('set.theme') }}</label>
<div class="row">
<button :class="{ ghost: theme.mode !== 'light' }" @click="setTheme('light')">{{ L.t('set.light') }}</button>
<button :class="{ ghost: theme.mode !== 'dark' }" @click="setTheme('dark')">{{ L.t('set.dark') }}</button>
<button :class="{ ghost: theme.mode !== 'system' }" @click="setTheme('system')">{{ L.t('set.system') }}</button>
</div>
<p class="muted">👀 {{ L.t('set.theme') }}: <strong>{{ theme.resolved }}</strong></p>
</div>
<div v-if="ui.settingsTab === 'language'" style="display: grid; gap: 10px">
<button :class="{ ghost: L.locale !== 'de' }" @click="setLocale('de')">🇩🇪 Deutsch</button>
<button :class="{ ghost: L.locale !== 'en' }" @click="setLocale('en')">🇬🇧 English</button>
<button :class="{ ghost: L.locale !== 'zh' }" @click="setLocale('zh')">🇨🇳 中文</button>
</div>
<div v-if="ui.settingsTab === 'background'" style="display: grid; gap: 10px">
<div class="preset-grid">
<div
v-for="(grad, key) in GRADIENTS"
:key="key"
class="preset"
:class="{ active: bgPreset === key }"
:style="grad ? { background: grad } : {}"
@click="bgPreset = key"
>
{{ key === 'none' ? '🚫' : '' }}
</div>
</div>
<label>{{ L.t('set.bgUrl') }}</label>
<input v-model="bgUrl" placeholder="https://…" @input="bgPreset = 'custom'" />
<p class="muted">{{ L.t('set.bgUrlHint') }}</p>
<label class="row"><input type="checkbox" v-model="bgBlur" style="width: auto" /> {{ L.t('set.bgBlur') }}</label>
<button @click="saveBackground">{{ L.t('set.save') }}</button>
</div>
<p v-if="msg" style="color: green">{{ msg }}</p>
<p v-if="err" style="color: red">{{ err }}</p>
</section>
</div>
</div>
</div>
</template>
+153
View File
@@ -0,0 +1,153 @@
<script setup>
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { useAuthStore } from '../stores/auth';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
import { useUiStore } from '../stores/ui';
import PageTree from './PageTree.vue';
import Avatar from './Avatar.vue';
const props = defineProps({ activePageId: String });
const auth = useAuthStore();
const ws = useWorkspaceStore();
const L = useLocaleStore();
const ui = useUiStore();
const router = useRouter();
const newWs = ref('');
const newPage = ref('');
const newBook = ref('');
const search = ref('');
const results = ref([]);
const collapsed = ref(JSON.parse(localStorage.getItem('collapsedBooks') || '{}'));
function toggleBook(id) {
collapsed.value[id] = !collapsed.value[id];
localStorage.setItem('collapsedBooks', JSON.stringify(collapsed.value));
}
async function refresh() {
await ws.fetchBooks();
await ws.fetchPages();
}
async function switchWs(id) {
ws.setCurrent(id);
await refresh();
}
async function createWs() {
if (!newWs.value.trim()) return;
await ws.createWorkspace(newWs.value.trim());
newWs.value = '';
await refresh();
}
async function createPage(bookId = null) {
const title = newPage.value.trim() || 'Untitled';
const { data } = await api.post('/pages', {
workspace_id: ws.currentId, book_id: bookId, title,
});
newPage.value = '';
await ws.fetchPages();
router.push(`/page/${data.page.id}`);
}
async function createBook() {
if (!newBook.value.trim()) return;
await ws.createBook(newBook.value.trim(), '📚');
newBook.value = '';
}
async function deleteBook(id) {
if (!confirm('🗑️?')) return;
await api.delete(`/books/${id}`);
await refresh();
}
async function doSearch() {
if (!search.value.trim()) { results.value = []; return; }
const { data } = await api.get('/pages/search/query', {
params: { q: search.value, workspace_id: ws.currentId },
});
results.value = data.results;
}
function logout() {
auth.logout();
router.push('/login');
}
onMounted(refresh);
</script>
<template>
<aside class="sidebar">
<div class="user-chip">
<Avatar :name="auth.user?.name" :avatar="auth.user?.avatar" :size="32" />
<strong style="flex: 1">{{ auth.user?.name }}</strong>
<button class="ghost icon-btn" @click="ui.openSettings()" title="⚙️">⚙️</button>
</div>
<select :value="ws.currentId" @change="switchWs($event.target.value)">
<option v-for="w in ws.workspaces" :key="w.id" :value="w.id">{{ w.name }} ({{ w.role }})</option>
</select>
<div class="row">
<input v-model="newWs" :placeholder="L.t('side.newWorkspace')" @keyup.enter="createWs" />
<button @click="createWs">+</button>
</div>
<nav class="side-nav">
<router-link to="/">🏠 {{ L.t('nav.dashboard') }}</router-link>
<router-link to="/tasks">✅ {{ L.t('nav.tasks') }}</router-link>
<router-link to="/calendar">🗓️ {{ L.t('nav.calendar') }}</router-link>
<router-link to="/time">⏱️ {{ L.t('nav.time') }}</router-link>
<router-link v-if="auth.user?.isAdmin" to="/admin">🛡️ {{ L.t('nav.admin') }}</router-link>
</nav>
<input v-model="search" :placeholder="L.t('side.searchPh')" @input="doSearch" />
<div v-if="results.length" class="card search-results">
<div v-for="r in results" :key="r.id" class="tree-item" @click="router.push(`/page/${r.id}`)">
🔍 {{ r.title }}
</div>
</div>
<div class="side-label">{{ L.t('side.books') }}</div>
<div class="row">
<input v-model="newBook" :placeholder="L.t('side.newBookPh')" @keyup.enter="createBook" />
<button @click="createBook">+</button>
</div>
<div v-for="b in ws.books" :key="b.id" class="book">
<div class="tree-item book-head" @click="toggleBook(b.id)">
<span>{{ collapsed[b.id] ? '▶' : '▼' }} {{ b.icon }} {{ b.title }}</span>
<span>
<button class="ghost mini" @click.stop="createPage(b.id)" title="+">+</button>
<button class="ghost mini" @click.stop="deleteBook(b.id)" title="🗑️">🗑️</button>
</span>
</div>
<div v-if="!collapsed[b.id]" class="book-pages">
<PageTree
:nodes="ws.treeFor(b.id)" :active-id="activePageId"
@open="router.push(`/page/${$event}`)" @add-child="createPage(b.id)"
/>
</div>
</div>
<div class="side-label">{{ L.t('side.pages') }}</div>
<div class="row">
<input v-model="newPage" :placeholder="L.t('side.newPagePh')" @keyup.enter="createPage()" />
<button @click="createPage()">+</button>
</div>
<PageTree
:nodes="ws.tree()" :active-id="activePageId"
@open="router.push(`/page/${$event}`)" @add-child="createPage()"
/>
<div class="side-footer">
<span class="muted">{{ ws.role }}</span>
<button class="ghost" @click="logout">🚪 {{ L.t('nav.logout') }}</button>
</div>
</aside>
</template>
-12
View File
@@ -1,12 +0,0 @@
<script setup>
import { useThemeStore } from '../stores/theme';
const theme = useThemeStore();
</script>
<template>
<div class="row">
<button class="ghost" @click="theme.set('light')" :disabled="theme.resolved === 'light' && theme.mode !== 'system'">☀ Light</button>
<button class="ghost" @click="theme.set('dark')" :disabled="theme.resolved === 'dark' && theme.mode !== 'system'">🌙 Dark</button>
<button class="ghost" @click="theme.toggle()">Toggle</button>
</div>
</template>
@@ -0,0 +1,66 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useTimeStore, formatDuration } from '../stores/time';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
const time = useTimeStore();
const ws = useWorkspaceStore();
const L = useLocaleStore();
const router = useRouter();
const open = ref(false);
const desc = ref('');
const bookId = ref('');
const tick = ref(0);
let timer = null;
onMounted(() => { timer = setInterval(() => (tick.value += 1), 1000); });
onUnmounted(() => clearInterval(timer));
const elapsed = computed(() => {
void tick.value;
return time.open
? formatDuration((Date.now() - new Date(time.open.started_at).getTime()) / 1000)
: '00:00:00';
});
async function toggle() {
if (time.open) {
await time.stop();
open.value = false;
} else {
open.value = !open.value;
}
}
async function start() {
await time.start(desc.value, bookId.value || null);
desc.value = '';
open.value = false;
}
</script>
<template>
<div class="timetracker">
<button
class="ghost timer-btn"
:class="{ running: !!time.open }"
@click="toggle"
:title="L.t('time.title')"
>
⏱️ {{ time.open ? elapsed : L.t('time.start') }}
</button>
<div v-if="open && !time.open" class="card timer-pop">
<input v-model="desc" :placeholder="L.t('time.descPh')" />
<select v-model="bookId">
<option value="">{{ L.t('time.book') }}: {{ L.t('time.none') }}</option>
<option v-for="b in ws.books" :key="b.id" :value="b.id">{{ b.icon }} {{ b.title }}</option>
</select>
<div class="row">
<button @click="start">{{ L.t('time.start') }}</button>
<button class="ghost" @click="router.push('/time')">{{ L.t('time.entries') }}</button>
</div>
</div>
</div>
</template>
+53
View File
@@ -0,0 +1,53 @@
<script setup>
import { ref, watch } from 'vue';
import api from '../api';
import { useLocaleStore } from '../stores/locale';
import Avatar from './Avatar.vue';
const props = defineProps({ workspaceId: String });
const emit = defineEmits(['pick']);
const L = useLocaleStore();
const q = ref('');
const results = ref([]);
const busy = ref(false);
let timer = null;
watch(q, () => {
clearTimeout(timer);
timer = setTimeout(search, 250);
});
async function search() {
const query = q.value.trim();
if (query.length < 2) { results.value = []; return; }
busy.value = true;
try {
const { data } = await api.get('/users/discover', { params: { q: query } });
results.value = data.users;
} catch {
results.value = [];
} finally {
busy.value = false;
}
}
function pick(u) {
emit('pick', u);
q.value = '';
results.value = [];
}
</script>
<template>
<div class="discover">
<input v-model="q" :placeholder="L.t('inv.ph')" />
<div v-if="q.trim().length >= 2" class="discover-results card">
<div v-if="busy" class="muted">{{ L.t('c.loading') }}</div>
<div v-for="u in results" :key="u.id" class="discover-row" @click="pick(u)">
<Avatar :name="u.name" :avatar="u.avatar" :size="24" />
<span><strong>{{ u.name }}</strong><br /><span class="muted">{{ u.email }}</span></span>
</div>
<div v-if="!busy && !results.length" class="muted">∅</div>
</div>
</div>
</template>
+61
View File
@@ -0,0 +1,61 @@
export default {
nav: {
dashboard: 'Dashboard', tasks: 'Aufgaben', calendar: 'Kalender', time: 'Zeiterfassung',
admin: 'Admin', logout: 'Abmelden',
},
side: {
workspaces: 'Workspaces', newWorkspace: 'Neuer Workspace', searchPh: 'Seiten suchen …',
newPagePh: 'Neue Seite', newBookPh: 'Neues Book', books: '📚 Books', pages: '📄 Seiten',
members: 'Mitglieder', role: 'Rolle',
},
greet: {
morning: 'Guten Morgen', midday: 'Mahlzeit', afternoon: 'Guten Tag',
evening: 'Guten Abend', night: 'Gute Nacht',
},
set: {
title: 'Einstellungen', profile: '👤 Profil', appearance: '🎨 Darstellung',
language: '🌍 Sprache', background: '🖼️ Hintergrund',
name: 'Anzeigename', email: 'E-Mail', avatar: 'Profilbild (Emoji oder Bild-URL)',
avatarHint: 'Z. B. 🦊 oder https://…/foto.png',
curPw: 'Aktuelles Passwort', newPw: 'Neues Passwort (min. 8 Zeichen)',
changePw: 'Passwort ändern', pwChanged: 'Passwort geändert ✅',
save: 'Speichern', saved: 'Gespeichert ✅', theme: 'Farbschema',
light: '☀️ Hell', dark: '🌙 Dunkel', system: '🖥️ System',
bgUrl: 'Hintergrundbild-URL', bgUrlHint: 'Leer lassen = kein Bild. Oder Preset wählen:',
bgBlur: 'Hintergrund weichzeichnen (Blur)', close: 'Schließen',
presetNone: 'Kein Bild', presetCustom: 'Eigene URL',
},
task: {
title: 'Aufgaben', new: 'Neue Aufgabe', ph: 'Was ist zu tun?',
todo: '📝 Offen', doing: '🚧 In Arbeit', done: '✅ Erledigt',
priority: 'Prio', low: 'Niedrig', normal: 'Normal', high: 'Hoch',
due: 'Fällig', noDue: 'Ohne Termin', book: 'Book', page: 'Seite',
none: '—', overdue: 'Überfällig!', toTodo: '← Offen', toDoing: 'In Arbeit →',
toDone: 'Erledigt →', reopen: '↩ Öffnen',
},
cal: { title: 'Kalender', today: 'Heute' },
time: {
title: 'Zeiterfassung', start: '▶ Start', stop: '⏹ Stopp',
descPh: 'Woran arbeitest du?', book: 'Book', none: '—',
entries: 'Einträge', dur: 'Dauer', running: 'läuft …',
total: 'Gesamt', noEntries: 'Noch keine Einträge. Starte oben den Timer! ⏱️',
started: 'Start', ended: 'Ende',
},
admin: {
title: 'Admin-Dashboard', overview: '📊 Übersicht', users: '👥 Benutzer',
name: 'Name', email: 'E-Mail', role: 'Rolle', admin: 'Admin', user: 'User',
workspaces: 'Workspaces', joined: 'Dabei seit', makeAdmin: 'Zum Admin machen',
unmake: 'Admin entziehen', del: 'Löschen', confirm: 'Wirklich löschen?',
you: '(du)', books: 'Books', pages: 'Seiten', tasks: 'Aufgaben', time: 'Zeiteinträge',
},
c: {
save: 'Speichern', cancel: 'Abbrechen', delete: 'Löschen', create: 'Anlegen',
back: 'Zurück', loading: 'Lädt …',
},
inv: {
title: '✉️ Mitarbeiter einladen', hint: 'Tippen zum Suchen – oder volle E-Mail eingeben.',
ph: 'Name oder E-Mail suchen …', btn: 'Einladen', done: 'Eingeladen',
pick: 'Person wählen …',
},
com: { title: 'Kommentare', ph: 'Kommentar schreiben …', post: 'Posten' },
};
+61
View File
@@ -0,0 +1,61 @@
export default {
nav: {
dashboard: 'Dashboard', tasks: 'Tasks', calendar: 'Calendar', time: 'Time tracking',
admin: 'Admin', logout: 'Log out',
},
side: {
workspaces: 'Workspaces', newWorkspace: 'New workspace', searchPh: 'Search pages …',
newPagePh: 'New page', newBookPh: 'New book', books: '📚 Books', pages: '📄 Pages',
members: 'Members', role: 'Role',
},
greet: {
morning: 'Good morning', midday: 'Enjoy your lunch', afternoon: 'Good afternoon',
evening: 'Good evening', night: 'Good night',
},
set: {
title: 'Settings', profile: '👤 Profile', appearance: '🎨 Appearance',
language: '🌍 Language', background: '🖼️ Background',
name: 'Display name', email: 'Email', avatar: 'Avatar (emoji or image URL)',
avatarHint: 'E.g. 🦊 or https://…/photo.png',
curPw: 'Current password', newPw: 'New password (min. 8 chars)',
changePw: 'Change password', pwChanged: 'Password changed ✅',
save: 'Save', saved: 'Saved ✅', theme: 'Color scheme',
light: '☀️ Light', dark: '🌙 Dark', system: '🖥️ System',
bgUrl: 'Background image URL', bgUrlHint: 'Empty = no image. Or pick a preset:',
bgBlur: 'Blur background', close: 'Close',
presetNone: 'No image', presetCustom: 'Custom URL',
},
task: {
title: 'Tasks', new: 'New task', ph: 'What needs doing?',
todo: '📝 Todo', doing: '🚧 Doing', done: '✅ Done',
priority: 'Priority', low: 'Low', normal: 'Normal', high: 'High',
due: 'Due', noDue: 'No due date', book: 'Book', page: 'Page',
none: '—', overdue: 'Overdue!', toTodo: '← Todo', toDoing: 'Doing →',
toDone: 'Done →', reopen: '↩ Reopen',
},
cal: { title: 'Calendar', today: 'Today' },
time: {
title: 'Time tracking', start: '▶ Start', stop: '⏹ Stop',
descPh: 'What are you working on?', book: 'Book', none: '—',
entries: 'Entries', dur: 'Duration', running: 'running …',
total: 'Total', noEntries: 'No entries yet. Start the timer above! ⏱️',
started: 'Started', ended: 'Ended',
},
admin: {
title: 'Admin dashboard', overview: '📊 Overview', users: '👥 Users',
name: 'Name', email: 'Email', role: 'Role', admin: 'Admin', user: 'User',
workspaces: 'Workspaces', joined: 'Joined', makeAdmin: 'Make admin',
unmake: 'Remove admin', del: 'Delete', confirm: 'Really delete?',
you: '(you)', books: 'Books', pages: 'Pages', tasks: 'Tasks', time: 'Time entries',
},
c: {
save: 'Save', cancel: 'Cancel', delete: 'Delete', create: 'Create',
back: 'Back', loading: 'Loading …',
},
inv: {
title: '✉️ Invite collaborator', hint: 'Type to search – or enter a full email.',
ph: 'Search name or email …', btn: 'Invite', done: 'Invited',
pick: 'Pick a person …',
},
com: { title: 'Comments', ph: 'Write a comment …', post: 'Post' },
};
+61
View File
@@ -0,0 +1,61 @@
export default {
nav: {
dashboard: '仪表盘', tasks: '任务', calendar: '日历', time: '时间记录',
admin: '管理', logout: '退出登录',
},
side: {
workspaces: '工作区', newWorkspace: '新建工作区', searchPh: '搜索页面 …',
newPagePh: '新建页面', newBookPh: '新建书册', books: '📚 书册', pages: '📄 页面',
members: '成员', role: '角色',
},
greet: {
morning: '早上好', midday: '午餐愉快', afternoon: '下午好',
evening: '晚上好', night: '晚安',
},
set: {
title: '设置', profile: '👤 个人资料', appearance: '🎨 外观',
language: '🌍 语言', background: '🖼️ 背景',
name: '显示名称', email: '电子邮件', avatar: '头像(表情或图片链接)',
avatarHint: '例如 🦊 或 https://…/photo.png',
curPw: '当前密码', newPw: '新密码(至少 8 位)',
changePw: '修改密码', pwChanged: '密码已修改 ✅',
save: '保存', saved: '已保存 ✅', theme: '配色方案',
light: '☀️ 浅色', dark: '🌙 深色', system: '🖥️ 跟随系统',
bgUrl: '背景图片链接', bgUrlHint: '留空 = 无背景,或选择预设:',
bgBlur: '模糊背景', close: '关闭',
presetNone: '无背景', presetCustom: '自定义链接',
},
task: {
title: '任务', new: '新建任务', ph: '要做什么?',
todo: '📝 待办', doing: '🚧 进行中', done: '✅ 已完成',
priority: '优先级', low: '低', normal: '中', high: '高',
due: '截止', noDue: '无截止日期', book: '书册', page: '页面',
none: '—', overdue: '已逾期!', toTodo: '← 待办', toDoing: '进行中 →',
toDone: '完成 →', reopen: '↩ 重新打开',
},
cal: { title: '日历', today: '今天' },
time: {
title: '时间记录', start: '▶ 开始', stop: '⏹ 停止',
descPh: '在忙什么?', book: '书册', none: '—',
entries: '记录', dur: '时长', running: '计时中 …',
total: '总计', noEntries: '还没有记录,点击上方开始计时!⏱️',
started: '开始', ended: '结束',
},
admin: {
title: '管理面板', overview: '📊 总览', users: '👥 用户',
name: '名称', email: '邮箱', role: '角色', admin: '管理员', user: '用户',
workspaces: '工作区', joined: '加入时间', makeAdmin: '设为管理员',
unmake: '取消管理员', del: '删除', confirm: '确认删除?',
you: '(你)', books: '书册', pages: '页面', tasks: '任务', time: '时间记录',
},
c: {
save: '保存', cancel: '取消', delete: '删除', create: '创建',
back: '返回', loading: '加载中 …',
},
inv: {
title: '✉️ 邀请协作者', hint: '输入搜索,或填写完整邮箱。',
ph: '搜索姓名或邮箱 …', btn: '邀请', done: '已邀请',
pick: '选择人员 …',
},
com: { title: '评论', ph: '写评论 …', post: '发送' },
};
+2
View File
@@ -3,10 +3,12 @@ import { createPinia } from 'pinia';
import App from './App.vue'; import App from './App.vue';
import router from './router'; import router from './router';
import { useThemeStore } from './stores/theme'; import { useThemeStore } from './stores/theme';
import { useLocaleStore } from './stores/locale';
import './assets/style.css'; import './assets/style.css';
const app = createApp(App); const app = createApp(App);
app.use(createPinia()); app.use(createPinia());
app.use(router); app.use(router);
app.mount('#app'); app.mount('#app');
useLocaleStore().set(localStorage.getItem('locale') || 'en');
useThemeStore().apply(); useThemeStore().apply();
+9
View File
@@ -6,6 +6,10 @@ import Register from '../views/Register.vue';
import Setup from '../views/Setup.vue'; import Setup from '../views/Setup.vue';
import Dashboard from '../views/Dashboard.vue'; import Dashboard from '../views/Dashboard.vue';
import PageEditor from '../views/PageEditor.vue'; import PageEditor from '../views/PageEditor.vue';
import Tasks from '../views/Tasks.vue';
import Calendar from '../views/Calendar.vue';
import Time from '../views/Time.vue';
import Admin from '../views/Admin.vue';
const routes = [ const routes = [
{ path: '/setup', component: Setup }, { path: '/setup', component: Setup },
@@ -13,6 +17,10 @@ const routes = [
{ path: '/register', component: Register, meta: { guest: true } }, { path: '/register', component: Register, meta: { guest: true } },
{ path: '/', component: Dashboard, meta: { auth: true } }, { path: '/', component: Dashboard, meta: { auth: true } },
{ path: '/page/:id', component: PageEditor, meta: { auth: true } }, { path: '/page/:id', component: PageEditor, meta: { auth: true } },
{ path: '/tasks', component: Tasks, meta: { auth: true } },
{ path: '/calendar', component: Calendar, meta: { auth: true } },
{ path: '/time', component: Time, meta: { auth: true } },
{ path: '/admin', component: Admin, meta: { auth: true, admin: true } },
]; ];
const router = createRouter({ history: createWebHistory(), routes }); const router = createRouter({ history: createWebHistory(), routes });
@@ -25,6 +33,7 @@ router.beforeEach(async (to) => {
const auth = useAuthStore(); const auth = useAuthStore();
if (to.meta.auth && !auth.isLoggedIn) return '/login'; if (to.meta.auth && !auth.isLoggedIn) return '/login';
if (to.meta.guest && auth.isLoggedIn) return '/'; if (to.meta.guest && auth.isLoggedIn) return '/';
if (to.meta.admin && !auth.user?.isAdmin) return '/';
return true; return true;
}); });
+9
View File
@@ -1,6 +1,8 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import api from '../api'; import api from '../api';
import { connectSocket, disconnectSocket } from '../socket'; import { connectSocket, disconnectSocket } from '../socket';
import { useLocaleStore } from './locale';
import { useThemeStore } from './theme';
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
@@ -11,6 +13,11 @@ export const useAuthStore = defineStore('auth', {
isLoggedIn: (s) => !!s.token, isLoggedIn: (s) => !!s.token,
}, },
actions: { actions: {
applyPrefs() {
if (!this.user) return;
useLocaleStore().set(this.user.locale || 'en');
useThemeStore().set(this.user.theme || 'system');
},
async register(email, name, password) { async register(email, name, password) {
const { data } = await api.post('/auth/register', { email, name, password }); const { data } = await api.post('/auth/register', { email, name, password });
this.apply(data); this.apply(data);
@@ -24,6 +31,7 @@ export const useAuthStore = defineStore('auth', {
this.token = data.token; this.token = data.token;
localStorage.setItem('user', JSON.stringify(data.user)); localStorage.setItem('user', JSON.stringify(data.user));
localStorage.setItem('token', data.token); localStorage.setItem('token', data.token);
this.applyPrefs();
connectSocket(); connectSocket();
}, },
async fetchMe() { async fetchMe() {
@@ -31,6 +39,7 @@ export const useAuthStore = defineStore('auth', {
const { data } = await api.get('/auth/me'); const { data } = await api.get('/auth/me');
this.user = data.user; this.user = data.user;
localStorage.setItem('user', JSON.stringify(data.user)); localStorage.setItem('user', JSON.stringify(data.user));
this.applyPrefs();
}, },
logout() { logout() {
this.user = null; this.user = null;
+34
View File
@@ -0,0 +1,34 @@
import { defineStore } from 'pinia';
import de from '../i18n/de';
import en from '../i18n/en';
import zh from '../i18n/zh';
const dicts = { de, en, zh };
export const useLocaleStore = defineStore('locale', {
state: () => ({
locale: localStorage.getItem('locale') || 'en',
}),
actions: {
set(l) {
if (!dicts[l]) return;
this.locale = l;
localStorage.setItem('locale', l);
document.documentElement.lang = l === 'zh' ? 'zh-CN' : l;
},
t(path) {
const dict = dicts[this.locale] || en;
const parts = String(path).split('.');
let node = dict;
for (const p of parts) {
node = node?.[p];
if (node === undefined) break;
}
if (typeof node === 'string') return node;
// fallback to English, then to the key itself
let fb = en;
for (const p of parts) fb = fb?.[p];
return typeof fb === 'string' ? fb : path;
},
},
});
+52
View File
@@ -0,0 +1,52 @@
import { defineStore } from 'pinia';
import api from '../api';
export function formatDuration(totalSeconds) {
const s = Math.max(0, Math.floor(totalSeconds || 0));
const h = String(Math.floor(s / 3600)).padStart(2, '0');
const m = String(Math.floor((s % 3600) / 60)).padStart(2, '0');
const sec = String(s % 60).padStart(2, '0');
return `${h}:${m}:${sec}`;
}
export const useTimeStore = defineStore('time', {
state: () => ({
entries: [],
open: null, // running timer entry (ended_at == null)
workspaceId: null,
}),
getters: {
elapsed: (s) => {
if (!s.open) return 0;
return (Date.now() - new Date(s.open.started_at).getTime()) / 1000;
},
},
actions: {
async fetch(workspaceId) {
this.workspaceId = workspaceId;
const { data } = await api.get(`/time/by-workspace/${workspaceId}`);
this.entries = data.entries;
this.open = data.open;
},
async start(description = '', book_id = null) {
const { data } = await api.post('/time/start', {
workspace_id: this.workspaceId, description, book_id,
});
this.open = data.entry;
this.entries.unshift(data.entry);
return data.entry;
},
async stop() {
const { data } = await api.post('/time/stop', { workspace_id: this.workspaceId });
this.open = null;
const idx = this.entries.findIndex((e) => e.id === data.entry.id);
if (idx >= 0) this.entries[idx] = { ...data.entry, user_name: this.entries[idx].user_name };
else this.entries.unshift(data.entry);
return data.entry;
},
async remove(id) {
await api.delete(`/time/${id}`);
this.entries = this.entries.filter((e) => e.id !== id);
},
},
});
+19
View File
@@ -0,0 +1,19 @@
import { defineStore } from 'pinia';
import api from '../api';
// Global UI state: settings window visibility.
export const useUiStore = defineStore('ui', {
state: () => ({
settingsOpen: false,
settingsTab: 'profile',
}),
actions: {
openSettings(tab = 'profile') {
this.settingsTab = tab;
this.settingsOpen = true;
},
closeSettings() {
this.settingsOpen = false;
},
},
});
+21 -1
View File
@@ -6,6 +6,7 @@ export const useWorkspaceStore = defineStore('workspace', {
workspaces: [], workspaces: [],
currentId: localStorage.getItem('workspaceId') || null, currentId: localStorage.getItem('workspaceId') || null,
pages: [], pages: [],
books: [],
role: null, role: null,
}), }),
actions: { actions: {
@@ -32,9 +33,28 @@ export const useWorkspaceStore = defineStore('workspace', {
this.role = data.role; this.role = data.role;
return this.pages; return this.pages;
}, },
async fetchBooks() {
if (!this.currentId) return [];
const { data } = await api.get(`/books/by-workspace/${this.currentId}`);
this.books = data.books;
return this.books;
},
async createBook(title, icon) {
const { data } = await api.post('/books', {
workspace_id: this.currentId, title, icon,
});
this.books.push(data.book);
return data.book;
},
// Tree of top-level pages (book_id == null), nested via parent_id.
tree() { tree() {
return this.treeFor(null);
},
// Tree of pages inside one book.
treeFor(bookId) {
const inScope = this.pages.filter((p) => (p.book_id || null) === (bookId || null));
const byParent = new Map(); const byParent = new Map();
for (const p of this.pages) { for (const p of inScope) {
const key = p.parent_id || 'root'; const key = p.parent_id || 'root';
if (!byParent.has(key)) byParent.set(key, []); if (!byParent.has(key)) byParent.set(key, []);
byParent.get(key).push(p); byParent.get(key).push(p);
+83
View File
@@ -0,0 +1,83 @@
<script setup>
import { onMounted, ref } from 'vue';
import api from '../api';
import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
import Avatar from '../components/Avatar.vue';
const L = useLocaleStore();
const counts = ref({});
const users = ref([]);
const recent = ref([]);
async function load() {
const { data } = await api.get('/admin/overview');
counts.value = data.counts;
recent.value = data.recentUsers;
const u = await api.get('/admin/users');
users.value = u.data.users;
}
async function setAdmin(u, val) {
const { data } = await api.patch(`/admin/users/${u.id}`, { is_admin: val });
u.is_admin = data.user.is_admin;
load();
}
async function removeUser(u) {
if (!confirm(`${L.t('admin.confirm')} (${u.email})`)) return;
await api.delete(`/admin/users/${u.id}`);
users.value = users.value.filter((x) => x.id !== u.id);
}
onMounted(load);
</script>
<template>
<div class="layout">
<Sidebar />
<main class="main">
<h2>🛡️ {{ L.t('admin.title') }}</h2>
<h3>{{ L.t('admin.overview') }}</h3>
<div class="stat-grid">
<div class="card stat">👥<strong>{{ counts.users }}</strong>{{ L.t('admin.users') }}</div>
<div class="card stat">🏠<strong>{{ counts.workspaces }}</strong>{{ L.t('nav.dashboard') }}</div>
<div class="card stat">📚<strong>{{ counts.books }}</strong>{{ L.t('admin.books') }}</div>
<div class="card stat">📄<strong>{{ counts.pages }}</strong>{{ L.t('admin.pages') }}</div>
<div class="card stat">✅<strong>{{ counts.tasks }}</strong>{{ L.t('admin.tasks') }}</div>
<div class="card stat">⏱️<strong>{{ counts.timeEntries }}</strong>{{ L.t('admin.time') }}</div>
</div>
<h3>{{ L.t('admin.users') }} ({{ users.length }})</h3>
<div class="card">
<table class="data-table">
<thead>
<tr>
<th></th>
<th>{{ L.t('admin.name') }}</th>
<th>{{ L.t('admin.email') }}</th>
<th>{{ L.t('admin.role') }}</th>
<th>{{ L.t('admin.workspaces') }}</th>
<th>{{ L.t('admin.joined') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="u in users" :key="u.id">
<td><Avatar :name="u.name" :size="24" /></td>
<td><strong>{{ u.name }}</strong></td>
<td class="muted">{{ u.email }}</td>
<td>{{ u.is_admin ? `⭐ ${L.t('admin.admin')}` : L.t('admin.user') }}</td>
<td>{{ u.workspaces }}</td>
<td class="muted">{{ new Date(u.created_at).toLocaleDateString() }}</td>
<td class="row">
<button v-if="!u.is_admin" class="ghost mini" @click="setAdmin(u, true)">{{ L.t('admin.makeAdmin') }}</button>
<button v-else class="ghost mini" @click="setAdmin(u, false)">{{ L.t('admin.unmake') }}</button>
<button class="ghost mini" @click="removeUser(u)">🗑️</button>
</td>
</tr>
</tbody>
</table>
</div>
</main>
</div>
</template>
+106
View File
@@ -0,0 +1,106 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
const ws = useWorkspaceStore();
const L = useLocaleStore();
const router = useRouter();
const cursor = ref(new Date());
cursor.value.setDate(1);
const tasks = ref([]);
const year = computed(() => cursor.value.getFullYear());
const month = computed(() => cursor.value.getMonth());
const monthName = computed(() =>
cursor.value.toLocaleDateString(L.locale === 'zh' ? 'zh-CN' : L.locale === 'de' ? 'de-DE' : 'en-US', {
month: 'long', year: 'numeric',
}),
);
const cells = computed(() => {
const first = new Date(year.value, month.value, 1);
const startDay = (first.getDay() + 6) % 7; // Monday first
const days = new Date(year.value, month.value + 1, 0).getDate();
const arr = [];
for (let i = 0; i < startDay; i++) arr.push(null);
for (let d = 1; d <= days; d++) arr.push(new Date(year.value, month.value, d));
return arr;
});
function dayKey(d) {
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
const byDay = computed(() => {
const map = {};
for (const t of tasks.value) {
if (!t.due_at) continue;
const d = new Date(t.due_at);
const k = dayKey(d);
if (!map[k]) map[k] = [];
map[k].push(t);
}
return map;
});
function isToday(d) {
const n = new Date();
return d && d.toDateString() === n.toDateString();
}
async function load() {
if (!ws.currentId) return;
const from = new Date(year.value, month.value, 1).toISOString();
const to = new Date(year.value, month.value + 1, 0, 23, 59).toISOString();
const { data } = await api.get(`/tasks/by-workspace/${ws.currentId}`, { params: { from, to } });
tasks.value = data.tasks;
}
function prev() { cursor.value = new Date(year.value, month.value - 1, 1); }
function next() { cursor.value = new Date(year.value, month.value + 1, 1); }
function goToday() { const n = new Date(); cursor.value = new Date(n.getFullYear(), n.getMonth(), 1); }
watch(cursor, load);
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
await load();
});
</script>
<template>
<div class="layout">
<Sidebar />
<main class="main">
<div class="topbar">
<h2 style="margin: 0">🗓️ {{ L.t('cal.title') }} — {{ monthName }}</h2>
<div class="row">
<button class="ghost" @click="prev">◀</button>
<button class="ghost" @click="goToday">{{ L.t('cal.today') }}</button>
<button class="ghost" @click="next">▶</button>
</div>
</div>
<div class="calendar">
<div class="cal-head" v-for="d in ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']" :key="d">{{ d }}</div>
<div v-for="(d, i) in cells" :key="i" class="cal-cell" :class="{ today: isToday(d) }">
<template v-if="d">
<div class="cal-day">{{ d.getDate() }}</div>
<div
v-for="t in (byDay[dayKey(d)] || [])" :key="t.id"
class="cal-task" :class="{ done: t.status === 'done' }"
:title="t.title"
@click="router.push('/tasks')"
>
{{ t.status === 'done' ? '✅' : '📌' }} {{ t.title }}
</div>
</template>
</div>
</div>
<p class="muted">📌 {{ L.t('task.due') }} → {{ L.t('cal.title') }} ✨</p>
</main>
</div>
</template>
+22 -79
View File
@@ -1,106 +1,49 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api'; import api from '../api';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { useWorkspaceStore } from '../stores/workspace'; import { useWorkspaceStore } from '../stores/workspace';
import PageTree from '../components/PageTree.vue'; import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
import GreetingHeader from '../components/GreetingHeader.vue';
import InviteModal from '../components/InviteModal.vue'; import InviteModal from '../components/InviteModal.vue';
import Avatar from '../components/Avatar.vue';
const auth = useAuthStore(); const auth = useAuthStore();
const ws = useWorkspaceStore(); const ws = useWorkspaceStore();
const router = useRouter(); const L = useLocaleStore();
const newWs = ref('');
const newPage = ref('');
const search = ref('');
const results = ref([]);
const members = ref([]); const members = ref([]);
async function init() {
await auth.fetchMe().catch(() => {});
await ws.fetchWorkspaces();
await ws.fetchPages();
fetchMembers();
}
async function fetchMembers() { async function fetchMembers() {
if (!ws.currentId) return; if (!ws.currentId) return;
const { data } = await api.get(`/workspaces/${ws.currentId}/members`); const { data } = await api.get(`/workspaces/${ws.currentId}/members`);
members.value = data.members; members.value = data.members;
} }
async function switchWs(id) { const currentName = computed(() => ws.workspaces.find((w) => w.id === ws.currentId)?.name || '');
ws.setCurrent(id);
onMounted(async () => {
await auth.fetchMe().catch(() => {});
await ws.fetchWorkspaces();
await ws.fetchBooks();
await ws.fetchPages(); await ws.fetchPages();
fetchMembers(); await fetchMembers();
} });
async function createWs() {
if (!newWs.value.trim()) return;
await ws.createWorkspace(newWs.value.trim());
newWs.value = '';
await ws.fetchPages();
fetchMembers();
}
async function createPage(parentId = null) {
const title = newPage.value.trim() || 'Untitled';
const { data } = await api.post('/pages', { workspace_id: ws.currentId, parent_id: parentId, title });
newPage.value = '';
await ws.fetchPages();
router.push(`/page/${data.page.id}`);
}
async function doSearch() {
if (!search.value.trim()) { results.value = []; return; }
const { data } = await api.get('/pages/search/query', { params: { q: search.value, workspace_id: ws.currentId } });
results.value = data.results;
}
function logout() {
auth.logout();
router.push('/login');
}
onMounted(init);
</script> </script>
<template> <template>
<div class="layout"> <div class="layout">
<aside class="sidebar"> <Sidebar />
<div class="row" style="justify-content: space-between">
<strong>{{ auth.user?.name }}'s notes</strong>
<button class="ghost" @click="logout">Logout</button>
</div>
<select :value="ws.currentId" @change="switchWs($event.target.value)">
<option v-for="w in ws.workspaces" :key="w.id" :value="w.id">{{ w.name }} ({{ w.role }})</option>
</select>
<div class="row">
<input v-model="newWs" placeholder="New workspace" />
<button @click="createWs">+</button>
</div>
<input v-model="search" placeholder="Search pages…" @input="doSearch" />
<div v-if="results.length" class="card">
<div v-for="r in results" :key="r.id" class="tree-item" @click="router.push(`/page/${r.id}`)">
{{ r.title }}
</div>
</div>
<div class="row">
<input v-model="newPage" placeholder="New page title" @keyup.enter="createPage()" />
<button @click="createPage()">+</button>
</div>
<PageTree :nodes="ws.tree()" @open="router.push(`/page/${$event}`)" @add-child="createPage($event)" />
<div class="muted">Role: {{ ws.role }} · Members: {{ members.length }}</div>
</aside>
<main class="main"> <main class="main">
<h2>Welcome, {{ auth.user?.name }}</h2> <GreetingHeader :name="auth.user?.name" />
<p class="muted">Select a page on the left, or create one. Invite teammates below to collaborate in real time.</p> <p class="muted">🏠 {{ currentName }} · {{ L.t('side.role') || ws.role }}: <strong>{{ ws.role }}</strong></p>
<InviteModal v-if="ws.currentId" :workspace-id="ws.currentId" @invited="fetchMembers" /> <InviteModal v-if="ws.currentId" :workspace-id="ws.currentId" @invited="fetchMembers" />
<div class="card" style="margin-top: 12px"> <div class="card" style="margin-top: 12px">
<h4>Members</h4> <h4>👥 {{ L.t('side.members') }} ({{ members.length }})</h4>
<div v-for="m in members" :key="m.id" class="row" style="justify-content: space-between; padding: 4px 0"> <div v-for="m in members" :key="m.id" class="row member-row">
<span>{{ m.name }} &lt;{{ m.email }}&gt;</span> <Avatar :name="m.name" :avatar="m.avatar" :size="28" />
<span class="muted">{{ m.role }}</span> <span style="flex: 1">{{ m.name }} <span class="muted">&lt;{{ m.email }}&gt;</span></span>
<span class="pill">{{ m.role }}</span>
</div> </div>
</div> </div>
</main> </main>
+40 -25
View File
@@ -3,16 +3,25 @@ import { onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { EditorContent, useEditor } from '@tiptap/vue-3'; import { EditorContent, useEditor } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit'; import StarterKit from '@tiptap/starter-kit';
import TaskList from '@tiptap/extension-task-list';
import TaskItem from '@tiptap/extension-task-item';
import Table from '@tiptap/extension-table';
import TableRow from '@tiptap/extension-table-row';
import TableCell from '@tiptap/extension-table-cell';
import TableHeader from '@tiptap/extension-table-header';
import api from '../api'; import api from '../api';
import { connectSocket } from '../socket'; import { connectSocket } from '../socket';
import { useAuthStore } from '../stores/auth'; import { useAuthStore } from '../stores/auth';
import { useWorkspaceStore } from '../stores/workspace'; import { useWorkspaceStore } from '../stores/workspace';
import PageTree from '../components/PageTree.vue'; import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
import Avatar from '../components/Avatar.vue';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const auth = useAuthStore(); const auth = useAuthStore();
const ws = useWorkspaceStore(); const ws = useWorkspaceStore();
const L = useLocaleStore();
const page = ref(null); const page = ref(null);
const comments = ref([]); const comments = ref([]);
@@ -26,7 +35,7 @@ let saveTimer = null;
let typingTimer = null; let typingTimer = null;
const editor = useEditor({ const editor = useEditor({
extensions: [StarterKit], extensions: [StarterKit, TaskList, TaskItem.configure({ nested: true }), Table.configure({ resizable: true }), TableRow, TableHeader, TableCell],
content: '', content: '',
editable: true, editable: true,
onUpdate: ({ editor }) => { onUpdate: ({ editor }) => {
@@ -44,7 +53,6 @@ async function save(ed = editor.value) {
if (!page.value || !canEdit.value || !ed) return; if (!page.value || !canEdit.value || !ed) return;
const content_json = ed.getJSON(); const content_json = ed.getJSON();
const content_html = ed.getHTML(); const content_html = ed.getHTML();
// Persist via REST (authoritative) + broadcast via socket for instant sync
await api.patch(`/pages/${page.value.id}`, { content_json, content_html }).catch(() => {}); await api.patch(`/pages/${page.value.id}`, { content_json, content_html }).catch(() => {});
socket?.emit('page:edit', { pageId: page.value.id, content_json, content_html }); socket?.emit('page:edit', { pageId: page.value.id, content_json, content_html });
} }
@@ -56,7 +64,6 @@ async function load() {
const { data } = await api.get(`/pages/${id}`); const { data } = await api.get(`/pages/${id}`);
page.value = data.page; page.value = data.page;
comments.value = data.comments; comments.value = data.comments;
canEdit.value = ['owner', 'admin', 'member'].includes(ws.role) || true;
editor.value?.commands.setContent(data.page.content_json?.type ? data.page.content_json : (data.page.content_html || '')); editor.value?.commands.setContent(data.page.content_json?.type ? data.page.content_json : (data.page.content_html || ''));
joinRoom(id); joinRoom(id);
} catch (e) { } catch (e) {
@@ -82,16 +89,18 @@ function joinRoom(pageId) {
}); });
socket.on('presence:update', ({ members }) => { presence.value = members; }); socket.on('presence:update', ({ members }) => { presence.value = members; });
socket.on('typing', ({ name }) => { socket.on('typing', ({ name }) => {
typingUser.value = `${name} is typing…`; typingUser.value = `✍️ ${name} …`;
clearTimeout(typingTimer); clearTimeout(typingTimer);
typingTimer = setTimeout(() => (typingUser.value = ''), 1500); typingTimer = setTimeout(() => (typingUser.value = ''), 1500);
}); });
socket.on('comment:added', ({ comment }) => comments.value.push(comment)); socket.on('comment:added', ({ comment }) => comments.value.push(comment));
} }
async function saveTitle() { async function saveMeta() {
if (!page.value) return; if (!page.value) return;
const { data } = await api.patch(`/pages/${page.value.id}`, { title: page.value.title }); const { data } = await api.patch(`/pages/${page.value.id}`, {
title: page.value.title, book_id: page.value.book_id,
});
page.value = data.page; page.value = data.page;
ws.fetchPages(); ws.fetchPages();
} }
@@ -104,7 +113,7 @@ async function addComment() {
} }
async function removePage() { async function removePage() {
if (!confirm('Delete this page?')) return; if (!confirm('🗑️?')) return;
await api.delete(`/pages/${page.value.id}`); await api.delete(`/pages/${page.value.id}`);
await ws.fetchPages(); await ws.fetchPages();
router.push('/'); router.push('/');
@@ -112,7 +121,10 @@ async function removePage() {
onMounted(async () => { onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces(); if (!ws.workspaces.length) await ws.fetchWorkspaces();
if (!ws.pages.length && ws.currentId) await ws.fetchPages(); if (!ws.pages.length && ws.currentId) {
await ws.fetchBooks();
await ws.fetchPages();
}
await load(); await load();
}); });
watch(() => route.params.id, load); watch(() => route.params.id, load);
@@ -124,46 +136,49 @@ onUnmounted(() => {
<template> <template>
<div class="layout"> <div class="layout">
<aside class="sidebar"> <Sidebar :active-page-id="route.params.id" />
<button class="ghost" @click="router.push('/')">← Dashboard</button>
<PageTree :nodes="ws.tree()" :active-id="route.params.id" @open="router.push(`/page/${$event}`)" @add-child="router.push('/')" />
</aside>
<main class="main" v-if="page"> <main class="main" v-if="page">
<div class="topbar"> <div class="topbar wrap">
<input v-model="page.title" @change="saveTitle" style="font-size: 22px; font-weight: 700" /> <input v-model="page.title" @change="saveMeta" style="font-size: 22px; font-weight: 700; flex: 1" />
<button class="ghost" @click="removePage">Delete</button> <select v-model="page.book_id" @change="saveMeta" style="max-width: 200px" :title="L.t('task.book')">
<option :value="null">📄 {{ L.t('task.none') }}</option>
<option v-for="b in ws.books" :key="b.id" :value="b.id">{{ b.icon }} {{ b.title }}</option>
</select>
<button class="ghost" @click="removePage">🗑️</button>
</div> </div>
<div class="presence" style="margin-bottom: 8px"> <div class="presence" style="margin-bottom: 8px">
<span class="muted">Online ({{ presence.length }}):</span> <span class="muted">🟢 ({{ presence.length }}):</span>
<span v-for="m in presence" :key="m.userId" class="avatar" :title="m.email">{{ (m.name || m.email || '?')[0].toUpperCase() }}</span> <Avatar v-for="m in presence" :key="m.userId" :name="m.name" :size="26" />
<span class="muted" v-if="typingUser">{{ typingUser }}</span> <span class="muted" v-if="typingUser">{{ typingUser }}</span>
</div> </div>
<div class="toolbar"> <div class="toolbar">
<button class="ghost" @click="editor?.chain().focus().toggleBold().run()">B</button> <button class="ghost" @click="editor?.chain().focus().toggleBold().run()"><b>B</b></button>
<button class="ghost" @click="editor?.chain().focus().toggleItalic().run()"><i>I</i></button> <button class="ghost" @click="editor?.chain().focus().toggleItalic().run()"><i>I</i></button>
<button class="ghost" @click="editor?.chain().focus().toggleHeading({ level: 1 }).run()">H1</button> <button class="ghost" @click="editor?.chain().focus().toggleHeading({ level: 1 }).run()">H1</button>
<button class="ghost" @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">H2</button> <button class="ghost" @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">H2</button>
<button class="ghost" @click="editor?.chain().focus().toggleBulletList().run()">• List</button> <button class="ghost" @click="editor?.chain().focus().toggleBulletList().run()">• {{ L.t('side.pages') }}</button>
<button class="ghost" @click="editor?.chain().focus().toggleTaskList().run()">☑️</button>
<button class="ghost" @click="editor?.chain().focus().insertTable({ rows: 3, cols: 3 }).run()">▦ Table</button>
<button class="ghost" @click="editor?.chain().focus().toggleCodeBlock().run()">&lt;/&gt;</button> <button class="ghost" @click="editor?.chain().focus().toggleCodeBlock().run()">&lt;/&gt;</button>
<button @click="save()">Save</button> <button @click="save()">💾 {{ L.t('c.save') }}</button>
</div> </div>
<EditorContent :editor="editor" class="tiptap" /> <EditorContent :editor="editor" class="tiptap" />
<div class="card" style="margin-top: 16px"> <div class="card" style="margin-top: 16px">
<h3>Comments ({{ comments.length }})</h3> <h3>💬 {{ L.t('com.title') }} ({{ comments.length }})</h3>
<div v-for="c in comments" :key="c.id" class="comment"> <div v-for="c in comments" :key="c.id" class="comment">
<strong>{{ c.author_name || 'Someone' }}</strong> <strong>{{ c.author_name || 'Someone' }}</strong>
<span class="muted"> · {{ new Date(c.created_at).toLocaleString() }}</span> <span class="muted"> · {{ new Date(c.created_at).toLocaleString() }}</span>
<p>{{ c.body }}</p> <p>{{ c.body }}</p>
</div> </div>
<div class="row" style="margin-top: 8px"> <div class="row" style="margin-top: 8px">
<input v-model="newComment" placeholder="Write a comment…" @keyup.enter="addComment" /> <input v-model="newComment" :placeholder="L.t('com.ph')" @keyup.enter="addComment" />
<button @click="addComment">Post</button> <button @click="addComment">{{ L.t('com.post') }}</button>
</div> </div>
</div> </div>
</main> </main>
<main class="main" v-else> <main class="main" v-else>
<p v-if="err" style="color: red">{{ err }}</p> <p v-if="err" style="color: red">{{ err }}</p>
<p v-else class="muted">Loading…</p> <p v-else class="muted">{{ L.t('c.loading') }}</p>
</main> </main>
</div> </div>
</template> </template>
+128
View File
@@ -0,0 +1,128 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
const ws = useWorkspaceStore();
const L = useLocaleStore();
const router = useRouter();
const tasks = ref([]);
const title = ref('');
const due = ref('');
const priority = ref('normal');
const bookId = ref('');
const pageId = ref('');
const cols = computed(() => ({
todo: tasks.value.filter((t) => t.status === 'todo'),
doing: tasks.value.filter((t) => t.status === 'doing'),
done: tasks.value.filter((t) => t.status === 'done'),
}));
function isOverdue(t) {
return t.status !== 'done' && t.due_at && new Date(t.due_at) < new Date();
}
function fmtDate(d) {
return d ? new Date(d).toLocaleString() : L.t('task.noDue');
}
async function load() {
if (!ws.currentId) return;
const { data } = await api.get(`/tasks/by-workspace/${ws.currentId}`);
tasks.value = data.tasks;
}
async function create() {
if (!title.value.trim()) return;
const { data } = await api.post('/tasks', {
workspace_id: ws.currentId,
title: title.value.trim(),
priority: priority.value,
due_at: due.value || null,
book_id: bookId.value || null,
page_id: pageId.value || null,
});
tasks.value.unshift(data.task);
title.value = '';
due.value = '';
bookId.value = '';
pageId.value = '';
}
async function setStatus(t, status) {
const { data } = await api.patch(`/tasks/${t.id}`, { status });
Object.assign(t, data.task);
}
async function remove(t) {
if (!confirm('🗑️?')) return;
await api.delete(`/tasks/${t.id}`);
tasks.value = tasks.value.filter((x) => x.id !== t.id);
}
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
await ws.fetchBooks();
await ws.fetchPages();
await load();
});
</script>
<template>
<div class="layout">
<Sidebar />
<main class="main">
<h2>✅ {{ L.t('task.title') }}</h2>
<div class="card new-task">
<div class="row wrap">
<input v-model="title" :placeholder="L.t('task.ph')" @keyup.enter="create" style="flex: 2" />
<input v-model="due" type="datetime-local" :title="L.t('task.due')" />
<select v-model="priority" style="max-width: 130px">
<option value="low">🟢 {{ L.t('task.low') }}</option>
<option value="normal">🟡 {{ L.t('task.normal') }}</option>
<option value="high">🔴 {{ L.t('task.high') }}</option>
</select>
</div>
<div class="row wrap">
<select v-model="bookId">
<option value="">{{ L.t('task.book') }}: {{ L.t('task.none') }}</option>
<option v-for="b in ws.books" :key="b.id" :value="b.id">{{ b.icon }} {{ b.title }}</option>
</select>
<select v-model="pageId">
<option value="">{{ L.t('task.page') }}: {{ L.t('task.none') }}</option>
<option v-for="p in ws.pages" :key="p.id" :value="p.id">{{ p.title }}</option>
</select>
<button @click="create">+ {{ L.t('task.new') }}</button>
</div>
</div>
<div class="kanban">
<div v-for="col in ['todo', 'doing', 'done']" :key="col" class="kanban-col">
<h3>{{ L.t(`task.${col}`) }} ({{ cols[col].length }})</h3>
<div v-for="t in cols[col]" :key="t.id" class="card task-card" :class="{ overdue: isOverdue(t) }">
<strong>{{ t.title }}</strong>
<div class="muted">
{{ t.priority === 'high' ? '🔴' : t.priority === 'low' ? '🟢' : '🟡' }}
🕒 {{ fmtDate(t.due_at) }}
<span v-if="isOverdue(t)" class="overdue-tag">{{ L.t('task.overdue') }}</span>
</div>
<div class="muted">
<span v-if="t.book_title">📚 {{ t.book_title }}</span>
<span v-if="t.page_title" class="link" @click="router.push(`/page/${t.page_id}`)">📄 {{ t.page_title }}</span>
</div>
<div class="row wrap">
<button v-if="t.status !== 'todo'" class="ghost mini" @click="setStatus(t, 'todo')">{{ L.t('task.toTodo') }}</button>
<button v-if="t.status === 'todo'" class="ghost mini" @click="setStatus(t, 'doing')">{{ L.t('task.toDoing') }}</button>
<button v-if="t.status === 'doing'" class="ghost mini" @click="setStatus(t, 'done')">{{ L.t('task.toDone') }}</button>
<button v-if="t.status === 'done'" class="ghost mini" @click="setStatus(t, 'todo')">{{ L.t('task.reopen') }}</button>
<button class="ghost mini" @click="remove(t)">🗑️</button>
</div>
</div>
</div>
</div>
</main>
</div>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useTimeStore, formatDuration } from '../stores/time';
import { useWorkspaceStore } from '../stores/workspace';
import { useLocaleStore } from '../stores/locale';
import Sidebar from '../components/Sidebar.vue';
import Avatar from '../components/Avatar.vue';
const time = useTimeStore();
const ws = useWorkspaceStore();
const L = useLocaleStore();
const total = computed(() =>
time.entries.reduce((s, e) => s + (e.duration_seconds || 0), 0),
);
function fmt(s) {
return s ? new Date(s).toLocaleString() : '—';
}
async function remove(id) {
if (!confirm('🗑️?')) return;
await time.remove(id);
}
onMounted(async () => {
if (!ws.workspaces.length) await ws.fetchWorkspaces();
if (ws.currentId) await time.fetch(ws.currentId);
});
</script>
<template>
<div class="layout">
<Sidebar />
<main class="main">
<h2>⏱️ {{ L.t('time.title') }}</h2>
<div v-if="time.open" class="card running-banner">
🔴 {{ L.t('time.running') }} <strong>{{ time.open.description || '…' }}</strong>
<span v-if="time.open.book_id" class="muted">📚</span>
</div>
<div class="card">
<h3>{{ L.t('time.entries') }} · {{ L.t('time.total') }}: {{ formatDuration(total) }}</h3>
<p v-if="!time.entries.length" class="muted">{{ L.t('time.noEntries') }}</p>
<table v-else class="data-table">
<thead>
<tr>
<th>{{ L.t('time.started') }}</th>
<th>{{ L.t('time.ended') }}</th>
<th>{{ L.t('time.dur') }}</th>
<th>{{ L.t('time.book') }}</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="e in time.entries" :key="e.id" :class="{ running: !e.ended_at }">
<td>{{ fmt(e.started_at) }}</td>
<td>{{ e.ended_at ? fmt(e.ended_at) : `🔴 ${L.t('time.running')}` }}</td>
<td><strong>{{ e.duration_seconds != null ? formatDuration(e.duration_seconds) : '…' }}</strong></td>
<td>{{ e.book_title ? `📚 ${e.book_title}` : L.t('time.none') }}{{ e.description ? ` — ${e.description}` : '' }}</td>
<td><Avatar :name="e.user_name" :size="22" /></td>
<td><button class="ghost mini" @click="remove(e.id)">🗑️</button></td>
</tr>
</tbody>
</table>
</div>
</main>
</div>
</template>