From 20432f4b472874ec882feba9b7aa474853d07a44 Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Sun, 28 Jun 2026 13:52:54 +0200 Subject: [PATCH] feat: add notifications and project shares features - Backend: notifications + shares routes, DB tables, SqliteAdapter methods - Frontend: NotificationPanel (bell icon + dropdown), ShareDialog, Dashboard integration - Styles: notification + share dialog CSS --- backend/src/database/DatabaseInterface.ts | 31 +++++ backend/src/database/SqliteAdapter.ts | 40 +++++++ backend/src/database/schema.sql | 28 +++++ backend/src/routes/notifications.ts | 63 ++++++++++ backend/src/routes/shares.ts | 67 +++++++++++ backend/src/server.ts | 4 + frontend/src/components/NotificationPanel.tsx | 103 ++++++++++++++++ frontend/src/components/ShareDialog.tsx | 110 ++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 35 ++++-- frontend/src/services/api.ts | 75 ++++++++++++ frontend/src/styles.css | 106 +++++++++++++++++ 11 files changed, 655 insertions(+), 7 deletions(-) create mode 100644 backend/src/routes/notifications.ts create mode 100644 backend/src/routes/shares.ts create mode 100644 frontend/src/components/NotificationPanel.tsx create mode 100644 frontend/src/components/ShareDialog.tsx diff --git a/backend/src/database/DatabaseInterface.ts b/backend/src/database/DatabaseInterface.ts index 8cd7c71..1b312f9 100644 --- a/backend/src/database/DatabaseInterface.ts +++ b/backend/src/database/DatabaseInterface.ts @@ -79,6 +79,26 @@ export interface DBSession { created_at: string; } +export interface DBNotification { + id: string; + user_id: string; + type: string; + title: string; + message: string; + read: number; + created_at: string; +} + +export interface DBProjectShare { + id: string; + project_id: string; + shared_with_email: string; + shared_by: string; + permission: string; + share_token: string | null; + created_at: string; +} + export interface DatabaseInterface { init(): Promise; close(): void; @@ -132,4 +152,15 @@ export interface DatabaseInterface { getSession(token: string): DBSession | null; deleteSession(token: string): boolean; deleteExpiredSessions(): void; + + // Notifications + listNotifications(userId: string): DBNotification[]; + createNotification(data: Partial): DBNotification; + markNotificationRead(id: string): boolean; + deleteNotification(id: string): boolean; + + // Project Shares + listProjectShares(projectId: string): DBProjectShare[]; + createProjectShare(data: Partial): DBProjectShare; + deleteProjectShare(id: string): boolean; } diff --git a/backend/src/database/SqliteAdapter.ts b/backend/src/database/SqliteAdapter.ts index 79777f3..7611c21 100644 --- a/backend/src/database/SqliteAdapter.ts +++ b/backend/src/database/SqliteAdapter.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'crypto'; import type { DatabaseInterface, DBProject, DBDrawing, DBLayer, DBElement, DBBlock, DBSetting, DBUser, DBSession, + DBNotification, DBProjectShare, } from './DatabaseInterface.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -265,4 +266,43 @@ export class SqliteAdapter implements DatabaseInterface { deleteExpiredSessions(): void { this.db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now()); } + + // ─── Notifications ────────────────────────────────── + listNotifications(userId: string): DBNotification[] { + return this.db.prepare('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC').all(userId) as DBNotification[]; + } + + createNotification(data: Partial): DBNotification { + const id = data.id ?? `notif-${Date.now()}`; + this.db.prepare( + 'INSERT INTO notifications (id, user_id, type, title, message, read) VALUES (?, ?, ?, ?, ?, ?)', + ).run(id, data.user_id!, data.type ?? 'info', data.title!, data.message!, data.read ?? 0); + return this.db.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as DBNotification; + } + + markNotificationRead(id: string): boolean { + return this.db.prepare('UPDATE notifications SET read = 1 WHERE id = ?').run(id).changes > 0; + } + + deleteNotification(id: string): boolean { + return this.db.prepare('DELETE FROM notifications WHERE id = ?').run(id).changes > 0; + } + + // ─── Project Shares ───────────────────────────────── + listProjectShares(projectId: string): DBProjectShare[] { + return this.db.prepare('SELECT * FROM project_shares WHERE project_id = ? ORDER BY created_at DESC').all(projectId) as DBProjectShare[]; + } + + createProjectShare(data: Partial): DBProjectShare { + const id = data.id ?? `share-${Date.now()}`; + const shareToken = data.share_token ?? randomUUID(); + this.db.prepare( + 'INSERT INTO project_shares (id, project_id, shared_with_email, shared_by, permission, share_token) VALUES (?, ?, ?, ?, ?, ?)', + ).run(id, data.project_id!, data.shared_with_email!, data.shared_by!, data.permission ?? 'view', shareToken); + return this.db.prepare('SELECT * FROM project_shares WHERE id = ?').get(id) as DBProjectShare; + } + + deleteProjectShare(id: string): boolean { + return this.db.prepare('DELETE FROM project_shares WHERE id = ?').run(id).changes > 0; + } } diff --git a/backend/src/database/schema.sql b/backend/src/database/schema.sql index 0fb66e7..2ea8047 100644 --- a/backend/src/database/schema.sql +++ b/backend/src/database/schema.sql @@ -105,3 +105,31 @@ CREATE INDEX IF NOT EXISTS idx_layers_drawing ON layers(drawing_id); CREATE INDEX IF NOT EXISTS idx_elements_drawing ON elements(drawing_id); CREATE INDEX IF NOT EXISTS idx_elements_layer ON elements(layer_id); CREATE INDEX IF NOT EXISTS idx_blocks_drawing ON blocks(drawing_id); + + -- ─── Notifications ─────────────────────────────────── + CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'info', + title TEXT NOT NULL, + message TEXT NOT NULL, + read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + -- ─── Project Shares ────────────────────────────────── + CREATE TABLE IF NOT EXISTS project_shares ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + shared_with_email TEXT NOT NULL, + shared_by TEXT NOT NULL, + permission TEXT NOT NULL DEFAULT 'view' CHECK(permission IN ('view','edit','admin')), + share_token TEXT UNIQUE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (shared_by) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id); + CREATE INDEX IF NOT EXISTS idx_shares_project ON project_shares(project_id); diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts new file mode 100644 index 0000000..2dce402 --- /dev/null +++ b/backend/src/routes/notifications.ts @@ -0,0 +1,63 @@ +/** + * Notifications Routes – List, create, mark read, delete notifications + */ +import type { FastifyInstance } from 'fastify'; +import type { DatabaseInterface } from '../database/DatabaseInterface.js'; +import type { AuthService } from '../auth/AuthService.js'; + +function extractToken(request: any): string | null { + const auth = request.headers?.authorization; + if (auth && auth.startsWith('Bearer ')) return auth.slice(7); + return null; +} + +export function registerNotificationRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { + // List notifications for current user + fastify.get('/api/notifications', async (request, reply) => { + const token = extractToken(request); + if (!token) return reply.code(401).send({ error: 'Authentication required' }); + const user = authService.getUserFromSession(token); + if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); + return db.listNotifications(user.id); + }); + + // Create notification + fastify.post('/api/notifications', async (request, reply) => { + const token = extractToken(request); + if (!token) return reply.code(401).send({ error: 'Authentication required' }); + const user = authService.getUserFromSession(token); + if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); + const body = request.body as { type?: string; title?: string; message?: string; user_id?: string }; + if (!body.title || !body.message) return reply.code(400).send({ error: 'title and message are required' }); + return reply.code(201).send(db.createNotification({ + user_id: body.user_id ?? user.id, + type: body.type ?? 'info', + title: body.title, + message: body.message, + })); + }); + + // Mark notification as read + fastify.patch('/api/notifications/:id/read', async (request, reply) => { + const token = extractToken(request); + if (!token) return reply.code(401).send({ error: 'Authentication required' }); + const user = authService.getUserFromSession(token); + if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); + const { id } = request.params as { id: string }; + const ok = db.markNotificationRead(id); + if (!ok) return reply.code(404).send({ error: 'Notification not found' }); + return reply.code(204).send(); + }); + + // Delete notification + fastify.delete('/api/notifications/:id', async (request, reply) => { + const token = extractToken(request); + if (!token) return reply.code(401).send({ error: 'Authentication required' }); + const user = authService.getUserFromSession(token); + if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); + const { id } = request.params as { id: string }; + const ok = db.deleteNotification(id); + if (!ok) return reply.code(404).send({ error: 'Notification not found' }); + return reply.code(204).send(); + }); +} diff --git a/backend/src/routes/shares.ts b/backend/src/routes/shares.ts new file mode 100644 index 0000000..3b99fd5 --- /dev/null +++ b/backend/src/routes/shares.ts @@ -0,0 +1,67 @@ +/** + * Project Shares Routes – List, create, delete project shares + */ +import type { FastifyInstance } from 'fastify'; +import type { DatabaseInterface } from '../database/DatabaseInterface.js'; +import type { AuthService } from '../auth/AuthService.js'; + +function extractToken(request: any): string | null { + const auth = request.headers?.authorization; + if (auth && auth.startsWith('Bearer ')) return auth.slice(7); + return null; +} + +export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { + // List shares for a project + fastify.get('/api/projects/:projectId/shares', async (request, reply) => { + const token = extractToken(request); + if (!token) return reply.code(401).send({ error: 'Authentication required' }); + const user = authService.getUserFromSession(token); + if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); + const { projectId } = request.params as { projectId: string }; + return db.listProjectShares(projectId); + }); + + // Create a project share + fastify.post('/api/projects/:projectId/shares', async (request, reply) => { + const token = extractToken(request); + if (!token) return reply.code(401).send({ error: 'Authentication required' }); + const user = authService.getUserFromSession(token); + if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); + const { projectId } = request.params as { projectId: string }; + const body = request.body as { shared_with_email?: string; permission?: string }; + if (!body.shared_with_email) return reply.code(400).send({ error: 'shared_with_email is required' }); + + const share = db.createProjectShare({ + project_id: projectId, + shared_with_email: body.shared_with_email, + shared_by: user.id, + permission: body.permission ?? 'view', + }); + + // Create a notification for the shared user if they exist + const sharedUser = db.getUserByEmail(body.shared_with_email); + if (sharedUser) { + db.createNotification({ + user_id: sharedUser.id, + type: 'share', + title: 'Projekt geteilt', + message: `${user.name} hat ein Projekt mit Ihnen geteilt: ${db.getProject(projectId)?.name ?? 'Unbenannt'}`, + }); + } + + return reply.code(201).send(share); + }); + + // Delete a project share + fastify.delete('/api/shares/:id', async (request, reply) => { + const token = extractToken(request); + if (!token) return reply.code(401).send({ error: 'Authentication required' }); + const user = authService.getUserFromSession(token); + if (!user) return reply.code(401).send({ error: 'Invalid or expired session' }); + const { id } = request.params as { id: string }; + const ok = db.deleteProjectShare(id); + if (!ok) return reply.code(404).send({ error: 'Share not found' }); + return reply.code(204).send(); + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 750dc64..0f7a0db 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -57,6 +57,8 @@ export async function createServer(opts: ServerOptions) { const { registerAuthRoutes } = await import('./routes/auth.js'); const { registerUserRoutes } = await import('./routes/users.js'); const { registerAIRoutes } = await import('./routes/ai.js'); + const { registerNotificationRoutes } = await import('./routes/notifications.js'); + const { registerShareRoutes } = await import('./routes/shares.js'); const authService = new AuthService(opts.db); @@ -69,6 +71,8 @@ export async function createServer(opts: ServerOptions) { registerSettingsRoutes(fastify, opts.db); registerUserRoutes(fastify, opts.db, authService); registerAIRoutes(fastify, authService); + registerNotificationRoutes(fastify, opts.db, authService); + registerShareRoutes(fastify, opts.db, authService); // Yjs collaboration WebSocket registerYjsWebSocket(fastify); diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx new file mode 100644 index 0000000..9a1c1a4 --- /dev/null +++ b/frontend/src/components/NotificationPanel.tsx @@ -0,0 +1,103 @@ +/** + * NotificationPanel – Shows user notifications with mark-read and delete + */ +import { useState, useEffect, useCallback } from 'react'; +import { getNotifications, markNotificationRead, deleteNotification, type NotificationItem } from '../services/api'; + +interface NotificationPanelProps { + token: string; +} + +export function NotificationPanel({ token }: NotificationPanelProps) { + const [notifications, setNotifications] = useState([]); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + + const fetchNotifications = useCallback(async () => { + setLoading(true); + try { + const data = await getNotifications(token); + setNotifications(data); + } catch { + // ignore + } finally { + setLoading(false); + } + }, [token]); + + useEffect(() => { + if (open) fetchNotifications(); + }, [open, fetchNotifications]); + + const unreadCount = notifications.filter(n => !n.read).length; + + const handleMarkRead = async (id: string) => { + try { + await markNotificationRead(token, id); + setNotifications(prev => prev.map(n => n.id === id ? { ...n, read: 1 } : n)); + } catch { + // ignore + } + }; + + const handleDelete = async (id: string) => { + try { + await deleteNotification(token, id); + setNotifications(prev => prev.filter(n => n.id !== id)); + } catch { + // ignore + } + }; + + return ( +
+ + {open && ( +
+
+

Benachrichtigungen

+ +
+ {loading ? ( +

Lädt…

+ ) : notifications.length === 0 ? ( +

Keine Benachrichtigungen

+ ) : ( +
+ {notifications.map(n => ( +
+
+ {n.type} + {n.title} + {n.message} + {new Date(n.created_at).toLocaleString('de-DE')} +
+
+ {!n.read && ( + + )} + +
+
+ ))} +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx new file mode 100644 index 0000000..90c0380 --- /dev/null +++ b/frontend/src/components/ShareDialog.tsx @@ -0,0 +1,110 @@ +/** + * ShareDialog – Share a project with other users by email + */ +import { useState, useEffect, useCallback } from 'react'; +import { getProjectShares, createProjectShare, deleteProjectShare, type ProjectShare } from '../services/api'; + +interface ShareDialogProps { + token: string; + projectId: string; + open: boolean; + onClose: () => void; +} + +export function ShareDialog({ token, projectId, open, onClose }: ShareDialogProps) { + const [shares, setShares] = useState([]); + const [email, setEmail] = useState(''); + const [permission, setPermission] = useState('view'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchShares = useCallback(async () => { + setLoading(true); + try { + const data = await getProjectShares(token, projectId); + setShares(data); + } catch { + // ignore + } finally { + setLoading(false); + } + }, [token, projectId]); + + useEffect(() => { + if (open) fetchShares(); + }, [open, fetchShares]); + + const handleShare = async () => { + if (!email.trim()) return; + setError(null); + try { + const newShare = await createProjectShare(token, projectId, { + shared_with_email: email.trim(), + permission, + }); + setShares(prev => [newShare, ...prev]); + setEmail(''); + } catch (err: any) { + setError(err.message || 'Freigabe fehlgeschlagen'); + } + }; + + const handleDelete = async (id: string) => { + try { + await deleteProjectShare(token, id); + setShares(prev => prev.filter(s => s.id !== id)); + } catch { + // ignore + } + }; + + if (!open) return null; + + return ( +
+
e.stopPropagation()}> +
+

Projekt teilen

+ +
+
+
+ setEmail(e.target.value)} + className="share-email-input" + /> + + +
+ {error &&

{error}

} + {loading ? ( +

Lädt…

+ ) : shares.length === 0 ? ( +

Noch niemandem geteilt

+ ) : ( +
+ {shares.map(s => ( +
+
+ {s.shared_with_email} + {s.permission} +
+ +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index abb0379..6151a39 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -3,6 +3,8 @@ */ import { useState, useEffect, useCallback } from 'react'; import { useAuth } from '../contexts/AuthContext'; +import { NotificationPanel } from '../components/NotificationPanel'; +import { ShareDialog } from '../components/ShareDialog'; const API_BASE = import.meta.env.VITE_API_BASE || ''; @@ -25,6 +27,7 @@ export function Dashboard({ onOpenProject }: DashboardProps) { const [showCreate, setShowCreate] = useState(false); const [newName, setNewName] = useState(''); const [newDesc, setNewDesc] = useState(''); + const [shareProjectId, setShareProjectId] = useState(null); const fetchProjects = useCallback(async () => { setLoading(true); @@ -86,7 +89,10 @@ export function Dashboard({ onOpenProject }: DashboardProps) {

Web CAD

{user?.name} ({user?.role}) - +
+ + +
@@ -133,18 +139,33 @@ export function Dashboard({ onOpenProject }: DashboardProps) { {project.description &&

{project.description}

}
Erstellt: {new Date(project.created_at).toLocaleDateString('de-DE')} - +
+ + +
))} )} + + setShareProjectId(null)} + /> ); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f3febd3..33732fe 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -387,6 +387,81 @@ export async function loadProjectDataTyped(token: string, projectId: string): Pr return promise; } +// ─── Notifications ─────────────────────────────────────── +export interface NotificationItem { + id: string; + user_id: string; + type: string; + title: string; + message: string; + read: number; + created_at: string; +} + +export async function getNotifications(token: string): Promise { + const res = await fetch(`${API_BASE}/api/notifications`, { headers: authHeaders(token) }); + if (!res.ok) throw new Error('Failed to load notifications'); + return res.json(); +} + +export async function createNotification(token: string, data: { type?: string; title: string; message: string; user_id?: string }): Promise { + const res = await fetch(`${API_BASE}/api/notifications`, { + method: 'POST', + headers: authHeaders(token), + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Failed to create notification'); + return res.json(); +} + +export async function markNotificationRead(token: string, id: string): Promise { + await fetch(`${API_BASE}/api/notifications/${id}/read`, { + method: 'PATCH', + headers: authHeaders(token), + }); +} + +export async function deleteNotification(token: string, id: string): Promise { + await fetch(`${API_BASE}/api/notifications/${id}`, { + method: 'DELETE', + headers: authHeaders(token), + }); +} + +// ─── Project Shares ────────────────────────────────────── +export interface ProjectShare { + id: string; + project_id: string; + shared_with_email: string; + shared_by: string; + permission: string; + share_token: string | null; + created_at: string; +} + +export async function getProjectShares(token: string, projectId: string): Promise { + const res = await fetch(`${API_BASE}/api/projects/${projectId}/shares`, { headers: authHeaders(token) }); + if (!res.ok) throw new Error('Failed to load shares'); + return res.json(); +} + +export async function createProjectShare(token: string, projectId: string, data: { shared_with_email: string; permission?: string }): Promise { + const res = await fetch(`${API_BASE}/api/projects/${projectId}/shares`, { + method: 'POST', + headers: authHeaders(token), + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error('Failed to create share'); + return res.json(); +} + +export async function deleteProjectShare(token: string, shareId: string): Promise { + await fetch(`${API_BASE}/api/shares/${shareId}`, { + method: 'DELETE', + headers: authHeaders(token), + }); +} + export { API_BASE }; // ─── AI Copilot ───────────────────────────────────────── diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 72b6fce..bf75f21 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2010,3 +2010,109 @@ body.drawer-open { overflow: hidden; } padding: 3px 8px; border-radius: 4px; } +/* ─── Notification Panel ─────────────────────────────── */ +.dashboard-header-actions { display: flex; align-items: center; gap: 12px; } +.notification-panel-wrapper { position: relative; } +.notification-bell-btn { + position: relative; background: none; border: none; cursor: pointer; + color: var(--color-text-muted); padding: 6px; border-radius: 6px; transition: background 0.2s; +} +.notification-bell-btn:hover { background: var(--color-border); } +.notification-badge { + position: absolute; top: 0; right: 0; background: var(--color-primary); color: white; + font-size: 10px; font-weight: 700; min-width: 16px; height: 16px; border-radius: 8px; + display: flex; align-items: center; justify-content: center; padding: 0 4px; +} +.notification-dropdown { + position: absolute; top: 100%; right: 0; margin-top: 8px; width: 360px; + background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 8px; + box-shadow: 0 8px 24px rgba(0,0,0,0.3); z-index: 1000; max-height: 480px; overflow-y: auto; +} +.notification-dropdown-header { + display: flex; justify-content: space-between; align-items: center; + padding: 12px 16px; border-bottom: 1px solid var(--color-border); +} +.notification-dropdown-header h3 { margin: 0; font-size: 14px; font-weight: 600; } +.notification-close { background: none; border: none; cursor: pointer; font-size: 18px; color: var(--color-text-muted); } +.notification-loading, .notification-empty { padding: 16px; text-align: center; color: var(--color-text-muted); font-size: 13px; } +.notification-list { padding: 4px 0; } +.notification-item { + display: flex; justify-content: space-between; align-items: flex-start; + padding: 10px 16px; border-bottom: 1px solid var(--color-border); gap: 8px; +} +.notification-item.unread { background: rgba(52, 152, 219, 0.08); } +.notification-item-content { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; } +.notification-type-badge { + font-size: 10px; text-transform: uppercase; font-weight: 600; color: var(--color-text-muted); + padding: 1px 6px; border-radius: 3px; background: var(--color-border); align-self: flex-start; +} +.notification-type-badge.type-share { color: #2ecc71; background: rgba(46, 204, 113, 0.15); } +.notification-type-badge.type-info { color: #3498db; background: rgba(52, 152, 219, 0.15); } +.notification-title { font-size: 13px; font-weight: 600; color: var(--color-text); } +.notification-message { font-size: 12px; color: var(--color-text-muted); line-height: 1.4; } +.notification-time { font-size: 11px; color: var(--color-text-muted); margin-top: 2px; } +.notification-item-actions { display: flex; gap: 4px; flex-shrink: 0; } +.notification-action-btn { + background: none; border: none; cursor: pointer; padding: 4px; + color: var(--color-text-muted); border-radius: 4px; transition: background 0.2s; +} +.notification-action-btn:hover { background: var(--color-border); } +.notification-action-btn.delete:hover { color: #e74c3c; } + +/* ─── Share Dialog ────────────────────────────────────── */ +.share-dialog-overlay { + position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 2000; +} +.share-dialog { + background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 12px; + width: 440px; max-width: 90vw; max-height: 80vh; overflow-y: auto; box-shadow: 0 16px 48px rgba(0,0,0,0.4); +} +.share-dialog-header { + display: flex; justify-content: space-between; align-items: center; + padding: 16px 20px; border-bottom: 1px solid var(--color-border); +} +.share-dialog-header h3 { margin: 0; font-size: 16px; font-weight: 600; } +.share-dialog-close { background: none; border: none; cursor: pointer; font-size: 20px; color: var(--color-text-muted); } +.share-dialog-body { padding: 20px; } +.share-form { display: flex; gap: 8px; margin-bottom: 16px; } +.share-email-input { + flex: 1; padding: 8px 12px; border: 1px solid var(--color-border); border-radius: 6px; + background: var(--color-bg); color: var(--color-text); font-size: 13px; +} +.share-permission-select { + padding: 8px 12px; border: 1px solid var(--color-border); border-radius: 6px; + background: var(--color-bg); color: var(--color-text); font-size: 13px; +} +.share-add-btn { + padding: 8px 16px; border: none; border-radius: 6px; cursor: pointer; + background: var(--color-primary); color: white; font-size: 13px; font-weight: 600; +} +.share-add-btn:hover { opacity: 0.9; } +.share-error { color: #e74c3c; font-size: 12px; margin: 8px 0; } +.share-loading, .share-empty { padding: 16px; text-align: center; color: var(--color-text-muted); font-size: 13px; } +.share-list { display: flex; flex-direction: column; gap: 8px; } +.share-item { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 12px; border: 1px solid var(--color-border); border-radius: 6px; +} +.share-item-info { display: flex; align-items: center; gap: 8px; } +.share-email { font-size: 13px; color: var(--color-text); } +.share-permission-badge { + font-size: 10px; text-transform: uppercase; font-weight: 600; padding: 2px 8px; + border-radius: 4px; background: var(--color-border); color: var(--color-text-muted); +} +.share-remove-btn { + background: none; border: none; cursor: pointer; padding: 4px; + color: var(--color-text-muted); border-radius: 4px; transition: color 0.2s; +} +.share-remove-btn:hover { color: #e74c3c; } + +/* ─── Dashboard Share Button ──────────────────────────── */ +.dashboard-project-actions { display: flex; gap: 6px; } +.dashboard-share-btn { + padding: 4px 10px; border: 1px solid var(--color-border); border-radius: 4px; + background: transparent; color: var(--color-text-muted); cursor: pointer; font-size: 12px; +} +.dashboard-share-btn:hover { border-color: var(--color-primary); color: var(--color-primary); } +