Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fe56265c9 | ||
|
|
ab751ea7ef |
@@ -22,6 +22,8 @@ light/dark mode. Self-hostable via Node.js or Docker Compose.
|
|||||||
- **Invites + notifications** 🔔 — Pending invites with accept/decline, bell with unread
|
- **Invites + notifications** 🔔 — Pending invites with accept/decline, bell with unread
|
||||||
badge (polling + realtime push), notifications for invites and chat messages.
|
badge (polling + realtime push), notifications for invites and chat messages.
|
||||||
- **Team chat** 💬 — Realtime workspace channel via Socket.IO.
|
- **Team chat** 💬 — Realtime workspace channel via Socket.IO.
|
||||||
|
- **Private chats** ✉️ — 1:1 DMs with reactions, edit, attachments, search, typing,
|
||||||
|
online presence and unread badges; team chats listed per workspace.
|
||||||
- **Uploads** 📤 — Avatar/background images uploadable (served from `/uploads`).
|
- **Uploads** 📤 — Avatar/background images uploadable (served from `/uploads`).
|
||||||
- **Calendar** 🗓️ — Deadlines from tasks appear automatically.
|
- **Calendar** 🗓️ — Deadlines from tasks appear automatically.
|
||||||
- **Time tracking** ⏱️ — Top-right timer, entries linkable to books, history list.
|
- **Time tracking** ⏱️ — Top-right timer, entries linkable to books, history list.
|
||||||
|
|||||||
@@ -212,6 +212,56 @@ CREATE TABLE IF NOT EXISTS group_members (
|
|||||||
PRIMARY KEY (group_id, user_id)
|
PRIMARY KEY (group_id, user_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Per-user permission overrides (explicit allow/deny beats group rights)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_permissions (
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
permission TEXT NOT NULL CHECK (permission IN ('teams.create','teams.edit','teams.invite','workspace.invite')),
|
||||||
|
allow BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (workspace_id, user_id, permission)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Chat attachments + edit timestamps
|
||||||
|
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS attachment TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ NULL;
|
||||||
|
|
||||||
|
-- Private 1:1 conversations (DMs)
|
||||||
|
CREATE TABLE IF NOT EXISTS conversations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS conversation_members (
|
||||||
|
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
last_read_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (conversation_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_conv_members_user ON conversation_members(user_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS dm_messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
body TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000),
|
||||||
|
attachment TEXT NOT NULL DEFAULT '',
|
||||||
|
edited_at TIMESTAMPTZ NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_conv ON dm_messages(conversation_id, created_at);
|
||||||
|
|
||||||
|
-- Emoji reactions on chat + DM messages (toggle per user+emoji)
|
||||||
|
CREATE TABLE IF NOT EXISTS reactions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
target_type TEXT NOT NULL CHECK (target_type IN ('chat','dm')),
|
||||||
|
target_id UUID NOT NULL,
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (target_type, target_id, user_id, emoji)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reactions_target ON reactions(target_type, target_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
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import chatRoutes from './routes/chat.js';
|
|||||||
import teamRoutes from './routes/teams.js';
|
import teamRoutes from './routes/teams.js';
|
||||||
import groupRoutes from './routes/groups.js';
|
import groupRoutes from './routes/groups.js';
|
||||||
import uploadRoutes from './routes/upload.js';
|
import uploadRoutes from './routes/upload.js';
|
||||||
|
import dmRoutes from './routes/dm.js';
|
||||||
|
import reactionRoutes from './routes/reactions.js';
|
||||||
import { initSocket } from './socket.js';
|
import { initSocket } from './socket.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -64,6 +66,8 @@ app.use('/api/chat', chatRoutes);
|
|||||||
app.use('/api/teams', teamRoutes);
|
app.use('/api/teams', teamRoutes);
|
||||||
app.use('/api/groups', groupRoutes);
|
app.use('/api/groups', groupRoutes);
|
||||||
app.use('/api/upload', uploadRoutes);
|
app.use('/api/upload', uploadRoutes);
|
||||||
|
app.use('/api/dm', dmRoutes);
|
||||||
|
app.use('/api/reactions', reactionRoutes);
|
||||||
app.use('/uploads', express.static('uploads'));
|
app.use('/uploads', express.static('uploads'));
|
||||||
|
|
||||||
app.get('/api/health', async (_req, res) => {
|
app.get('/api/health', async (_req, res) => {
|
||||||
|
|||||||
@@ -6,6 +6,23 @@ import { getRole } from './guards.js';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
async function attachReactions(messages) {
|
||||||
|
if (!messages.length) return messages;
|
||||||
|
const ids = messages.map((m) => m.id);
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT target_id, emoji, COUNT(*)::int AS count, array_agg(user_id) AS users
|
||||||
|
FROM reactions WHERE target_type = 'chat' AND target_id = ANY($1)
|
||||||
|
GROUP BY target_id, emoji`,
|
||||||
|
[ids],
|
||||||
|
);
|
||||||
|
const byMsg = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!byMsg[r.target_id]) byMsg[r.target_id] = [];
|
||||||
|
byMsg[r.target_id].push({ emoji: r.emoji, count: r.count, users: r.users });
|
||||||
|
}
|
||||||
|
return messages.map((m) => ({ ...m, reactions: byMsg[m.id] || [] }));
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/chat/:workspaceId — last 100 messages
|
// GET /api/chat/:workspaceId — last 100 messages
|
||||||
router.get('/:workspaceId', async (req, res) => {
|
router.get('/:workspaceId', async (req, res) => {
|
||||||
const role = await getRole(req.params.workspaceId, req.user.id);
|
const role = await getRole(req.params.workspaceId, req.user.id);
|
||||||
@@ -16,7 +33,8 @@ router.get('/:workspaceId', async (req, res) => {
|
|||||||
WHERE m.workspace_id = $1 ORDER BY m.created_at DESC LIMIT 100`,
|
WHERE m.workspace_id = $1 ORDER BY m.created_at DESC LIMIT 100`,
|
||||||
[req.params.workspaceId],
|
[req.params.workspaceId],
|
||||||
);
|
);
|
||||||
return res.json({ messages: rows.reverse() });
|
const withReactions = await attachReactions(rows.reverse());
|
||||||
|
return res.json({ messages: withReactions });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/chat/:workspaceId { body } — post + realtime broadcast + notify others
|
// POST /api/chat/:workspaceId { body } — post + realtime broadcast + notify others
|
||||||
@@ -24,16 +42,16 @@ router.post('/:workspaceId', async (req, res) => {
|
|||||||
const wsId = req.params.workspaceId;
|
const wsId = req.params.workspaceId;
|
||||||
const role = await getRole(wsId, req.user.id);
|
const role = await getRole(wsId, req.user.id);
|
||||||
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
|
if (!role) return res.status(403).json({ error: 'Not a workspace member' });
|
||||||
const { body = '' } = req.body ?? {};
|
const { body = '', attachment = '' } = req.body ?? {};
|
||||||
if (String(body).trim().length < 1 || String(body).length > 2000) {
|
if (String(body).trim().length < 1 || String(body).length > 2000) {
|
||||||
return res.status(400).json({ error: 'Body must be 1-2000 chars' });
|
return res.status(400).json({ error: 'Body must be 1-2000 chars' });
|
||||||
}
|
}
|
||||||
const { rows } = await query(
|
const { rows } = await query(
|
||||||
'INSERT INTO chat_messages (workspace_id, user_id, body) VALUES ($1,$2,$3) RETURNING *',
|
'INSERT INTO chat_messages (workspace_id, user_id, body, attachment) VALUES ($1,$2,$3,$4) RETURNING *',
|
||||||
[wsId, req.user.id, String(body).trim()],
|
[wsId, req.user.id, String(body).trim(), String(attachment).slice(0, 2000)],
|
||||||
);
|
);
|
||||||
const { rows: author } = await query('SELECT name, avatar FROM users WHERE id = $1', [req.user.id]);
|
const { rows: author } = await query('SELECT name, avatar FROM users WHERE id = $1', [req.user.id]);
|
||||||
const msg = { ...rows[0], author_name: author[0]?.name, author_avatar: author[0]?.avatar };
|
const msg = { ...rows[0], author_name: author[0]?.name, author_avatar: author[0]?.avatar, reactions: [] };
|
||||||
const io = req.app.get('io');
|
const io = req.app.get('io');
|
||||||
io?.to(`chat:${wsId}`).emit('chat:message', { message: msg });
|
io?.to(`chat:${wsId}`).emit('chat:message', { message: msg });
|
||||||
// Notify all other members
|
// Notify all other members
|
||||||
@@ -54,7 +72,39 @@ router.post('/:workspaceId', async (req, res) => {
|
|||||||
return res.status(201).json({ message: msg });
|
return res.status(201).json({ message: msg });
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/chat/message/:id (own or admin+)
|
// PATCH /api/chat/message/:id { body } (own)
|
||||||
|
router.patch('/message/:id', async (req, res) => {
|
||||||
|
const { rows } = await query('SELECT * FROM chat_messages WHERE id = $1', [req.params.id]);
|
||||||
|
const msg = rows[0];
|
||||||
|
if (!msg) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (msg.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
const { body = '' } = req.body ?? {};
|
||||||
|
if (String(body).trim().length < 1 || String(body).length > 2000) {
|
||||||
|
return res.status(400).json({ error: 'Body must be 1-2000 chars' });
|
||||||
|
}
|
||||||
|
const { rows: updated } = await query(
|
||||||
|
'UPDATE chat_messages SET body = $1, edited_at = now() WHERE id = $2 RETURNING *',
|
||||||
|
[String(body).trim(), msg.id],
|
||||||
|
);
|
||||||
|
req.app.get('io')?.to(`chat:${msg.workspace_id}`).emit('chat:edited', { message: updated[0] });
|
||||||
|
return res.json({ message: updated[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/chat/:workspaceId/search?q=
|
||||||
|
router.get('/:workspaceId/search', 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 q = String(req.query.q || '').slice(0, 100);
|
||||||
|
if (q.length < 2) return res.json({ messages: [] });
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT m.*, u.name AS author_name FROM chat_messages m
|
||||||
|
LEFT JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.workspace_id = $1 AND m.body ILIKE $2
|
||||||
|
ORDER BY m.created_at DESC LIMIT 30`,
|
||||||
|
[req.params.workspaceId, `%${q}%`],
|
||||||
|
);
|
||||||
|
return res.json({ messages: rows });
|
||||||
|
});
|
||||||
router.delete('/message/:id', async (req, res) => {
|
router.delete('/message/:id', async (req, res) => {
|
||||||
const { rows } = await query('SELECT * FROM chat_messages WHERE id = $1', [req.params.id]);
|
const { rows } = await query('SELECT * FROM chat_messages WHERE id = $1', [req.params.id]);
|
||||||
const msg = rows[0];
|
const msg = rows[0];
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { query } from '../config/db.js';
|
||||||
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
async function isMember(conversationId, userId) {
|
||||||
|
const { rows } = await query(
|
||||||
|
'SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2',
|
||||||
|
[conversationId, userId],
|
||||||
|
);
|
||||||
|
return !!rows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withReactions(type, messages) {
|
||||||
|
if (!messages.length) return messages;
|
||||||
|
const ids = messages.map((m) => m.id);
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT target_id, emoji, COUNT(*)::int AS count,
|
||||||
|
array_agg(user_id) AS users
|
||||||
|
FROM reactions WHERE target_type = $1 AND target_id = ANY($2)
|
||||||
|
GROUP BY target_id, emoji`,
|
||||||
|
[type, ids],
|
||||||
|
);
|
||||||
|
const byMsg = {};
|
||||||
|
for (const r of rows) {
|
||||||
|
if (!byMsg[r.target_id]) byMsg[r.target_id] = [];
|
||||||
|
byMsg[r.target_id].push({ emoji: r.emoji, count: r.count, users: r.users });
|
||||||
|
}
|
||||||
|
return messages.map((m) => ({ ...m, reactions: byMsg[m.id] || [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/dm — my conversations (peer, last message, unread)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT c.id, c.created_at,
|
||||||
|
(SELECT json_build_object('id', u.id, 'name', u.name, 'email', u.email, 'avatar', COALESCE(u.avatar,''))
|
||||||
|
FROM conversation_members cm2 JOIN users u ON u.id = cm2.user_id
|
||||||
|
WHERE cm2.conversation_id = c.id AND cm2.user_id <> $1 LIMIT 1) AS peer,
|
||||||
|
(SELECT json_build_object('body', m.body, 'created_at', m.created_at, 'user_id', m.user_id)
|
||||||
|
FROM dm_messages m WHERE m.conversation_id = c.id
|
||||||
|
ORDER BY m.created_at DESC LIMIT 1) AS last_message,
|
||||||
|
(SELECT COUNT(*)::int FROM dm_messages m
|
||||||
|
JOIN conversation_members me ON me.conversation_id = c.id AND me.user_id = $1
|
||||||
|
WHERE m.conversation_id = c.id AND m.created_at > me.last_read_at AND m.user_id <> $1) AS unread
|
||||||
|
FROM conversations c
|
||||||
|
JOIN conversation_members me ON me.conversation_id = c.id AND me.user_id = $1
|
||||||
|
ORDER BY COALESCE((SELECT MAX(created_at) FROM dm_messages WHERE conversation_id = c.id), c.created_at) DESC`,
|
||||||
|
[req.user.id],
|
||||||
|
);
|
||||||
|
return res.json({ conversations: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/dm { user_id } — find-or-create 1:1 conversation
|
||||||
|
router.post('/', async (req, res) => {
|
||||||
|
const { user_id } = req.body ?? {};
|
||||||
|
if (!user_id || user_id === req.user.id) return res.status(400).json({ error: 'Valid user_id required' });
|
||||||
|
const { rows: target } = await query('SELECT id, name, email, avatar FROM users WHERE id = $1', [user_id]);
|
||||||
|
if (!target[0]) return res.status(404).json({ error: 'User not found' });
|
||||||
|
const { rows: existing } = await query(
|
||||||
|
`SELECT c.id FROM conversations c
|
||||||
|
JOIN conversation_members a ON a.conversation_id = c.id AND a.user_id = $1
|
||||||
|
JOIN conversation_members b ON b.conversation_id = c.id AND b.user_id = $2
|
||||||
|
WHERE (SELECT COUNT(*) FROM conversation_members WHERE conversation_id = c.id) = 2
|
||||||
|
LIMIT 1`,
|
||||||
|
[req.user.id, user_id],
|
||||||
|
);
|
||||||
|
if (existing[0]) return res.json({ conversation: { id: existing[0].id } });
|
||||||
|
const { rows } = await query('INSERT INTO conversations (created_by) VALUES ($1) RETURNING id', [req.user.id]);
|
||||||
|
await query('INSERT INTO conversation_members (conversation_id, user_id) VALUES ($1,$2),($1,$3)', [
|
||||||
|
rows[0].id, req.user.id, user_id,
|
||||||
|
]);
|
||||||
|
return res.status(201).json({ conversation: { id: rows[0].id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dm/:id/messages?limit=100 — marks as read
|
||||||
|
router.get('/:id/messages', async (req, res) => {
|
||||||
|
if (!(await isMember(req.params.id, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
const limit = Math.min(Number(req.query.limit) || 100, 200);
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT m.*, u.name AS author_name, u.avatar AS author_avatar
|
||||||
|
FROM dm_messages m LEFT JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.conversation_id = $1 ORDER BY m.created_at DESC LIMIT $2`,
|
||||||
|
[req.params.id, limit],
|
||||||
|
);
|
||||||
|
const messages = (await withReactions('dm', rows)).reverse();
|
||||||
|
await query(
|
||||||
|
'UPDATE conversation_members SET last_read_at = now() WHERE conversation_id = $1 AND user_id = $2',
|
||||||
|
[req.params.id, req.user.id],
|
||||||
|
);
|
||||||
|
return res.json({ messages });
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/dm/:id/search?q=
|
||||||
|
router.get('/:id/search', async (req, res) => {
|
||||||
|
if (!(await isMember(req.params.id, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
const q = String(req.query.q || '').slice(0, 100);
|
||||||
|
if (q.length < 2) return res.json({ messages: [] });
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT m.*, u.name AS author_name FROM dm_messages m
|
||||||
|
LEFT JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.conversation_id = $1 AND m.body ILIKE $2
|
||||||
|
ORDER BY m.created_at DESC LIMIT 30`,
|
||||||
|
[req.params.id, `%${q}%`],
|
||||||
|
);
|
||||||
|
return res.json({ messages: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/dm/:id/messages { body, attachment? }
|
||||||
|
router.post('/:id/messages', async (req, res) => {
|
||||||
|
if (!(await isMember(req.params.id, req.user.id))) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
const { body = '', attachment = '' } = req.body ?? {};
|
||||||
|
if (String(body).trim().length < 1 || String(body).length > 2000) {
|
||||||
|
return res.status(400).json({ error: 'Body must be 1-2000 chars' });
|
||||||
|
}
|
||||||
|
const { rows } = await query(
|
||||||
|
'INSERT INTO dm_messages (conversation_id, user_id, body, attachment) VALUES ($1,$2,$3,$4) RETURNING *',
|
||||||
|
[req.params.id, req.user.id, String(body).trim(), String(attachment).slice(0, 2000)],
|
||||||
|
);
|
||||||
|
const { rows: author } = await query('SELECT name, avatar FROM users WHERE id = $1', [req.user.id]);
|
||||||
|
const msg = { ...rows[0], author_name: author[0]?.name, author_avatar: author[0]?.avatar, reactions: [] };
|
||||||
|
const io = req.app.get('io');
|
||||||
|
io?.to(`dm:${req.params.id}`).emit('dm:message', { message: msg });
|
||||||
|
// Notify the other member
|
||||||
|
const { rows: others } = await query(
|
||||||
|
'SELECT user_id FROM conversation_members WHERE conversation_id = $1 AND user_id <> $2',
|
||||||
|
[req.params.id, req.user.id],
|
||||||
|
);
|
||||||
|
for (const o of others) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO notifications (user_id, type, title, body, link)
|
||||||
|
VALUES ($1,'chat',$2,$3,$4)`,
|
||||||
|
[o.user_id, `✉️ ${author[0]?.name || 'Someone'}`, String(body).trim().slice(0, 140), '/chat'],
|
||||||
|
);
|
||||||
|
io?.to(`user:${o.user_id}`).emit('notification:new', { type: 'dm' });
|
||||||
|
}
|
||||||
|
return res.status(201).json({ message: msg });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/dm/message/:mid { body } (own)
|
||||||
|
router.patch('/message/:mid', async (req, res) => {
|
||||||
|
const { rows } = await query('SELECT * FROM dm_messages WHERE id = $1', [req.params.mid]);
|
||||||
|
const msg = rows[0];
|
||||||
|
if (!msg) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (msg.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
const { body = '' } = req.body ?? {};
|
||||||
|
if (String(body).trim().length < 1 || String(body).length > 2000) {
|
||||||
|
return res.status(400).json({ error: 'Body must be 1-2000 chars' });
|
||||||
|
}
|
||||||
|
const { rows: updated } = await query(
|
||||||
|
'UPDATE dm_messages SET body = $1, edited_at = now() WHERE id = $2 RETURNING *',
|
||||||
|
[String(body).trim(), msg.id],
|
||||||
|
);
|
||||||
|
req.app.get('io')?.to(`dm:${msg.conversation_id}`).emit('dm:edited', { message: updated[0] });
|
||||||
|
return res.json({ message: updated[0] });
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/dm/message/:mid (own or admin)
|
||||||
|
router.delete('/message/:mid', async (req, res) => {
|
||||||
|
const { rows } = await query('SELECT * FROM dm_messages WHERE id = $1', [req.params.mid]);
|
||||||
|
const msg = rows[0];
|
||||||
|
if (!msg) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const { rows: me } = await query('SELECT is_admin FROM users WHERE id = $1', [req.user.id]);
|
||||||
|
if (msg.user_id !== req.user.id && !me[0]?.is_admin) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
await query('DELETE FROM dm_messages WHERE id = $1', [msg.id]);
|
||||||
|
req.app.get('io')?.to(`dm:${msg.conversation_id}`).emit('dm:deleted', { id: msg.id });
|
||||||
|
return res.status(204).end();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -41,11 +41,17 @@ export async function requireAdmin(req, res, next) {
|
|||||||
|
|
||||||
export const PERMS = ['teams.create', 'teams.edit', 'teams.invite', 'workspace.invite'];
|
export const PERMS = ['teams.create', 'teams.edit', 'teams.invite', 'workspace.invite'];
|
||||||
|
|
||||||
/** True if user holds a workspace permission via groups (owner/admin always pass). */
|
/** True if user holds a workspace permission.
|
||||||
|
* owner/admin always pass; explicit per-user allow/deny beats group rights. */
|
||||||
export async function hasPerm(workspaceId, userId, perm) {
|
export async function hasPerm(workspaceId, userId, perm) {
|
||||||
const role = await getRole(workspaceId, userId);
|
const role = await getRole(workspaceId, userId);
|
||||||
if (role === 'owner' || role === 'admin') return true;
|
if (role === 'owner' || role === 'admin') return true;
|
||||||
if (!role) return false;
|
if (!role) return false;
|
||||||
|
const { rows: override } = await query(
|
||||||
|
'SELECT allow FROM user_permissions WHERE workspace_id = $1 AND user_id = $2 AND permission = $3',
|
||||||
|
[workspaceId, userId, perm],
|
||||||
|
);
|
||||||
|
if (override[0]) return !!override[0].allow;
|
||||||
const { rows } = await query(
|
const { rows } = await query(
|
||||||
`SELECT 1 FROM group_members gm JOIN groups g ON g.id = gm.group_id
|
`SELECT 1 FROM group_members gm JOIN groups g ON g.id = gm.group_id
|
||||||
WHERE g.workspace_id = $1 AND gm.user_id = $2 AND $3 = ANY(g.permissions) LIMIT 1`,
|
WHERE g.workspace_id = $1 AND gm.user_id = $2 AND $3 = ANY(g.permissions) LIMIT 1`,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
async function canSee(type, targetId, userId) {
|
||||||
|
if (type === 'chat') {
|
||||||
|
const { rows } = await query('SELECT workspace_id FROM chat_messages WHERE id = $1', [targetId]);
|
||||||
|
if (!rows[0]) return null;
|
||||||
|
const role = await getRole(rows[0].workspace_id, userId);
|
||||||
|
return role ? { room: `chat:${rows[0].workspace_id}` } : null;
|
||||||
|
}
|
||||||
|
const { rows } = await query('SELECT conversation_id FROM dm_messages WHERE id = $1', [targetId]);
|
||||||
|
if (!rows[0]) return null;
|
||||||
|
const { rows: m } = await query(
|
||||||
|
'SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2',
|
||||||
|
[rows[0].conversation_id, userId],
|
||||||
|
);
|
||||||
|
return m[0] ? { room: `dm:${rows[0].conversation_id}` } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/reactions/toggle { target_type: 'chat'|'dm', target_id, emoji }
|
||||||
|
router.post('/toggle', async (req, res) => {
|
||||||
|
const { target_type, target_id, emoji = '' } = req.body ?? {};
|
||||||
|
if (!['chat', 'dm'].includes(target_type)) return res.status(400).json({ error: 'Invalid target' });
|
||||||
|
const e = String(emoji).slice(0, 20);
|
||||||
|
if (!e) return res.status(400).json({ error: 'Emoji required' });
|
||||||
|
const access = await canSee(target_type, target_id, req.user.id);
|
||||||
|
if (!access) return res.status(404).json({ error: 'Message not found' });
|
||||||
|
const { rows: existing } = await query(
|
||||||
|
'SELECT id FROM reactions WHERE target_type = $1 AND target_id = $2 AND user_id = $3 AND emoji = $4',
|
||||||
|
[target_type, target_id, req.user.id, e],
|
||||||
|
);
|
||||||
|
let added;
|
||||||
|
if (existing[0]) {
|
||||||
|
await query('DELETE FROM reactions WHERE id = $1', [existing[0].id]);
|
||||||
|
added = false;
|
||||||
|
} else {
|
||||||
|
await query(
|
||||||
|
'INSERT INTO reactions (target_type, target_id, user_id, emoji) VALUES ($1,$2,$3,$4) ON CONFLICT DO NOTHING',
|
||||||
|
[target_type, target_id, req.user.id, e],
|
||||||
|
);
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
const { rows: counts } = await query(
|
||||||
|
`SELECT emoji, COUNT(*)::int AS count, array_agg(user_id) AS users
|
||||||
|
FROM reactions WHERE target_type = $1 AND target_id = $2 GROUP BY emoji`,
|
||||||
|
[target_type, target_id],
|
||||||
|
);
|
||||||
|
req.app.get('io')?.to(access.room).emit('reaction:updated', {
|
||||||
|
target_type, target_id, reactions: counts, by: req.user.id, added, emoji: e,
|
||||||
|
});
|
||||||
|
return res.json({ added, reactions: counts });
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { query } from '../config/db.js';
|
import { query } from '../config/db.js';
|
||||||
import { requireAuth } from '../middleware/auth.js';
|
import { requireAuth } from '../middleware/auth.js';
|
||||||
import { getRole, requireWorkspace, hasPerm, ensureDefaultGroups, ROLES } from './guards.js';
|
import { getRole, requireWorkspace, hasPerm, ensureDefaultGroups, PERMS, ROLES } from './guards.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(requireAuth);
|
router.use(requireAuth);
|
||||||
@@ -114,6 +114,44 @@ router.post('/:id/members', requireWorkspace('viewer'), async (req, res) => {
|
|||||||
return res.status(201).json({ invite: inv[0] });
|
return res.status(201).json({ invite: inv[0] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/workspaces/:id/permissions — members with explicit overrides (admin+)
|
||||||
|
router.get('/:id/permissions', requireWorkspace('admin'), async (req, res) => {
|
||||||
|
const { rows } = await query(
|
||||||
|
`SELECT m.user_id, u.name, u.email, m.role,
|
||||||
|
(SELECT json_object_agg(permission, allow) FROM user_permissions
|
||||||
|
WHERE workspace_id = $1 AND user_id = m.user_id) AS overrides
|
||||||
|
FROM workspace_members m JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.workspace_id = $1 ORDER BY u.name`,
|
||||||
|
[req.params.id],
|
||||||
|
);
|
||||||
|
return res.json({ members: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/workspaces/:id/permissions { user_id, permission, mode }
|
||||||
|
// mode: 'allow' | 'deny' | 'inherit' (inherit removes the override) (admin+)
|
||||||
|
router.patch('/:id/permissions', requireWorkspace('admin'), async (req, res) => {
|
||||||
|
const { user_id, permission, mode } = req.body ?? {};
|
||||||
|
if (!PERMS.includes(permission)) return res.status(400).json({ error: 'Invalid permission' });
|
||||||
|
if (!['allow', 'deny', 'inherit'].includes(mode)) return res.status(400).json({ error: 'Invalid mode' });
|
||||||
|
if (!(await getRole(req.params.id, user_id))) {
|
||||||
|
return res.status(400).json({ error: 'User must be a workspace member' });
|
||||||
|
}
|
||||||
|
if (mode === 'inherit') {
|
||||||
|
await query(
|
||||||
|
'DELETE FROM user_permissions WHERE workspace_id = $1 AND user_id = $2 AND permission = $3',
|
||||||
|
[req.params.id, user_id, permission],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO user_permissions (workspace_id, user_id, permission, allow)
|
||||||
|
VALUES ($1,$2,$3,$4)
|
||||||
|
ON CONFLICT (workspace_id, user_id, permission) DO UPDATE SET allow = EXCLUDED.allow`,
|
||||||
|
[req.params.id, user_id, permission, mode === 'allow'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
// PATCH /api/workspaces/:id/members/:userId { role } (admin+)
|
// PATCH /api/workspaces/:id/members/:userId { role } (admin+)
|
||||||
router.patch('/:id/members/:userId', requireWorkspace('admin'), async (req, res) => {
|
router.patch('/:id/members/:userId', requireWorkspace('admin'), async (req, res) => {
|
||||||
const { role } = req.body ?? {};
|
const { role } = req.body ?? {};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export function initSocket(httpServer, corsOrigin) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const presence = new Map(); // room -> Map(socketId -> { userId, email, name })
|
const presence = new Map(); // room -> Map(socketId -> { userId, email, name })
|
||||||
|
const online = new Map(); // userId -> connection count (global presence)
|
||||||
|
|
||||||
function emitPresence(room) {
|
function emitPresence(room) {
|
||||||
const members = [...(presence.get(room)?.values() ?? [])];
|
const members = [...(presence.get(room)?.values() ?? [])];
|
||||||
@@ -40,6 +41,9 @@ export function initSocket(httpServer, corsOrigin) {
|
|||||||
const user = socket.data.user;
|
const user = socket.data.user;
|
||||||
// Personal room for notifications (bell).
|
// Personal room for notifications (bell).
|
||||||
socket.join(`user:${user.id}`);
|
socket.join(`user:${user.id}`);
|
||||||
|
// Global online presence.
|
||||||
|
online.set(user.id, (online.get(user.id) || 0) + 1);
|
||||||
|
io.emit('presence:online', [...online.keys()]);
|
||||||
|
|
||||||
socket.on('chat:join', async ({ workspaceId }) => {
|
socket.on('chat:join', async ({ workspaceId }) => {
|
||||||
try {
|
try {
|
||||||
@@ -110,10 +114,36 @@ export function initSocket(httpServer, corsOrigin) {
|
|||||||
socket.to(`page:${pageId}`).emit('typing', { userId: user.id, name });
|
socket.to(`page:${pageId}`).emit('typing', { userId: user.id, name });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('dm:join', async ({ conversationId }) => {
|
||||||
|
try {
|
||||||
|
if (!conversationId) return;
|
||||||
|
const { rows } = await query(
|
||||||
|
'SELECT 1 FROM conversation_members WHERE conversation_id = $1 AND user_id = $2',
|
||||||
|
[conversationId, user.id],
|
||||||
|
);
|
||||||
|
if (!rows[0]) return socket.emit('error:message', { error: 'Forbidden' });
|
||||||
|
socket.join(`dm:${conversationId}`);
|
||||||
|
} catch {
|
||||||
|
socket.emit('error:message', { error: 'Join failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('chat:typing', ({ workspaceId, name }) => {
|
||||||
|
socket.to(`chat:${workspaceId}`).emit('chat:typing', { userId: user.id, name });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('dm:typing', ({ conversationId, name }) => {
|
||||||
|
socket.to(`dm:${conversationId}`).emit('dm:typing', { userId: user.id, name });
|
||||||
|
});
|
||||||
|
|
||||||
socket.on('disconnect', () => {
|
socket.on('disconnect', () => {
|
||||||
for (const [room, members] of presence) {
|
for (const [room, members] of presence) {
|
||||||
if (members.delete(socket.id)) emitPresence(room);
|
if (members.delete(socket.id)) emitPresence(room);
|
||||||
}
|
}
|
||||||
|
const left = (online.get(user.id) || 1) - 1;
|
||||||
|
if (left <= 0) online.delete(user.id);
|
||||||
|
else online.set(user.id, left);
|
||||||
|
io.emit('presence:online', [...online.keys()]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -93,12 +93,20 @@ Content fields: `content_json` (TipTap JSON), `content_html` (rendered HTML).
|
|||||||
- `POST /workspaces/:id/members {email,role}` → pending invite + notification
|
- `POST /workspaces/:id/members {email,role}` → pending invite + notification
|
||||||
(workspace admin+ or `workspace.invite` permission)
|
(workspace admin+ or `workspace.invite` permission)
|
||||||
- `GET /invites/mine`, `POST /invites/:id/accept`, `POST /invites/:id/decline`
|
- `GET /invites/mine`, `POST /invites/:id/accept`, `POST /invites/:id/decline`
|
||||||
|
- `GET /workspaces/:id/permissions`, `PATCH /workspaces/:id/permissions`
|
||||||
|
(per-user allow/deny/inherit overrides, workspace admin+)
|
||||||
- `GET /notifications/mine?unread=1` → `{ notifications, unread }`
|
- `GET /notifications/mine?unread=1` → `{ notifications, unread }`
|
||||||
- `PATCH /notifications/:id/read`, `POST /notifications/read-all`
|
- `PATCH /notifications/:id/read`, `POST /notifications/read-all`
|
||||||
|
|
||||||
## Chat / teams / groups / upload
|
## Chat / teams / groups / upload
|
||||||
|
|
||||||
- `GET /chat/:workspaceId`, `POST /chat/:workspaceId {body}`, `DELETE /chat/message/:id`
|
- `GET /chat/:workspaceId`, `POST /chat/:workspaceId {body,attachment?}`,
|
||||||
|
`PATCH /chat/message/:id`, `DELETE /chat/message/:id`, `GET /chat/:workspaceId/search?q=`
|
||||||
|
- `GET /dm`, `POST /dm {user_id}`, `GET /dm/:id/messages`, `POST /dm/:id/messages`,
|
||||||
|
`PATCH/DELETE /dm/message/:mid`, `GET /dm/:id/search?q=`
|
||||||
|
- `POST /reactions/toggle {target_type,target_id,emoji}` (chat + DM messages)
|
||||||
|
- Socket: `chat:join`, `dm:join`, `chat:typing`, `dm:typing`, `presence:online`,
|
||||||
|
`chat:message/edited/deleted`, `dm:message/edited/deleted`, `reaction:updated`
|
||||||
- `GET /teams/by-workspace/:id`, `POST /teams`, `PATCH/DELETE /teams/:id`,
|
- `GET /teams/by-workspace/:id`, `POST /teams`, `PATCH/DELETE /teams/:id`,
|
||||||
team members CRUD, `POST /teams/:id/workspace {name}` (team workspace + badge)
|
team members CRUD, `POST /teams/:id/workspace {name}` (team workspace + badge)
|
||||||
- `GET /groups/by-workspace/:id` (seeds `Team Creators`), groups CRUD + members
|
- `GET /groups/by-workspace/:id` (seeds `Team Creators`), groups CRUD + members
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import { useThemeStore } from './stores/theme';
|
|||||||
import { useLocaleStore } from './stores/locale';
|
import { useLocaleStore } from './stores/locale';
|
||||||
import { useUiStore } from './stores/ui';
|
import { useUiStore } from './stores/ui';
|
||||||
import { fileUrl } from './files';
|
import { fileUrl } from './files';
|
||||||
|
import { connectSocket } from './socket';
|
||||||
import SettingsWindow from './components/SettingsWindow.vue';
|
import SettingsWindow from './components/SettingsWindow.vue';
|
||||||
import TimeTrackerButton from './components/TimeTrackerButton.vue';
|
import TimeTrackerButton from './components/TimeTrackerButton.vue';
|
||||||
import Bell from './components/Bell.vue';
|
import Bell from './components/Bell.vue';
|
||||||
|
import Icon from './components/Icon.vue';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const theme = useThemeStore();
|
const theme = useThemeStore();
|
||||||
@@ -32,6 +34,7 @@ onMounted(() => {
|
|||||||
locale.set(locale.locale);
|
locale.set(locale.locale);
|
||||||
theme.apply();
|
theme.apply();
|
||||||
auth.applyPrefs();
|
auth.applyPrefs();
|
||||||
|
if (auth.isLoggedIn) connectSocket();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -41,7 +44,7 @@ onMounted(() => {
|
|||||||
<div v-if="auth.isLoggedIn" class="top-right">
|
<div v-if="auth.isLoggedIn" class="top-right">
|
||||||
<Bell />
|
<Bell />
|
||||||
<TimeTrackerButton />
|
<TimeTrackerButton />
|
||||||
<button class="ghost icon-btn" @click="ui.openSettings()" title="⚙️">⚙️</button>
|
<button class="ghost icon-btn" @click="ui.openSettings()" title="Settings"><Icon name="settings" /></button>
|
||||||
</div>
|
</div>
|
||||||
<router-view />
|
<router-view />
|
||||||
<SettingsWindow />
|
<SettingsWindow />
|
||||||
|
|||||||
@@ -301,6 +301,45 @@ input:focus, select:focus { outline: 2px solid var(--accent); border-color: tran
|
|||||||
[data-theme='dark'] .side-error { background: #3f1d1d; color: #fca5a5; border-color: #7f2d2d; }
|
[data-theme='dark'] .side-error { background: #3f1d1d; color: #fca5a5; border-color: #7f2d2d; }
|
||||||
.ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.ellipsis { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ---- Chat: Discord/Slack mix ---- */
|
||||||
|
.chat-layout { display: flex; flex-direction: column; }
|
||||||
|
.chat-grid { display: grid; grid-template-columns: 280px 1fr; gap: 12px; align-items: start; }
|
||||||
|
.chat-list { display: flex; flex-direction: column; gap: 4px; max-height: calc(100vh - 220px); overflow-y: auto; }
|
||||||
|
.conv-row {
|
||||||
|
display: flex; gap: 10px; align-items: center;
|
||||||
|
padding: 8px; border-radius: 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.conv-row:hover { background: var(--bg-soft); }
|
||||||
|
.conv-row.active { background: var(--accent-soft); }
|
||||||
|
.avatar-wrap { position: relative; flex-shrink: 0; }
|
||||||
|
.online-dot {
|
||||||
|
position: absolute; bottom: -1px; right: -1px;
|
||||||
|
width: 11px; height: 11px; border-radius: 50%;
|
||||||
|
background: #22c55e; border: 2px solid var(--card);
|
||||||
|
}
|
||||||
|
.badge.static { position: static; }
|
||||||
|
.chat-pane { min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.chat-box.slim { max-height: calc(100vh - 340px); min-height: 40vh; }
|
||||||
|
.typing { font-style: italic; margin: 4px 0; min-height: 18px; }
|
||||||
|
.search-hits { margin-top: 8px; max-height: 140px; overflow-y: auto; }
|
||||||
|
.reactions { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
|
||||||
|
.reaction {
|
||||||
|
background: var(--bg-soft); border: 1px solid var(--border);
|
||||||
|
border-radius: 14px; padding: 1px 8px; font-size: 13px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.reaction.mine { border-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.msg-actions { display: flex; gap: 4px; margin-top: 4px; opacity: 0.35; position: relative; }
|
||||||
|
.chat-bubble:hover .msg-actions { opacity: 1; }
|
||||||
|
.emoji-pop {
|
||||||
|
position: absolute; z-index: 30; background: var(--card);
|
||||||
|
border: 1px solid var(--border); border-radius: 12px; padding: 6px;
|
||||||
|
display: flex; gap: 2px; font-size: 20px; box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.emoji-pop span { cursor: pointer; padding: 2px; border-radius: 6px; }
|
||||||
|
.emoji-pop span:hover { background: var(--accent-soft); }
|
||||||
|
.chat-img { max-width: 320px; border-radius: 10px; margin-top: 6px; display: block; }
|
||||||
|
.attach { cursor: pointer; }
|
||||||
|
|
||||||
/* ---- Scaling / overflow fixes ---- */
|
/* ---- Scaling / overflow fixes ---- */
|
||||||
.main { min-width: 0; }
|
.main { min-width: 0; }
|
||||||
.row > input, .row > select { min-width: 0; flex: 1; }
|
.row > input, .row > select { min-width: 0; flex: 1; }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import api from '../api';
|
|||||||
import { getSocket } from '../socket';
|
import { getSocket } from '../socket';
|
||||||
import { useLocaleStore } from '../stores/locale';
|
import { useLocaleStore } from '../stores/locale';
|
||||||
import { useWorkspaceStore } from '../stores/workspace';
|
import { useWorkspaceStore } from '../stores/workspace';
|
||||||
|
import Icon from './Icon.vue';
|
||||||
|
|
||||||
const L = useLocaleStore();
|
const L = useLocaleStore();
|
||||||
const ws = useWorkspaceStore();
|
const ws = useWorkspaceStore();
|
||||||
@@ -75,10 +76,10 @@ onUnmounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="bell">
|
<div class="bell">
|
||||||
<button class="ghost icon-btn bell-btn" @click="open = !open" :title="L.t('bell.title')">
|
<button class="ghost icon-btn bell-btn" @click="open = !open" :title="L.t('bell.title')">
|
||||||
🔔<span v-if="total" class="badge">{{ total }}</span>
|
<Icon name="bell" /><span v-if="total" class="badge">{{ total }}</span>
|
||||||
</button>
|
</button>
|
||||||
<div v-if="open" class="card bell-pop">
|
<div v-if="open" class="card bell-pop">
|
||||||
<h4 style="margin: 0 0 8px">🔔 {{ L.t('bell.title') }}</h4>
|
<h4 style="margin: 0 0 8px"><Icon name="bell" :size="16" /> {{ L.t('bell.title') }}</h4>
|
||||||
<div v-if="invites.length">
|
<div v-if="invites.length">
|
||||||
<div class="side-label">{{ L.t('bell.invites') }}</div>
|
<div class="side-label">{{ L.t('bell.invites') }}</div>
|
||||||
<div v-for="inv in invites" :key="inv.id" class="card invite-card">
|
<div v-for="inv in invites" :key="inv.id" class="card invite-card">
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import api from '../api';
|
||||||
|
import { fileUrl } from '../files';
|
||||||
|
import { useAuthStore } from '../stores/auth';
|
||||||
|
import { useLocaleStore } from '../stores/locale';
|
||||||
|
import Avatar from './Avatar.vue';
|
||||||
|
import Icon from './Icon.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
message: Object,
|
||||||
|
targetType: { type: String, default: 'chat' }, // 'chat' | 'dm'
|
||||||
|
basePath: { type: String, default: 'chat' }, // REST base for edit/delete
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['deleted']);
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const L = useLocaleStore();
|
||||||
|
|
||||||
|
const editing = ref(false);
|
||||||
|
const editText = ref('');
|
||||||
|
const showPicker = ref(false);
|
||||||
|
const FLAT = ['👍', '❤️', '😄', '🎉', '👀', '✅', '🔥', '💡'];
|
||||||
|
|
||||||
|
const mine = (id) => (props.message.reactions || []).some((r) => (r.users || []).includes(id));
|
||||||
|
|
||||||
|
async function toggle(emoji) {
|
||||||
|
showPicker.value = false;
|
||||||
|
const { data } = await api.post('/reactions/toggle', {
|
||||||
|
target_type: props.targetType, target_id: props.message.id, emoji,
|
||||||
|
});
|
||||||
|
props.message.reactions = data.reactions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
editText.value = props.message.body;
|
||||||
|
editing.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit() {
|
||||||
|
if (!editText.value.trim()) return;
|
||||||
|
const { data } = await api.patch(`/${props.basePath}/message/${props.message.id}`, {
|
||||||
|
body: editText.value.trim(),
|
||||||
|
});
|
||||||
|
Object.assign(props.message, data.message);
|
||||||
|
editing.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
if (!confirm('✕?')) return;
|
||||||
|
await api.delete(`/${props.basePath}/message/${props.message.id}`);
|
||||||
|
emit('deleted', props.message.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachSrc() {
|
||||||
|
return props.message.attachment ? fileUrl(props.message.attachment) : '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="chat-msg" :class="{ mine: message.user_id === auth.user?.id }">
|
||||||
|
<Avatar :name="message.author_name" :avatar="message.author_avatar" :size="32" />
|
||||||
|
<div class="chat-bubble">
|
||||||
|
<div class="muted chat-meta">
|
||||||
|
<strong>{{ message.author_name || '?' }}</strong> ·
|
||||||
|
{{ new Date(message.created_at).toLocaleString() }}
|
||||||
|
<span v-if="message.edited_at" class="muted"> · {{ L.t('chat.edited') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!editing">{{ message.body }}</div>
|
||||||
|
<div v-else class="row">
|
||||||
|
<input v-model="editText" @keyup.enter="saveEdit" />
|
||||||
|
<button class="mini" @click="saveEdit">✓</button>
|
||||||
|
<button class="ghost mini" @click="editing = false">✕</button>
|
||||||
|
</div>
|
||||||
|
<img v-if="attachSrc()" :src="attachSrc()" class="chat-img" loading="lazy" />
|
||||||
|
<div v-if="(message.reactions || []).length" class="reactions">
|
||||||
|
<span
|
||||||
|
v-for="r in message.reactions" :key="r.emoji"
|
||||||
|
class="reaction" :class="{ mine: mine(auth.user?.id) }"
|
||||||
|
@click="toggle(r.emoji)"
|
||||||
|
>
|
||||||
|
{{ r.emoji }} {{ r.count }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="msg-actions">
|
||||||
|
<button class="ghost mini" @click="showPicker = !showPicker"><Icon name="smile" :size="14" /></button>
|
||||||
|
<div v-if="showPicker" class="emoji-pop">
|
||||||
|
<span v-for="e in FLAT" :key="e" @click="toggle(e)">{{ e }}</span>
|
||||||
|
</div>
|
||||||
|
<button v-if="message.user_id === auth.user?.id" class="ghost mini" @click="startEdit">{{ L.t('chat.edit') }}</button>
|
||||||
|
<button v-if="message.user_id === auth.user?.id" class="ghost mini" @click="remove">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -36,6 +36,6 @@ const date = computed(() =>
|
|||||||
<h2 style="margin: 0">{{ period.emoji }} {{ L.t(period.key) }}{{ name ? `, ${name}` : '' }}!</h2>
|
<h2 style="margin: 0">{{ period.emoji }} {{ L.t(period.key) }}{{ name ? `, ${name}` : '' }}!</h2>
|
||||||
<p class="muted" style="margin: 4px 0 0">📅 {{ date }}</p>
|
<p class="muted" style="margin: 4px 0 0">📅 {{ date }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="clock">🕒 {{ clock }}</div>
|
<div class="clock">{{ clock }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup>
|
||||||
|
// Flat, modern line icons (inline SVG, no external dependency).
|
||||||
|
// Usage: <Icon name="home" :size="18" />
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
name: { type: String, required: true },
|
||||||
|
size: { type: Number, default: 18 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const PATHS = {
|
||||||
|
home: '<path d="M3 11l9-8 9 8"/><path d="M5 10v10h5v-6h4v6h5V10"/>',
|
||||||
|
tasks: '<rect x="4" y="4" width="16" height="16" rx="3"/><path d="M9 12l2 2 4-4"/>',
|
||||||
|
calendar: '<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M8 3v4M16 3v4M3 10h18"/>',
|
||||||
|
timer: '<circle cx="12" cy="13" r="7"/><path d="M12 9v4l2.5 2.5M9 2h6"/>',
|
||||||
|
chat: '<path d="M4 5h16v11H9l-5 4z"/>',
|
||||||
|
shield: '<path d="M12 3l7 3v5c0 5-3.5 8-7 9-3.5-1-7-4-7-9V6z"/><path d="M9.5 12l2 2 3.5-4"/>',
|
||||||
|
settings:
|
||||||
|
'<path d="M4 7h9M17 7h3M4 17h3M11 17h9"/><circle cx="15" cy="7" r="2"/><circle cx="9" cy="17" r="2"/><path d="M4 12h9M17 12h3"/><circle cx="15" cy="12" r="2"/>',
|
||||||
|
bell: '<path d="M18 8a6 6 0 1 0-12 0c0 7-3 8-3 8h18s-3-1-3-8"/><path d="M10.3 21a2 2 0 0 0 3.4 0"/>',
|
||||||
|
logout: '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/>',
|
||||||
|
refresh: '<path d="M21 12a9 9 0 1 1-2.6-6.4"/><path d="M21 3v6h-6"/>',
|
||||||
|
search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>',
|
||||||
|
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||||
|
close: '<path d="M6 6l12 12M18 6L6 18"/>',
|
||||||
|
check: '<path d="M4 12l5 5L20 7"/>',
|
||||||
|
chevDown: '<path d="M6 9l6 6 6-6"/>',
|
||||||
|
chevRight: '<path d="M9 6l6 6-6 6"/>',
|
||||||
|
users: '<circle cx="9" cy="8" r="3.5"/><path d="M3.5 20c0-3.6 2.5-6 5.5-6s5.5 2.4 5.5 6"/><circle cx="17" cy="9" r="2.5"/><path d="M15.8 14.6c2.7.4 4.7 2.4 4.7 5.4"/>',
|
||||||
|
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 3.5-7 8-7s8 3 8 7"/>',
|
||||||
|
clock: '<circle cx="12" cy="12" r="8.5"/><path d="M12 7v5l3 2"/>',
|
||||||
|
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 7l9 6 9-6"/>',
|
||||||
|
image: '<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.5"/><path d="M21 16l-5-5-9 9"/>',
|
||||||
|
attach: '<path d="M21 11l-9 9a5.5 5.5 0 0 1-8-8l9-9a4 4 0 0 1 5 5l-9 9a2.5 2.5 0 0 1-4-4l8-8"/>',
|
||||||
|
send: '<path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4z"/>',
|
||||||
|
edit: '<path d="M17 3l4 4L8 20l-5 1 1-5z"/>',
|
||||||
|
globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18 14 14 0 0 1 0-18z"/>',
|
||||||
|
key: '<circle cx="8" cy="15" r="4"/><path d="M11 12l9-9M17 5l2 2M14 8l2 2"/>',
|
||||||
|
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/>',
|
||||||
|
moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
|
||||||
|
desktop: '<rect x="2" y="4" width="20" height="13" rx="2"/><path d="M8 21h8M12 17v4"/>',
|
||||||
|
lock: '<rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
|
||||||
|
back: '<path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/>',
|
||||||
|
smile: '<circle cx="12" cy="12" r="9"/><path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01"/>',
|
||||||
|
dot: '<circle cx="12" cy="12" r="5" fill="currentColor" stroke="none"/>',
|
||||||
|
};
|
||||||
|
|
||||||
|
const inner = computed(() => PATHS[props.name] || '');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
:width="size" :height="size" viewBox="0 0 24 24" fill="none"
|
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
style="flex-shrink: 0; vertical-align: -3px" v-html="inner"
|
||||||
|
></svg>
|
||||||
|
</template>
|
||||||
@@ -3,6 +3,7 @@ import { ref } from 'vue';
|
|||||||
import api from '../api';
|
import api from '../api';
|
||||||
import { useLocaleStore } from '../stores/locale';
|
import { useLocaleStore } from '../stores/locale';
|
||||||
import UserSearch from './UserSearch.vue';
|
import UserSearch from './UserSearch.vue';
|
||||||
|
import Icon from './Icon.vue';
|
||||||
|
|
||||||
const props = defineProps({ workspaceId: String });
|
const props = defineProps({ workspaceId: String });
|
||||||
const emit = defineEmits(['invited']);
|
const emit = defineEmits(['invited']);
|
||||||
@@ -37,7 +38,7 @@ async function invite() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h4>{{ L.t('inv.title') }}</h4>
|
<h4><Icon name="mail" :size="16" /> {{ L.t('inv.title') }}</h4>
|
||||||
<p class="muted">{{ L.t('inv.hint') }}</p>
|
<p class="muted">{{ L.t('inv.hint') }}</p>
|
||||||
<UserSearch @pick="onPick" />
|
<UserSearch @pick="onPick" />
|
||||||
<div class="row" style="margin-top: 8px">
|
<div class="row" style="margin-top: 8px">
|
||||||
@@ -47,7 +48,7 @@ async function invite() {
|
|||||||
<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">✉️ {{ L.t('inv.btn') }}</button>
|
<button @click="invite">{{ L.t('inv.btn') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="picked" class="muted">🔎 {{ picked.name }} <{{ picked.email }}></p>
|
<p v-if="picked" class="muted">🔎 {{ picked.name }} <{{ picked.email }}></p>
|
||||||
<p v-if="msg" style="color: green">{{ msg }}</p>
|
<p v-if="msg" style="color: green">{{ msg }}</p>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useLocaleStore } from '../stores/locale';
|
|||||||
import { useThemeStore } from '../stores/theme';
|
import { useThemeStore } from '../stores/theme';
|
||||||
import { useUiStore } from '../stores/ui';
|
import { useUiStore } from '../stores/ui';
|
||||||
import Avatar from './Avatar.vue';
|
import Avatar from './Avatar.vue';
|
||||||
|
import Icon from './Icon.vue';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const L = useLocaleStore();
|
const L = useLocaleStore();
|
||||||
@@ -124,8 +125,12 @@ async function uploadAvatar(e) {
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
uploading.value = 'avatar';
|
uploading.value = 'avatar';
|
||||||
|
err.value = '';
|
||||||
try {
|
try {
|
||||||
form.avatar = await uploadImage(file);
|
form.avatar = await uploadImage(file);
|
||||||
|
const { data } = await api.patch('/users/me', { avatar: form.avatar });
|
||||||
|
persistUser(data.user);
|
||||||
|
msg.value = L.t('set.saved');
|
||||||
} catch {
|
} catch {
|
||||||
err.value = 'Upload failed';
|
err.value = 'Upload failed';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -137,9 +142,13 @@ async function uploadBg(e) {
|
|||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
uploading.value = 'bg';
|
uploading.value = 'bg';
|
||||||
|
err.value = '';
|
||||||
try {
|
try {
|
||||||
bgUrl.value = await uploadImage(file);
|
bgUrl.value = await uploadImage(file);
|
||||||
bgPreset.value = 'custom';
|
bgPreset.value = 'custom';
|
||||||
|
const { data } = await api.patch('/users/me', { bg_image: bgUrl.value });
|
||||||
|
persistUser(data.user);
|
||||||
|
msg.value = L.t('set.saved');
|
||||||
} catch {
|
} catch {
|
||||||
err.value = 'Upload failed';
|
err.value = 'Upload failed';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -157,15 +166,15 @@ async function uploadBg(e) {
|
|||||||
<span class="dot yellow"></span>
|
<span class="dot yellow"></span>
|
||||||
<span class="dot green"></span>
|
<span class="dot green"></span>
|
||||||
</div>
|
</div>
|
||||||
<strong>⚙️ {{ L.t('set.title') }}</strong>
|
<strong><Icon name="settings" :size="18" /> {{ L.t('set.title') }}</strong>
|
||||||
<button class="ghost" @click="ui.closeSettings()">✕ {{ L.t('set.close') }}</button>
|
<button class="ghost" @click="ui.closeSettings()">✕ {{ L.t('set.close') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-body">
|
<div class="settings-body">
|
||||||
<nav class="settings-tabs">
|
<nav class="settings-tabs">
|
||||||
<button :class="{ active: ui.settingsTab === 'profile' }" @click="ui.settingsTab = 'profile'">{{ L.t('set.profile') }}</button>
|
<button :class="{ active: ui.settingsTab === 'profile' }" @click="ui.settingsTab = 'profile'"><Icon name="user" :size="16" /> {{ 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 === 'appearance' }" @click="ui.settingsTab = 'appearance'"><Icon name="sun" :size="16" /> {{ 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 === 'language' }" @click="ui.settingsTab = 'language'"><Icon name="globe" :size="16" /> {{ L.t('set.language') }}</button>
|
||||||
<button :class="{ active: ui.settingsTab === 'background' }" @click="ui.settingsTab = 'background'">{{ L.t('set.background') }}</button>
|
<button :class="{ active: ui.settingsTab === 'background' }" @click="ui.settingsTab = 'background'"><Icon name="image" :size="16" /> {{ L.t('set.background') }}</button>
|
||||||
</nav>
|
</nav>
|
||||||
<section class="settings-content">
|
<section class="settings-content">
|
||||||
<div v-if="ui.settingsTab === 'profile'" style="display: grid; gap: 10px">
|
<div v-if="ui.settingsTab === 'profile'" style="display: grid; gap: 10px">
|
||||||
@@ -180,6 +189,7 @@ async function uploadBg(e) {
|
|||||||
<span v-for="e in EMOJIS" :key="e" @click="form.avatar = e">{{ e }}</span>
|
<span v-for="e in EMOJIS" :key="e" @click="form.avatar = e">{{ e }}</span>
|
||||||
</div>
|
</div>
|
||||||
<label class="row"><input type="file" accept="image/*" style="width: auto" @change="uploadAvatar" /> {{ uploading === 'avatar' ? '⏳' : L.t('set.upload') }}</label>
|
<label class="row"><input type="file" accept="image/*" style="width: auto" @change="uploadAvatar" /> {{ uploading === 'avatar' ? '⏳' : L.t('set.upload') }}</label>
|
||||||
|
<p class="muted">✓ {{ L.t('set.autoSaved') }}</p>
|
||||||
<label>{{ L.t('set.name') }}</label>
|
<label>{{ L.t('set.name') }}</label>
|
||||||
<input v-model="form.name" />
|
<input v-model="form.name" />
|
||||||
<label>{{ L.t('set.email') }}</label>
|
<label>{{ L.t('set.email') }}</label>
|
||||||
@@ -196,9 +206,9 @@ async function uploadBg(e) {
|
|||||||
<div v-if="ui.settingsTab === 'appearance'" style="display: grid; gap: 10px">
|
<div v-if="ui.settingsTab === 'appearance'" style="display: grid; gap: 10px">
|
||||||
<label>{{ L.t('set.theme') }}</label>
|
<label>{{ L.t('set.theme') }}</label>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button :class="{ ghost: theme.mode !== 'light' }" @click="setTheme('light')">{{ L.t('set.light') }}</button>
|
<button :class="{ ghost: theme.mode !== 'light' }" @click="setTheme('light')"><Icon name="sun" :size="16" /> {{ L.t('set.light') }}</button>
|
||||||
<button :class="{ ghost: theme.mode !== 'dark' }" @click="setTheme('dark')">{{ L.t('set.dark') }}</button>
|
<button :class="{ ghost: theme.mode !== 'dark' }" @click="setTheme('dark')"><Icon name="moon" :size="16" /> {{ L.t('set.dark') }}</button>
|
||||||
<button :class="{ ghost: theme.mode !== 'system' }" @click="setTheme('system')">{{ L.t('set.system') }}</button>
|
<button :class="{ ghost: theme.mode !== 'system' }" @click="setTheme('system')"><Icon name="desktop" :size="16" /> {{ L.t('set.system') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">👀 {{ L.t('set.theme') }}: <strong>{{ theme.resolved }}</strong></p>
|
<p class="muted">👀 {{ L.t('set.theme') }}: <strong>{{ theme.resolved }}</strong></p>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,6 +235,7 @@ async function uploadBg(e) {
|
|||||||
<label>{{ L.t('set.bgUrl') }}</label>
|
<label>{{ L.t('set.bgUrl') }}</label>
|
||||||
<input v-model="bgUrl" placeholder="https://…" @input="bgPreset = 'custom'" />
|
<input v-model="bgUrl" placeholder="https://…" @input="bgPreset = 'custom'" />
|
||||||
<label class="row"><input type="file" accept="image/*" style="width: auto" @change="uploadBg" /> {{ uploading === 'bg' ? '⏳' : L.t('set.upload') }}</label>
|
<label class="row"><input type="file" accept="image/*" style="width: auto" @change="uploadBg" /> {{ uploading === 'bg' ? '⏳' : L.t('set.upload') }}</label>
|
||||||
|
<p class="muted">✓ {{ L.t('set.autoSaved') }}</p>
|
||||||
<p class="muted">{{ L.t('set.bgUrlHint') }}</p>
|
<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>
|
<label class="row"><input type="checkbox" v-model="bgBlur" style="width: auto" /> {{ L.t('set.bgBlur') }}</label>
|
||||||
<label class="row"><input type="checkbox" v-model="bgGlass" style="width: auto" /> {{ L.t('set.bgGlass') }}</label>
|
<label class="row"><input type="checkbox" v-model="bgGlass" style="width: auto" /> {{ L.t('set.bgGlass') }}</label>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useLocaleStore } from '../stores/locale';
|
|||||||
import { useUiStore } from '../stores/ui';
|
import { useUiStore } from '../stores/ui';
|
||||||
import PageTree from './PageTree.vue';
|
import PageTree from './PageTree.vue';
|
||||||
import Avatar from './Avatar.vue';
|
import Avatar from './Avatar.vue';
|
||||||
|
import Icon from './Icon.vue';
|
||||||
|
|
||||||
const props = defineProps({ activePageId: String });
|
const props = defineProps({ activePageId: String });
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -85,7 +86,7 @@ async function createBook() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteBook(id) {
|
async function deleteBook(id) {
|
||||||
if (!confirm('🗑️?')) return;
|
if (!confirm('✕?')) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/books/${id}`);
|
await api.delete(`/books/${id}`);
|
||||||
await ws.fetchBooks();
|
await ws.fetchBooks();
|
||||||
@@ -120,7 +121,7 @@ onMounted(refresh);
|
|||||||
<div class="user-chip">
|
<div class="user-chip">
|
||||||
<Avatar :name="auth.user?.name" :avatar="auth.user?.avatar" :size="32" />
|
<Avatar :name="auth.user?.name" :avatar="auth.user?.avatar" :size="32" />
|
||||||
<strong style="flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis">{{ auth.user?.name }}</strong>
|
<strong style="flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis">{{ auth.user?.name }}</strong>
|
||||||
<button class="ghost icon-btn" @click="ui.openSettings()" title="⚙️">⚙️</button>
|
<button class="ghost icon-btn" @click="ui.openSettings()" title="Settings"><Icon name="settings" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="sideErr" class="side-error">{{ sideErr }}</p>
|
<p v-if="sideErr" class="side-error">{{ sideErr }}</p>
|
||||||
@@ -131,7 +132,7 @@ onMounted(refresh);
|
|||||||
{{ w.name }}{{ w.team_name ? ` · TEAM_${w.team_name}` : '' }} ({{ w.role }})
|
{{ w.name }}{{ w.team_name ? ` · TEAM_${w.team_name}` : '' }} ({{ w.role }})
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="ghost icon-btn" @click="refresh" :title="L.t('side.refresh')">⟳</button>
|
<button class="ghost icon-btn" @click="refresh" :title="L.t('side.refresh')"><Icon name="refresh" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input v-model="newWs" :placeholder="L.t('side.newWorkspace')" @keyup.enter="createWs" />
|
<input v-model="newWs" :placeholder="L.t('side.newWorkspace')" @keyup.enter="createWs" />
|
||||||
@@ -139,12 +140,12 @@ onMounted(refresh);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="side-nav">
|
<nav class="side-nav">
|
||||||
<router-link to="/">🏠 {{ L.t('nav.dashboard') }}</router-link>
|
<router-link to="/"><Icon name="home" /> {{ L.t('nav.dashboard') }}</router-link>
|
||||||
<router-link to="/tasks">✅ {{ L.t('nav.tasks') }}</router-link>
|
<router-link to="/tasks"><Icon name="tasks" /> {{ L.t('nav.tasks') }}</router-link>
|
||||||
<router-link to="/calendar">🗓️ {{ L.t('nav.calendar') }}</router-link>
|
<router-link to="/calendar"><Icon name="calendar" /> {{ L.t('nav.calendar') }}</router-link>
|
||||||
<router-link to="/time">⏱️ {{ L.t('nav.time') }}</router-link>
|
<router-link to="/time"><Icon name="timer" /> {{ L.t('nav.time') }}</router-link>
|
||||||
<router-link to="/chat">💬 {{ L.t('nav.chat') }}</router-link>
|
<router-link to="/chat"><Icon name="chat" /> {{ L.t('nav.chat') }}</router-link>
|
||||||
<router-link v-if="auth.user?.isAdmin" to="/admin">🛡️ {{ L.t('nav.admin') }}</router-link>
|
<router-link v-if="auth.user?.isAdmin" to="/admin"><Icon name="shield" /> {{ L.t('nav.admin') }}</router-link>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<input v-model="search" :placeholder="L.t('side.searchPh')" @input="doSearch" />
|
<input v-model="search" :placeholder="L.t('side.searchPh')" @input="doSearch" />
|
||||||
@@ -164,7 +165,7 @@ onMounted(refresh);
|
|||||||
<span class="ellipsis">{{ collapsed[b.id] ? '▶' : '▼' }} {{ b.icon }} {{ b.title }}</span>
|
<span class="ellipsis">{{ collapsed[b.id] ? '▶' : '▼' }} {{ b.icon }} {{ b.title }}</span>
|
||||||
<span class="row">
|
<span class="row">
|
||||||
<button class="ghost mini" @click.stop="createPage(b.id, null)" title="+">+</button>
|
<button class="ghost mini" @click.stop="createPage(b.id, null)" title="+">+</button>
|
||||||
<button class="ghost mini" @click.stop="deleteBook(b.id)" title="🗑️">🗑️</button>
|
<button class="ghost mini" @click.stop="deleteBook(b.id)" title="✕">✕</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!collapsed[b.id]" class="book-pages">
|
<div v-if="!collapsed[b.id]" class="book-pages">
|
||||||
@@ -189,7 +190,7 @@ onMounted(refresh);
|
|||||||
|
|
||||||
<div class="side-footer">
|
<div class="side-footer">
|
||||||
<span class="muted">{{ ws.role }}</span>
|
<span class="muted">{{ ws.role }}</span>
|
||||||
<button class="ghost" @click="logout">🚪 {{ L.t('nav.logout') }}</button>
|
<button class="ghost" @click="logout"><Icon name="logout" /> {{ L.t('nav.logout') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
|
|||||||
import { useTimeStore, formatDuration } from '../stores/time';
|
import { useTimeStore, formatDuration } from '../stores/time';
|
||||||
import { useWorkspaceStore } from '../stores/workspace';
|
import { useWorkspaceStore } from '../stores/workspace';
|
||||||
import { useLocaleStore } from '../stores/locale';
|
import { useLocaleStore } from '../stores/locale';
|
||||||
|
import Icon from './Icon.vue';
|
||||||
|
|
||||||
const time = useTimeStore();
|
const time = useTimeStore();
|
||||||
const ws = useWorkspaceStore();
|
const ws = useWorkspaceStore();
|
||||||
@@ -49,7 +50,7 @@ async function start() {
|
|||||||
@click="toggle"
|
@click="toggle"
|
||||||
:title="L.t('time.title')"
|
:title="L.t('time.title')"
|
||||||
>
|
>
|
||||||
⏱️ {{ time.open ? elapsed : L.t('time.start') }}
|
<Icon name="timer" :size="16" /> {{ time.open ? elapsed : L.t('time.start') }}
|
||||||
</button>
|
</button>
|
||||||
<div v-if="open && !time.open" class="card timer-pop">
|
<div v-if="open && !time.open" class="card timer-pop">
|
||||||
<input v-model="desc" :placeholder="L.t('time.descPh')" />
|
<input v-model="desc" :placeholder="L.t('time.descPh')" />
|
||||||
|
|||||||
+19
-6
@@ -11,7 +11,7 @@ export default {
|
|||||||
auth: {
|
auth: {
|
||||||
title: 'Willkommen zurück', sub: 'Melde dich an.',
|
title: 'Willkommen zurück', sub: 'Melde dich an.',
|
||||||
email: 'E-Mail', password: 'Passwort', btn: 'Anmelden',
|
email: 'E-Mail', password: 'Passwort', btn: 'Anmelden',
|
||||||
locked: '🔒 Accounts werden vom Admin erstellt.',
|
locked: 'Accounts werden vom Admin erstellt.',
|
||||||
},
|
},
|
||||||
bell: {
|
bell: {
|
||||||
title: 'Mitteilungen', invites: 'Einladungen', notifs: 'Neuigkeiten',
|
title: 'Mitteilungen', invites: 'Einladungen', notifs: 'Neuigkeiten',
|
||||||
@@ -38,17 +38,17 @@ export default {
|
|||||||
evening: 'Guten Abend', night: 'Gute Nacht',
|
evening: 'Guten Abend', night: 'Gute Nacht',
|
||||||
},
|
},
|
||||||
set: {
|
set: {
|
||||||
title: 'Einstellungen', profile: '👤 Profil', appearance: '🎨 Darstellung',
|
title: 'Einstellungen', profile: 'Profil', appearance: 'Darstellung',
|
||||||
language: '🌍 Sprache', background: '🖼️ Hintergrund',
|
language: 'Sprache', background: 'Hintergrund',
|
||||||
name: 'Anzeigename', email: 'E-Mail', avatar: 'Profilbild (Emoji oder Bild-URL)',
|
name: 'Anzeigename', email: 'E-Mail', avatar: 'Profilbild (Emoji oder Bild-URL)',
|
||||||
avatarHint: 'Z. B. 🦊 oder https://…/foto.png',
|
avatarHint: 'Z. B. 🦊 oder https://…/foto.png',
|
||||||
curPw: 'Aktuelles Passwort', newPw: 'Neues Passwort (min. 8 Zeichen)',
|
curPw: 'Aktuelles Passwort', newPw: 'Neues Passwort (min. 8 Zeichen)',
|
||||||
changePw: 'Passwort ändern', pwChanged: 'Passwort geändert ✅',
|
changePw: 'Passwort ändern', pwChanged: 'Passwort geändert ✅',
|
||||||
save: 'Speichern', saved: 'Gespeichert ✅', theme: 'Farbschema',
|
save: 'Speichern', saved: 'Gespeichert ✅', theme: 'Farbschema',
|
||||||
light: '☀️ Hell', dark: '🌙 Dunkel', system: '🖥️ System',
|
light: 'Hell', dark: 'Dunkel', system: 'System',
|
||||||
bgUrl: 'Hintergrundbild-URL', bgUrlHint: 'Leer lassen = kein Bild. Oder Preset wählen:',
|
bgUrl: 'Hintergrundbild-URL', bgUrlHint: 'Leer lassen = kein Bild. Oder Preset wählen:',
|
||||||
bgBlur: 'Hintergrund weichzeichnen (Blur)', bgGlass: 'Widgets als Glas darstellen',
|
bgBlur: 'Hintergrund weichzeichnen (Blur)', bgGlass: 'Widgets als Glas darstellen',
|
||||||
upload: 'Bild hochladen …', close: 'Schließen',
|
upload: 'Bild hochladen …', autoSaved: 'Wird sofort übernommen ✓', close: 'Schließen',
|
||||||
presetNone: 'Kein Bild', presetCustom: 'Eigene URL',
|
presetNone: 'Kein Bild', presetCustom: 'Eigene URL',
|
||||||
},
|
},
|
||||||
task: {
|
task: {
|
||||||
@@ -81,9 +81,22 @@ export default {
|
|||||||
back: 'Zurück', loading: 'Lädt …',
|
back: 'Zurück', loading: 'Lädt …',
|
||||||
},
|
},
|
||||||
inv: {
|
inv: {
|
||||||
title: '✉️ Mitarbeiter einladen', hint: 'Tippen zum Suchen – oder volle E-Mail eingeben.',
|
title: 'Mitarbeiter einladen', hint: 'Tippen zum Suchen – oder volle E-Mail eingeben.',
|
||||||
ph: 'Name oder E-Mail suchen …', btn: 'Einladen', done: 'Eingeladen',
|
ph: 'Name oder E-Mail suchen …', btn: 'Einladen', done: 'Eingeladen',
|
||||||
pick: 'Person wählen …',
|
pick: 'Person wählen …',
|
||||||
},
|
},
|
||||||
|
chat: {
|
||||||
|
title: 'Team-Chat', ph: 'Nachricht schreiben …', send: 'Senden',
|
||||||
|
empty: 'Noch keine Nachrichten. Sag Hallo! 👋',
|
||||||
|
tabs: 'Chats', teamTabs: 'Team-Chats', search: 'Suchen …',
|
||||||
|
type: 'Nachricht …', edit: 'Bearbeiten', edited: '(bearbeitet)',
|
||||||
|
attach: 'Bild anhängen', typing: 'schreibt …', online: 'online',
|
||||||
|
noConv: 'Wähle links einen Chat oder starte einen neuen.',
|
||||||
|
pickUser: 'User suchen für neuen Chat …', unread: 'neu',
|
||||||
|
},
|
||||||
|
perm: {
|
||||||
|
title: 'Rechte pro Benutzer', allow: 'Erlauben', deny: 'Verbieten',
|
||||||
|
inherit: 'Vererbt', manage: 'Rechte',
|
||||||
|
},
|
||||||
com: { title: 'Kommentare', ph: 'Kommentar schreiben …', post: 'Posten' },
|
com: { title: 'Kommentare', ph: 'Kommentar schreiben …', post: 'Posten' },
|
||||||
};
|
};
|
||||||
|
|||||||
+19
-6
@@ -11,7 +11,7 @@ export default {
|
|||||||
auth: {
|
auth: {
|
||||||
title: 'Welcome back', sub: 'Log in to your workspace.',
|
title: 'Welcome back', sub: 'Log in to your workspace.',
|
||||||
email: 'Email', password: 'Password', btn: 'Log in',
|
email: 'Email', password: 'Password', btn: 'Log in',
|
||||||
locked: '🔒 Accounts are created by an admin.',
|
locked: 'Accounts are created by an admin.',
|
||||||
},
|
},
|
||||||
bell: {
|
bell: {
|
||||||
title: 'Notifications', invites: 'Invites', notifs: 'Updates',
|
title: 'Notifications', invites: 'Invites', notifs: 'Updates',
|
||||||
@@ -38,17 +38,17 @@ export default {
|
|||||||
evening: 'Good evening', night: 'Good night',
|
evening: 'Good evening', night: 'Good night',
|
||||||
},
|
},
|
||||||
set: {
|
set: {
|
||||||
title: 'Settings', profile: '👤 Profile', appearance: '🎨 Appearance',
|
title: 'Settings', profile: 'Profile', appearance: 'Appearance',
|
||||||
language: '🌍 Language', background: '🖼️ Background',
|
language: 'Language', background: 'Background',
|
||||||
name: 'Display name', email: 'Email', avatar: 'Avatar (emoji or image URL)',
|
name: 'Display name', email: 'Email', avatar: 'Avatar (emoji or image URL)',
|
||||||
avatarHint: 'E.g. 🦊 or https://…/photo.png',
|
avatarHint: 'E.g. 🦊 or https://…/photo.png',
|
||||||
curPw: 'Current password', newPw: 'New password (min. 8 chars)',
|
curPw: 'Current password', newPw: 'New password (min. 8 chars)',
|
||||||
changePw: 'Change password', pwChanged: 'Password changed ✅',
|
changePw: 'Change password', pwChanged: 'Password changed ✅',
|
||||||
save: 'Save', saved: 'Saved ✅', theme: 'Color scheme',
|
save: 'Save', saved: 'Saved ✅', theme: 'Color scheme',
|
||||||
light: '☀️ Light', dark: '🌙 Dark', system: '🖥️ System',
|
light: 'Light', dark: 'Dark', system: 'System',
|
||||||
bgUrl: 'Background image URL', bgUrlHint: 'Empty = no image. Or pick a preset:',
|
bgUrl: 'Background image URL', bgUrlHint: 'Empty = no image. Or pick a preset:',
|
||||||
bgBlur: 'Blur background', bgGlass: 'Glassmorphism widgets',
|
bgBlur: 'Blur background', bgGlass: 'Glassmorphism widgets',
|
||||||
upload: 'Upload image …', close: 'Close',
|
upload: 'Upload image …', autoSaved: 'Applied instantly ✓', close: 'Close',
|
||||||
presetNone: 'No image', presetCustom: 'Custom URL',
|
presetNone: 'No image', presetCustom: 'Custom URL',
|
||||||
},
|
},
|
||||||
task: {
|
task: {
|
||||||
@@ -81,9 +81,22 @@ export default {
|
|||||||
back: 'Back', loading: 'Loading …',
|
back: 'Back', loading: 'Loading …',
|
||||||
},
|
},
|
||||||
inv: {
|
inv: {
|
||||||
title: '✉️ Invite collaborator', hint: 'Type to search – or enter a full email.',
|
title: 'Invite collaborator', hint: 'Type to search – or enter a full email.',
|
||||||
ph: 'Search name or email …', btn: 'Invite', done: 'Invited',
|
ph: 'Search name or email …', btn: 'Invite', done: 'Invited',
|
||||||
pick: 'Pick a person …',
|
pick: 'Pick a person …',
|
||||||
},
|
},
|
||||||
|
chat: {
|
||||||
|
title: 'Team chat', ph: 'Write a message …', send: 'Send',
|
||||||
|
empty: 'No messages yet. Say hi! 👋',
|
||||||
|
tabs: 'Chats', teamTabs: 'Team chats', search: 'Search …',
|
||||||
|
type: 'Message …', edit: 'Edit', edited: '(edited)',
|
||||||
|
attach: 'Attach image', typing: 'is typing …', online: 'online',
|
||||||
|
noConv: 'Pick a chat on the left or start a new one.',
|
||||||
|
pickUser: 'Search user for new chat …', unread: 'new',
|
||||||
|
},
|
||||||
|
perm: {
|
||||||
|
title: 'Per-user permissions', allow: 'Allow', deny: 'Deny',
|
||||||
|
inherit: 'Inherited', manage: 'Rights',
|
||||||
|
},
|
||||||
com: { title: 'Comments', ph: 'Write a comment …', post: 'Post' },
|
com: { title: 'Comments', ph: 'Write a comment …', post: 'Post' },
|
||||||
};
|
};
|
||||||
|
|||||||
+19
-6
@@ -11,7 +11,7 @@ export default {
|
|||||||
auth: {
|
auth: {
|
||||||
title: '欢迎回来', sub: '登录到你的工作区。',
|
title: '欢迎回来', sub: '登录到你的工作区。',
|
||||||
email: '电子邮件', password: '密码', btn: '登录',
|
email: '电子邮件', password: '密码', btn: '登录',
|
||||||
locked: '🔒 账号由管理员创建。',
|
locked: '账号由管理员创建。',
|
||||||
},
|
},
|
||||||
bell: {
|
bell: {
|
||||||
title: '通知', invites: '邀请', notifs: '动态',
|
title: '通知', invites: '邀请', notifs: '动态',
|
||||||
@@ -38,17 +38,17 @@ export default {
|
|||||||
evening: '晚上好', night: '晚安',
|
evening: '晚上好', night: '晚安',
|
||||||
},
|
},
|
||||||
set: {
|
set: {
|
||||||
title: '设置', profile: '👤 个人资料', appearance: '🎨 外观',
|
title: '设置', profile: '个人资料', appearance: '外观',
|
||||||
language: '🌍 语言', background: '🖼️ 背景',
|
language: '语言', background: '背景',
|
||||||
name: '显示名称', email: '电子邮件', avatar: '头像(表情或图片链接)',
|
name: '显示名称', email: '电子邮件', avatar: '头像(表情或图片链接)',
|
||||||
avatarHint: '例如 🦊 或 https://…/photo.png',
|
avatarHint: '例如 🦊 或 https://…/photo.png',
|
||||||
curPw: '当前密码', newPw: '新密码(至少 8 位)',
|
curPw: '当前密码', newPw: '新密码(至少 8 位)',
|
||||||
changePw: '修改密码', pwChanged: '密码已修改 ✅',
|
changePw: '修改密码', pwChanged: '密码已修改 ✅',
|
||||||
save: '保存', saved: '已保存 ✅', theme: '配色方案',
|
save: '保存', saved: '已保存 ✅', theme: '配色方案',
|
||||||
light: '☀️ 浅色', dark: '🌙 深色', system: '🖥️ 跟随系统',
|
light: '浅色', dark: '深色', system: '跟随系统',
|
||||||
bgUrl: '背景图片链接', bgUrlHint: '留空 = 无背景,或选择预设:',
|
bgUrl: '背景图片链接', bgUrlHint: '留空 = 无背景,或选择预设:',
|
||||||
bgBlur: '模糊背景', bgGlass: '玻璃拟态组件',
|
bgBlur: '模糊背景', bgGlass: '玻璃拟态组件',
|
||||||
upload: '上传图片 …', close: '关闭',
|
upload: '上传图片 …', autoSaved: '即时生效 ✓', close: '关闭',
|
||||||
presetNone: '无背景', presetCustom: '自定义链接',
|
presetNone: '无背景', presetCustom: '自定义链接',
|
||||||
},
|
},
|
||||||
task: {
|
task: {
|
||||||
@@ -81,9 +81,22 @@ export default {
|
|||||||
back: '返回', loading: '加载中 …',
|
back: '返回', loading: '加载中 …',
|
||||||
},
|
},
|
||||||
inv: {
|
inv: {
|
||||||
title: '✉️ 邀请协作者', hint: '输入搜索,或填写完整邮箱。',
|
title: '邀请协作者', hint: '输入搜索,或填写完整邮箱。',
|
||||||
ph: '搜索姓名或邮箱 …', btn: '邀请', done: '已邀请',
|
ph: '搜索姓名或邮箱 …', btn: '邀请', done: '已邀请',
|
||||||
pick: '选择人员 …',
|
pick: '选择人员 …',
|
||||||
},
|
},
|
||||||
|
chat: {
|
||||||
|
title: '团队聊天', ph: '写消息 …', send: '发送',
|
||||||
|
empty: '还没有消息,打个招呼吧!👋',
|
||||||
|
tabs: '私聊', teamTabs: '团队聊天', search: '搜索 …',
|
||||||
|
type: '消息 …', edit: '编辑', edited: '(已编辑)',
|
||||||
|
attach: '添加图片', typing: '正在输入 …', online: '在线',
|
||||||
|
noConv: '在左侧选择聊天或开始新聊天。',
|
||||||
|
pickUser: '搜索用户开始新聊天 …', unread: '新',
|
||||||
|
},
|
||||||
|
perm: {
|
||||||
|
title: '用户权限', allow: '允许', deny: '拒绝',
|
||||||
|
inherit: '继承', manage: '权限',
|
||||||
|
},
|
||||||
com: { title: '评论', ph: '写评论 …', post: '发送' },
|
com: { title: '评论', ph: '写评论 …', post: '发送' },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
import { usePresenceStore } from './stores/presence';
|
||||||
|
|
||||||
let socket = null;
|
let socket = null;
|
||||||
|
|
||||||
@@ -9,6 +10,11 @@ export function connectSocket() {
|
|||||||
socket?.disconnect();
|
socket?.disconnect();
|
||||||
const url = import.meta.env.VITE_SOCKET_URL || 'http://localhost:3000';
|
const url = import.meta.env.VITE_SOCKET_URL || 'http://localhost:3000';
|
||||||
socket = io(url || undefined, { auth: { token } });
|
socket = io(url || undefined, { auth: { token } });
|
||||||
|
socket.on('presence:online', (ids) => {
|
||||||
|
try {
|
||||||
|
usePresenceStore().online = ids || [];
|
||||||
|
} catch { /* store not ready */ }
|
||||||
|
});
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineStore } from 'pinia';
|
||||||
|
|
||||||
|
export const usePresenceStore = defineStore('presence', {
|
||||||
|
state: () => ({
|
||||||
|
online: [],
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
isOnline(id) {
|
||||||
|
return (this.online || []).includes(id);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { useWorkspaceStore } from '../stores/workspace';
|
|||||||
import { useLocaleStore } from '../stores/locale';
|
import { useLocaleStore } from '../stores/locale';
|
||||||
import Sidebar from '../components/Sidebar.vue';
|
import Sidebar from '../components/Sidebar.vue';
|
||||||
import Avatar from '../components/Avatar.vue';
|
import Avatar from '../components/Avatar.vue';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
|
||||||
const L = useLocaleStore();
|
const L = useLocaleStore();
|
||||||
const ws = useWorkspaceStore();
|
const ws = useWorkspaceStore();
|
||||||
@@ -73,7 +74,7 @@ async function createTeam() {
|
|||||||
newTeam.value = '';
|
newTeam.value = '';
|
||||||
}
|
}
|
||||||
async function removeTeam(t) {
|
async function removeTeam(t) {
|
||||||
if (!confirm('🗑️?')) return;
|
if (!confirm('✕?')) return;
|
||||||
await api.delete(`/teams/${t.id}`);
|
await api.delete(`/teams/${t.id}`);
|
||||||
teams.value = teams.value.filter((x) => x.id !== t.id);
|
teams.value = teams.value.filter((x) => x.id !== t.id);
|
||||||
}
|
}
|
||||||
@@ -122,7 +123,7 @@ async function togglePerm(g, perm) {
|
|||||||
g.permissions = data.group.permissions;
|
g.permissions = data.group.permissions;
|
||||||
}
|
}
|
||||||
async function removeGroup(g) {
|
async function removeGroup(g) {
|
||||||
if (!confirm('🗑️?')) return;
|
if (!confirm('✕?')) return;
|
||||||
await api.delete(`/groups/${g.id}`);
|
await api.delete(`/groups/${g.id}`);
|
||||||
groups.value = groups.value.filter((x) => x.id !== g.id);
|
groups.value = groups.value.filter((x) => x.id !== g.id);
|
||||||
}
|
}
|
||||||
@@ -153,12 +154,12 @@ onMounted(async () => {
|
|||||||
<div class="layout">
|
<div class="layout">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<h2>🛡️ {{ L.t('admin.title') }}</h2>
|
<h2><Icon name="shield" :size="22" /> {{ L.t('admin.title') }}</h2>
|
||||||
<div class="row wrap admin-tabs">
|
<div class="row wrap admin-tabs">
|
||||||
<button :class="{ ghost: tab !== 'overview' }" @click="tab = 'overview'">{{ L.t('admin.overview') }}</button>
|
<button :class="{ ghost: tab !== 'overview' }" @click="tab = 'overview'">{{ L.t('admin.overview') }}</button>
|
||||||
<button :class="{ ghost: tab !== 'users' }" @click="tab = 'users'">{{ L.t('admin.users') }}</button>
|
<button :class="{ ghost: tab !== 'users' }" @click="tab = 'users'">{{ L.t('admin.users') }}</button>
|
||||||
<button :class="{ ghost: tab !== 'teams' }" @click="tab = 'teams'">👥 {{ L.t('teams.title') }}</button>
|
<button :class="{ ghost: tab !== 'teams' }" @click="tab = 'teams'"><Icon name="users" :size="16" /> {{ L.t('teams.title') }}</button>
|
||||||
<button :class="{ ghost: tab !== 'groups' }" @click="tab = 'groups'">🔑 {{ L.t('groups.title') }}</button>
|
<button :class="{ ghost: tab !== 'groups' }" @click="tab = 'groups'"><Icon name="key" :size="16" /> {{ L.t('groups.title') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="tab === 'overview'">
|
<div v-if="tab === 'overview'">
|
||||||
@@ -204,7 +205,7 @@ onMounted(async () => {
|
|||||||
<td class="row">
|
<td class="row">
|
||||||
<button v-if="!u.is_admin" class="ghost mini" @click="setAdmin(u, true)">{{ L.t('admin.makeAdmin') }}</button>
|
<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 v-else class="ghost mini" @click="setAdmin(u, false)">{{ L.t('admin.unmake') }}</button>
|
||||||
<button class="ghost mini" @click="removeUser(u)">🗑️</button>
|
<button class="ghost mini" @click="removeUser(u)">✕</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -224,7 +225,7 @@ onMounted(async () => {
|
|||||||
<div v-for="t in teams" :key="t.id" class="card" style="margin-bottom: 10px">
|
<div v-for="t in teams" :key="t.id" class="card" style="margin-bottom: 10px">
|
||||||
<div class="row" style="justify-content: space-between">
|
<div class="row" style="justify-content: space-between">
|
||||||
<strong>{{ t.icon }} {{ t.name }} <span class="muted">({{ t.members }})</span></strong>
|
<strong>{{ t.icon }} {{ t.name }} <span class="muted">({{ t.members }})</span></strong>
|
||||||
<button class="ghost mini" @click="removeTeam(t)">🗑️</button>
|
<button class="ghost mini" @click="removeTeam(t)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="row wrap" style="margin-top: 8px">
|
<div class="row wrap" style="margin-top: 8px">
|
||||||
<button class="ghost mini" @click="showTeamMembers(t)">👥 {{ L.t('teams.members') }}</button>
|
<button class="ghost mini" @click="showTeamMembers(t)">👥 {{ L.t('teams.members') }}</button>
|
||||||
@@ -263,8 +264,8 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div v-for="g in groups" :key="g.id" class="card" style="margin-bottom: 10px">
|
<div v-for="g in groups" :key="g.id" class="card" style="margin-bottom: 10px">
|
||||||
<div class="row" style="justify-content: space-between">
|
<div class="row" style="justify-content: space-between">
|
||||||
<strong>🔑 {{ g.name }} <span class="muted">({{ g.members }})</span></strong>
|
<strong><Icon name="key" :size="14" /> {{ g.name }} <span class="muted">({{ g.members }})</span></strong>
|
||||||
<button class="ghost mini" @click="removeGroup(g)">🗑️</button>
|
<button class="ghost mini" @click="removeGroup(g)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="muted">{{ L.t('groups.perms') }}:</div>
|
<div class="muted">{{ L.t('groups.perms') }}:</div>
|
||||||
<div class="row wrap">
|
<div class="row wrap">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import api from '../api';
|
|||||||
import { useWorkspaceStore } from '../stores/workspace';
|
import { useWorkspaceStore } from '../stores/workspace';
|
||||||
import { useLocaleStore } from '../stores/locale';
|
import { useLocaleStore } from '../stores/locale';
|
||||||
import Sidebar from '../components/Sidebar.vue';
|
import Sidebar from '../components/Sidebar.vue';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
|
||||||
const ws = useWorkspaceStore();
|
const ws = useWorkspaceStore();
|
||||||
const L = useLocaleStore();
|
const L = useLocaleStore();
|
||||||
@@ -77,7 +78,7 @@ onMounted(async () => {
|
|||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<h2 style="margin: 0">🗓️ {{ L.t('cal.title') }} — {{ monthName }}</h2>
|
<h2 style="margin: 0"><Icon name="calendar" :size="22" /> {{ L.t('cal.title') }} — {{ monthName }}</h2>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="ghost" @click="prev">◀</button>
|
<button class="ghost" @click="prev">◀</button>
|
||||||
<button class="ghost" @click="goToday">{{ L.t('cal.today') }}</button>
|
<button class="ghost" @click="goToday">{{ L.t('cal.today') }}</button>
|
||||||
@@ -95,12 +96,12 @@ onMounted(async () => {
|
|||||||
:title="t.title"
|
:title="t.title"
|
||||||
@click="router.push('/tasks')"
|
@click="router.push('/tasks')"
|
||||||
>
|
>
|
||||||
{{ t.status === 'done' ? '✅' : '📌' }} {{ t.title }}
|
{{ t.status === 'done' ? '✅' : '▸' }} {{ t.title }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted">📌 {{ L.t('task.due') }} → {{ L.t('cal.title') }} ✨</p>
|
<p class="muted">▸ {{ L.t('task.due') }} → {{ L.t('cal.title') }} ✨</p>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+242
-47
@@ -1,19 +1,45 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
import { uploadImage } from '../files';
|
||||||
import { connectSocket, getSocket } from '../socket';
|
import { connectSocket, getSocket } from '../socket';
|
||||||
import { useWorkspaceStore } from '../stores/workspace';
|
import { useWorkspaceStore } from '../stores/workspace';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { useLocaleStore } from '../stores/locale';
|
import { useLocaleStore } from '../stores/locale';
|
||||||
|
import { usePresenceStore } from '../stores/presence';
|
||||||
import Sidebar from '../components/Sidebar.vue';
|
import Sidebar from '../components/Sidebar.vue';
|
||||||
|
import ChatMessage from '../components/ChatMessage.vue';
|
||||||
|
import UserSearch from '../components/UserSearch.vue';
|
||||||
import Avatar from '../components/Avatar.vue';
|
import Avatar from '../components/Avatar.vue';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
|
||||||
const ws = useWorkspaceStore();
|
const ws = useWorkspaceStore();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const L = useLocaleStore();
|
const L = useLocaleStore();
|
||||||
const messages = ref([]);
|
const presence = usePresenceStore();
|
||||||
|
|
||||||
|
const tab = ref('chats'); // 'chats' | 'teams'
|
||||||
|
// DM state
|
||||||
|
const convs = ref([]);
|
||||||
|
const activeConv = ref(null);
|
||||||
|
const dmMessages = ref([]);
|
||||||
|
const dmTyping = ref('');
|
||||||
|
// Team state
|
||||||
|
const activeWs = ref(null);
|
||||||
|
const teamMessages = ref([]);
|
||||||
|
const teamTyping = ref('');
|
||||||
|
// shared
|
||||||
const body = ref('');
|
const body = ref('');
|
||||||
|
const search = ref('');
|
||||||
|
const searchHits = ref([]);
|
||||||
const box = ref(null);
|
const box = ref(null);
|
||||||
|
let typingTimer = null;
|
||||||
|
let typeThrottle = 0;
|
||||||
|
|
||||||
|
const visibleDm = computed(() => {
|
||||||
|
void dmTyping.value;
|
||||||
|
return dmMessages.value;
|
||||||
|
});
|
||||||
|
|
||||||
function scrollDown() {
|
function scrollDown() {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
@@ -21,75 +47,244 @@ function scrollDown() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function load() {
|
/* ---------- DMs ---------- */
|
||||||
if (!ws.currentId) return;
|
async function loadConvs() {
|
||||||
const { data } = await api.get(`/chat/${ws.currentId}`);
|
const { data } = await api.get('/dm');
|
||||||
messages.value = data.messages;
|
convs.value = data.conversations;
|
||||||
const s = connectSocket();
|
}
|
||||||
s?.emit('chat:join', { workspaceId: ws.currentId });
|
async function openConv(id) {
|
||||||
|
activeConv.value = id;
|
||||||
|
const { data } = await api.get(`/dm/${id}/messages`);
|
||||||
|
dmMessages.value = data.messages;
|
||||||
|
connectSocket()?.emit('dm:join', { conversationId: id });
|
||||||
|
loadConvs(); // refresh unread badges
|
||||||
scrollDown();
|
scrollDown();
|
||||||
}
|
}
|
||||||
|
async function startDm(user) {
|
||||||
function onMsg({ message }) {
|
const { data } = await api.post('/dm', { user_id: user.id });
|
||||||
if (message.workspace_id !== ws.currentId) return;
|
await loadConvs();
|
||||||
messages.value.push(message);
|
openConv(data.conversation.id);
|
||||||
scrollDown();
|
|
||||||
}
|
}
|
||||||
function onDel({ id }) {
|
async function sendDm(file = null) {
|
||||||
messages.value = messages.value.filter((m) => m.id !== id);
|
let attachment = '';
|
||||||
}
|
if (file) attachment = await uploadImage(file);
|
||||||
|
|
||||||
async function send() {
|
|
||||||
const text = body.value.trim();
|
const text = body.value.trim();
|
||||||
if (!text) return;
|
if (!text && !attachment) return;
|
||||||
body.value = '';
|
body.value = '';
|
||||||
await api.post(`/chat/${ws.currentId}`, { body: text });
|
await api.post(`/dm/${activeConv.value}/messages`, { body: text || '📎', attachment });
|
||||||
|
}
|
||||||
|
function onDmMsg({ message }) {
|
||||||
|
if (message.conversation_id !== activeConv.value) {
|
||||||
|
loadConvs();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dmMessages.value.push(message);
|
||||||
|
loadConvs();
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
function onDmEdited({ message }) {
|
||||||
|
const i = dmMessages.value.findIndex((m) => m.id === message.id);
|
||||||
|
if (i >= 0) Object.assign(dmMessages.value[i], message);
|
||||||
|
}
|
||||||
|
function onDmTyping({ name }) {
|
||||||
|
dmTyping.value = `${name} ${L.t('chat.typing')}`;
|
||||||
|
clearTimeout(typingTimer);
|
||||||
|
typingTimer = setTimeout(() => (dmTyping.value = ''), 1500);
|
||||||
|
}
|
||||||
|
function emitDmTyping() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - typeThrottle < 2000 || !activeConv.value) return;
|
||||||
|
typeThrottle = now;
|
||||||
|
connectSocket()?.emit('dm:typing', { conversationId: activeConv.value, name: auth.user?.name });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id) {
|
/* ---------- Team chats ---------- */
|
||||||
await api.delete(`/chat/message/${id}`);
|
async function openTeam(wsId) {
|
||||||
|
activeWs.value = wsId;
|
||||||
|
const { data } = await api.get(`/chat/${wsId}`);
|
||||||
|
teamMessages.value = data.messages;
|
||||||
|
connectSocket()?.emit('chat:join', { workspaceId: wsId });
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
async function sendTeam(file = null) {
|
||||||
|
if (!activeWs.value) return;
|
||||||
|
let attachment = '';
|
||||||
|
if (file) attachment = await uploadImage(file);
|
||||||
|
const text = body.value.trim();
|
||||||
|
if (!text && !attachment) return;
|
||||||
|
body.value = '';
|
||||||
|
await api.post(`/chat/${activeWs.value}`, { body: text || '📎', attachment });
|
||||||
|
}
|
||||||
|
function onTeamMsg({ message }) {
|
||||||
|
if (message.workspace_id !== activeWs.value) return;
|
||||||
|
teamMessages.value.push(message);
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
function onTeamEdited({ message }) {
|
||||||
|
const i = teamMessages.value.findIndex((m) => m.id === message.id);
|
||||||
|
if (i >= 0) Object.assign(teamMessages.value[i], message);
|
||||||
|
}
|
||||||
|
function onTeamTyping({ name }) {
|
||||||
|
teamTyping.value = `${name} ${L.t('chat.typing')}`;
|
||||||
|
clearTimeout(typingTimer);
|
||||||
|
typingTimer = setTimeout(() => (teamTyping.value = ''), 1500);
|
||||||
|
}
|
||||||
|
function emitTeamTyping() {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - typeThrottle < 2000 || !activeWs.value) return;
|
||||||
|
typeThrottle = now;
|
||||||
|
connectSocket()?.emit('chat:typing', { workspaceId: activeWs.value, name: auth.user?.name });
|
||||||
|
}
|
||||||
|
function onReaction({ target_type, target_id, reactions }) {
|
||||||
|
const list = target_type === 'dm' ? dmMessages.value : teamMessages.value;
|
||||||
|
const m = list.find((x) => x.id === target_id);
|
||||||
|
if (m) m.reactions = reactions;
|
||||||
|
}
|
||||||
|
function onDeleted({ id }) {
|
||||||
|
dmMessages.value = dmMessages.value.filter((m) => m.id !== id);
|
||||||
|
teamMessages.value = teamMessages.value.filter((m) => m.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doSearch() {
|
||||||
|
const q = search.value.trim();
|
||||||
|
if (q.length < 2) { searchHits.value = []; return; }
|
||||||
|
if (tab.value === 'teams' && activeWs.value) {
|
||||||
|
const { data } = await api.get(`/chat/${activeWs.value}/search`, { params: { q } });
|
||||||
|
searchHits.value = data.messages;
|
||||||
|
} else if (tab.value === 'chats' && activeConv.value) {
|
||||||
|
const { data } = await api.get(`/dm/${activeConv.value}/search`, { params: { q } });
|
||||||
|
searchHits.value = data.messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFile(e) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (tab.value === 'chats') sendDm(file);
|
||||||
|
else sendTeam(file);
|
||||||
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!ws.workspaces.length) await ws.fetchWorkspaces();
|
if (!ws.workspaces.length) await ws.fetchWorkspaces();
|
||||||
await load();
|
connectSocket();
|
||||||
|
await loadConvs();
|
||||||
|
if (ws.currentId) openTeam(ws.currentId);
|
||||||
const s = getSocket();
|
const s = getSocket();
|
||||||
s?.on('chat:message', onMsg);
|
s?.on('dm:message', onDmMsg);
|
||||||
s?.on('chat:deleted', onDel);
|
s?.on('dm:edited', onDmEdited);
|
||||||
|
s?.on('dm:deleted', onDeleted);
|
||||||
|
s?.on('dm:typing', onDmTyping);
|
||||||
|
s?.on('chat:message', onTeamMsg);
|
||||||
|
s?.on('chat:edited', onTeamEdited);
|
||||||
|
s?.on('chat:deleted', onDeleted);
|
||||||
|
s?.on('chat:typing', onTeamTyping);
|
||||||
|
s?.on('reaction:updated', onReaction);
|
||||||
});
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
const s = getSocket();
|
const s = getSocket();
|
||||||
s?.off('chat:message', onMsg);
|
s?.off('dm:message', onDmMsg);
|
||||||
s?.off('chat:deleted', onDel);
|
s?.off('dm:edited', onDmEdited);
|
||||||
|
s?.off('dm:deleted', onDeleted);
|
||||||
|
s?.off('dm:typing', onDmTyping);
|
||||||
|
s?.off('chat:message', onTeamMsg);
|
||||||
|
s?.off('chat:edited', onTeamEdited);
|
||||||
|
s?.off('chat:deleted', onDeleted);
|
||||||
|
s?.off('chat:typing', onTeamTyping);
|
||||||
|
s?.off('reaction:updated', onReaction);
|
||||||
});
|
});
|
||||||
watch(() => ws.currentId, load);
|
watch(activeWs, () => { search.value = ''; searchHits.value = []; });
|
||||||
|
watch(activeConv, () => { search.value = ''; searchHits.value = []; });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main class="main chat-main">
|
<main class="main chat-layout">
|
||||||
<h2>💬 {{ L.t('chat.title') }}</h2>
|
<div class="row wrap">
|
||||||
<div ref="box" class="card chat-box">
|
<button :class="{ ghost: tab !== 'chats' }" @click="tab = 'chats'"><Icon name="mail" :size="16" /> {{ L.t('chat.tabs') }}</button>
|
||||||
<div
|
<button :class="{ ghost: tab !== 'teams' }" @click="tab = 'teams'"><Icon name="users" :size="16" /> {{ L.t('chat.teamTabs') }}</button>
|
||||||
v-for="m in messages" :key="m.id"
|
<input v-model="search" :placeholder="L.t('chat.search')" @input="doSearch" style="max-width: 220px" />
|
||||||
class="chat-msg" :class="{ mine: m.user_id === auth.user?.id }"
|
</div>
|
||||||
>
|
|
||||||
<Avatar :name="m.author_name" :avatar="m.author_avatar" :size="30" />
|
<div class="chat-grid">
|
||||||
<div class="chat-bubble">
|
<!-- left: conversation list -->
|
||||||
<div class="muted chat-meta">
|
<div class="card chat-list">
|
||||||
<strong>{{ m.author_name || '?' }}</strong> ·
|
<template v-if="tab === 'chats'">
|
||||||
{{ new Date(m.created_at).toLocaleString() }}
|
<div class="side-label">{{ L.t('chat.pickUser') }}</div>
|
||||||
<span v-if="m.user_id === auth.user?.id" class="link" @click="remove(m.id)"> 🗑</span>
|
<UserSearch @pick="startDm" />
|
||||||
|
<div
|
||||||
|
v-for="c in convs" :key="c.id"
|
||||||
|
class="conv-row" :class="{ active: c.id === activeConv }"
|
||||||
|
@click="openConv(c.id)"
|
||||||
|
>
|
||||||
|
<div class="avatar-wrap">
|
||||||
|
<Avatar :name="c.peer?.name" :avatar="c.peer?.avatar" :size="34" />
|
||||||
|
<span v-if="presence.isOnline(c.peer?.id)" class="online-dot"></span>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; min-width: 0">
|
||||||
|
<strong>{{ c.peer?.name || '?' }}</strong>
|
||||||
|
<div class="muted ellipsis">{{ c.last_message?.body || '…' }}</div>
|
||||||
|
</div>
|
||||||
|
<span v-if="c.unread" class="badge static">{{ c.unread }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>{{ m.body }}</div>
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div
|
||||||
|
v-for="w in ws.workspaces" :key="w.id"
|
||||||
|
class="conv-row" :class="{ active: w.id === activeWs }"
|
||||||
|
@click="openTeam(w.id)"
|
||||||
|
>
|
||||||
|
<div class="avatar-wrap">
|
||||||
|
<span class="avatar" style="width: 34px; height: 34px">👥</span>
|
||||||
|
</div>
|
||||||
|
<div style="flex: 1; min-width: 0">
|
||||||
|
<strong class="ellipsis">{{ w.name }}</strong>
|
||||||
|
<div class="muted ellipsis">{{ w.team_name ? `TEAM_${w.team_name}` : '' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- right: messages -->
|
||||||
|
<div class="card chat-pane">
|
||||||
|
<template v-if="tab === 'chats' && activeConv">
|
||||||
|
<div ref="box" class="chat-box slim">
|
||||||
|
<ChatMessage
|
||||||
|
v-for="m in visibleDm" :key="m.id"
|
||||||
|
:message="m" target-type="dm" base-path="dm"
|
||||||
|
@deleted="(id) => (dmMessages = dmMessages.filter((x) => x.id !== id))"
|
||||||
|
/>
|
||||||
|
<p v-if="!dmMessages.length" class="muted">{{ L.t('chat.empty') }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="dmTyping" class="muted typing">{{ dmTyping }}</p>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="tab === 'teams' && activeWs">
|
||||||
|
<div ref="box" class="chat-box slim">
|
||||||
|
<ChatMessage
|
||||||
|
v-for="m in teamMessages" :key="m.id"
|
||||||
|
:message="m" target-type="chat" base-path="chat"
|
||||||
|
@deleted="(id) => (teamMessages = teamMessages.filter((x) => x.id !== id))"
|
||||||
|
/>
|
||||||
|
<p v-if="!teamMessages.length" class="muted">{{ L.t('chat.empty') }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="teamTyping" class="muted typing">{{ teamTyping }}</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="muted">{{ L.t('chat.noConv') }}</p>
|
||||||
|
|
||||||
|
<div v-if="searchHits.length" class="card search-hits">
|
||||||
|
<div v-for="h in searchHits" :key="h.id" class="muted">🔍 {{ h.author_name }}: {{ h.body }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="(tab === 'chats' && activeConv) || (tab === 'teams' && activeWs)" class="row" style="margin-top: 8px">
|
||||||
|
<label class="ghost icon-btn attach"><Icon name="attach" /><input type="file" accept="image/*" hidden @change="onFile" /></label>
|
||||||
|
<input
|
||||||
|
v-model="body" :placeholder="L.t('chat.type')"
|
||||||
|
@keyup.enter="tab === 'chats' ? sendDm() : sendTeam()"
|
||||||
|
@input="tab === 'chats' ? emitDmTyping() : emitTeamTyping()"
|
||||||
|
/>
|
||||||
|
<button @click="tab === 'chats' ? sendDm() : sendTeam()"><Icon name="send" :size="16" /></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!messages.length" class="muted">{{ L.t('chat.empty') }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="row" style="margin-top: 8px">
|
|
||||||
<input v-model="body" :placeholder="L.t('chat.ph')" @keyup.enter="send" />
|
|
||||||
<button @click="send">{{ L.t('chat.send') }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
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';
|
||||||
@@ -8,11 +8,15 @@ import Sidebar from '../components/Sidebar.vue';
|
|||||||
import GreetingHeader from '../components/GreetingHeader.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';
|
import Avatar from '../components/Avatar.vue';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const ws = useWorkspaceStore();
|
const ws = useWorkspaceStore();
|
||||||
const L = useLocaleStore();
|
const L = useLocaleStore();
|
||||||
const members = ref([]);
|
const members = ref([]);
|
||||||
|
const permRows = ref([]);
|
||||||
|
const showPerms = ref(false);
|
||||||
|
const PERMS = ['teams.create', 'teams.edit', 'teams.invite', 'workspace.invite'];
|
||||||
|
|
||||||
async function fetchMembers() {
|
async function fetchMembers() {
|
||||||
if (!ws.currentId) return;
|
if (!ws.currentId) return;
|
||||||
@@ -20,6 +24,29 @@ async function fetchMembers() {
|
|||||||
members.value = data.members;
|
members.value = data.members;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchPerms() {
|
||||||
|
if (!['owner', 'admin'].includes(ws.role)) return;
|
||||||
|
try {
|
||||||
|
const { data } = await api.get(`/workspaces/${ws.currentId}/permissions`);
|
||||||
|
permRows.value = data.members;
|
||||||
|
} catch {
|
||||||
|
permRows.value = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setPerm(userId, perm, mode) {
|
||||||
|
await api.patch(`/workspaces/${ws.currentId}/permissions`, {
|
||||||
|
user_id: userId, permission: perm, mode,
|
||||||
|
});
|
||||||
|
await fetchPerms();
|
||||||
|
}
|
||||||
|
|
||||||
|
function overrideOf(row, perm) {
|
||||||
|
const o = row.overrides || {};
|
||||||
|
if (!(perm in o)) return 'inherit';
|
||||||
|
return o[perm] ? 'allow' : 'deny';
|
||||||
|
}
|
||||||
|
|
||||||
const currentName = computed(() => ws.workspaces.find((w) => w.id === ws.currentId)?.name || '');
|
const currentName = computed(() => ws.workspaces.find((w) => w.id === ws.currentId)?.name || '');
|
||||||
const currentTeam = computed(() => ws.workspaces.find((w) => w.id === ws.currentId)?.team_name || '');
|
const currentTeam = computed(() => ws.workspaces.find((w) => w.id === ws.currentId)?.team_name || '');
|
||||||
|
|
||||||
@@ -29,7 +56,15 @@ onMounted(async () => {
|
|||||||
await ws.fetchBooks();
|
await ws.fetchBooks();
|
||||||
await ws.fetchPages();
|
await ws.fetchPages();
|
||||||
await fetchMembers();
|
await fetchMembers();
|
||||||
|
await fetchPerms();
|
||||||
});
|
});
|
||||||
|
watch(
|
||||||
|
() => ws.currentId,
|
||||||
|
async () => {
|
||||||
|
await fetchMembers();
|
||||||
|
await fetchPerms();
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -40,12 +75,44 @@ onMounted(async () => {
|
|||||||
<p class="muted">🏠 {{ currentName }} <span v-if="currentTeam" class="pill team-pill">TEAM_{{ currentTeam }}</span> · {{ L.t('side.role') }}: <strong>{{ ws.role }}</strong></p>
|
<p class="muted">🏠 {{ currentName }} <span v-if="currentTeam" class="pill team-pill">TEAM_{{ currentTeam }}</span> · {{ L.t('side.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>👥 {{ L.t('side.members') }} ({{ members.length }})</h4>
|
<div class="row" style="justify-content: space-between">
|
||||||
|
<h4><Icon name="users" :size="16" /> {{ L.t('side.members') }} ({{ members.length }})</h4>
|
||||||
|
<button
|
||||||
|
v-if="['owner', 'admin'].includes(ws.role)"
|
||||||
|
class="ghost mini" @click="showPerms = !showPerms"
|
||||||
|
>
|
||||||
|
<Icon name="key" :size="14" /> {{ L.t('perm.manage') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div v-for="m in members" :key="m.id" class="row member-row">
|
<div v-for="m in members" :key="m.id" class="row member-row">
|
||||||
<Avatar :name="m.name" :avatar="m.avatar" :size="28" />
|
<Avatar :name="m.name" :avatar="m.avatar" :size="28" />
|
||||||
<span style="flex: 1">{{ m.name }} <span class="muted"><{{ m.email }}></span></span>
|
<span style="flex: 1">{{ m.name }} <span class="muted"><{{ m.email }}></span></span>
|
||||||
<span class="pill">{{ m.role }}</span>
|
<span class="pill">{{ m.role }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="showPerms" class="card table-scroll" style="margin-top: 8px">
|
||||||
|
<h4><Icon name="key" :size="16" /> {{ L.t('perm.title') }}</h4>
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>{{ L.t('admin.name') }}</th><th v-for="p in PERMS" :key="p"><code>{{ p }}</code></th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="r in permRows" :key="r.user_id">
|
||||||
|
<td><strong>{{ r.name }}</strong></td>
|
||||||
|
<td v-for="p in PERMS" :key="p">
|
||||||
|
<select
|
||||||
|
:value="overrideOf(r, p)"
|
||||||
|
@change="setPerm(r.user_id, p, $event.target.value)"
|
||||||
|
style="max-width: 130px"
|
||||||
|
>
|
||||||
|
<option value="inherit">{{ L.t('perm.inherit') }}</option>
|
||||||
|
<option value="allow">✓ {{ L.t('perm.allow') }}</option>
|
||||||
|
<option value="deny">✕ {{ L.t('perm.deny') }}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref } from 'vue';
|
|||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useAuthStore } from '../stores/auth';
|
import { useAuthStore } from '../stores/auth';
|
||||||
import { useLocaleStore } from '../stores/locale';
|
import { useLocaleStore } from '../stores/locale';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
|
||||||
const L = useLocaleStore();
|
const L = useLocaleStore();
|
||||||
|
|
||||||
@@ -33,6 +34,6 @@ async function submit() {
|
|||||||
<button type="submit">{{ L.t('auth.btn') }}</button>
|
<button type="submit">{{ L.t('auth.btn') }}</button>
|
||||||
</form>
|
</form>
|
||||||
<p v-if="err" style="color: red">{{ err }}</p>
|
<p v-if="err" style="color: red">{{ err }}</p>
|
||||||
<p class="muted">{{ L.t('auth.locked') }}</p>
|
<p class="muted"><Icon name="lock" :size="14" /> {{ L.t('auth.locked') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ async function addComment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removePage() {
|
async function removePage() {
|
||||||
if (!confirm('🗑️?')) 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('/');
|
||||||
@@ -144,10 +144,10 @@ onUnmounted(() => {
|
|||||||
<option :value="null">📄 {{ L.t('task.none') }}</option>
|
<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>
|
<option v-for="b in ws.books" :key="b.id" :value="b.id">{{ b.icon }} {{ b.title }}</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="ghost" @click="removePage">🗑️</button>
|
<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">🟢 ({{ presence.length }}):</span>
|
<span class="muted">● ({{ presence.length }}):</span>
|
||||||
<Avatar v-for="m in presence" :key="m.userId" :name="m.name" :size="26" />
|
<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>
|
||||||
@@ -160,11 +160,11 @@ onUnmounted(() => {
|
|||||||
<button class="ghost" @click="editor?.chain().focus().toggleTaskList().run()">☑️</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().insertTable({ rows: 3, cols: 3 }).run()">⊞ Table</button>
|
||||||
<button class="ghost" @click="editor?.chain().focus().toggleCodeBlock().run()"></></button>
|
<button class="ghost" @click="editor?.chain().focus().toggleCodeBlock().run()"></></button>
|
||||||
<button @click="save()">💾 {{ L.t('c.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>💬 {{ L.t('com.title') }} ({{ 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>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useWorkspaceStore } from '../stores/workspace';
|
|||||||
import { useLocaleStore } from '../stores/locale';
|
import { useLocaleStore } from '../stores/locale';
|
||||||
import Sidebar from '../components/Sidebar.vue';
|
import Sidebar from '../components/Sidebar.vue';
|
||||||
import Avatar from '../components/Avatar.vue';
|
import Avatar from '../components/Avatar.vue';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
|
||||||
const ws = useWorkspaceStore();
|
const ws = useWorkspaceStore();
|
||||||
const L = useLocaleStore();
|
const L = useLocaleStore();
|
||||||
@@ -86,7 +87,7 @@ async function assign(t, userId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(t) {
|
async function remove(t) {
|
||||||
if (!confirm('🗑️?')) return;
|
if (!confirm('✕?')) return;
|
||||||
await api.delete(`/tasks/${t.id}`);
|
await api.delete(`/tasks/${t.id}`);
|
||||||
tasks.value = tasks.value.filter((x) => x.id !== t.id);
|
tasks.value = tasks.value.filter((x) => x.id !== t.id);
|
||||||
}
|
}
|
||||||
@@ -106,7 +107,7 @@ onMounted(async () => {
|
|||||||
<div class="layout">
|
<div class="layout">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<h2>✅ {{ L.t('task.title') }}</h2>
|
<h2><Icon name="tasks" :size="22" /> {{ L.t('task.title') }}</h2>
|
||||||
<div class="row wrap team-tabs">
|
<div class="row wrap team-tabs">
|
||||||
<button :class="{ ghost: teamFilter !== '' }" @click="teamFilter = ''">🌐 {{ L.t('task.allTeams') }}</button>
|
<button :class="{ ghost: teamFilter !== '' }" @click="teamFilter = ''">🌐 {{ L.t('task.allTeams') }}</button>
|
||||||
<button
|
<button
|
||||||
@@ -174,7 +175,7 @@ onMounted(async () => {
|
|||||||
<button v-if="t.status === 'todo'" class="ghost mini" @click="setStatus(t, 'doing')">{{ L.t('task.toDoing') }}</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 === '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 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>
|
<button class="ghost mini" @click="remove(t)">✕</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useWorkspaceStore } from '../stores/workspace';
|
|||||||
import { useLocaleStore } from '../stores/locale';
|
import { useLocaleStore } from '../stores/locale';
|
||||||
import Sidebar from '../components/Sidebar.vue';
|
import Sidebar from '../components/Sidebar.vue';
|
||||||
import Avatar from '../components/Avatar.vue';
|
import Avatar from '../components/Avatar.vue';
|
||||||
|
import Icon from '../components/Icon.vue';
|
||||||
|
|
||||||
const time = useTimeStore();
|
const time = useTimeStore();
|
||||||
const ws = useWorkspaceStore();
|
const ws = useWorkspaceStore();
|
||||||
@@ -19,7 +20,7 @@ function fmt(s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function remove(id) {
|
async function remove(id) {
|
||||||
if (!confirm('🗑️?')) return;
|
if (!confirm('✕?')) return;
|
||||||
await time.remove(id);
|
await time.remove(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ onMounted(async () => {
|
|||||||
<div class="layout">
|
<div class="layout">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<h2>⏱️ {{ L.t('time.title') }}</h2>
|
<h2><Icon name="timer" :size="22" /> {{ L.t('time.title') }}</h2>
|
||||||
<div v-if="time.open" class="card running-banner">
|
<div v-if="time.open" class="card running-banner">
|
||||||
🔴 {{ L.t('time.running') }} <strong>{{ time.open.description || '…' }}</strong>
|
🔴 {{ L.t('time.running') }} <strong>{{ time.open.description || '…' }}</strong>
|
||||||
<span v-if="time.open.book_id" class="muted">📚</span>
|
<span v-if="time.open.book_id" class="muted">📚</span>
|
||||||
@@ -59,7 +60,7 @@ onMounted(async () => {
|
|||||||
<td><strong>{{ e.duration_seconds != null ? formatDuration(e.duration_seconds) : '…' }}</strong></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>{{ 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><Avatar :name="e.user_name" :size="22" /></td>
|
||||||
<td><button class="ghost mini" @click="remove(e.id)">🗑️</button></td>
|
<td><button class="ghost mini" @click="remove(e.id)">✕</button></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user