feat(notifications): implement notification system with CRUD operations and UI integration
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m27s
This commit is contained in:
@@ -617,6 +617,38 @@ export async function initDatabase() {
|
|||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Notifications table ──────────────────────────────────────────────────
|
||||||
|
if (isPostgres) {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
link TEXT,
|
||||||
|
read INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
link TEXT,
|
||||||
|
read INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Default admin (only on very first start) ────────────────────────────
|
// ── Default admin (only on very first start) ────────────────────────────
|
||||||
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
||||||
if (!adminAlreadySeeded) {
|
if (!adminAlreadySeeded) {
|
||||||
|
|||||||
23
server/config/notifications.js
Normal file
23
server/config/notifications.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { getDb } from './database.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an in-app notification for a user.
|
||||||
|
* Non-fatal — exceptions are swallowed so that the main operation is never blocked.
|
||||||
|
*
|
||||||
|
* @param {number} userId - Recipient user ID
|
||||||
|
* @param {string} type - Notification type (room_share_added | room_share_removed | federation_invite_received)
|
||||||
|
* @param {string} title - Short title (e.g. room name or "from" address)
|
||||||
|
* @param {string|null} body - Optional longer message
|
||||||
|
* @param {string|null} link - Optional frontend path to navigate to when clicked
|
||||||
|
*/
|
||||||
|
export async function createNotification(userId, type, title, body = null, link = null) {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
await db.run(
|
||||||
|
'INSERT INTO notifications (user_id, type, title, body, link) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[userId, type, title, body, link],
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Notifications are non-critical — never break main functionality
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import adminRoutes from './routes/admin.js';
|
|||||||
import brandingRoutes from './routes/branding.js';
|
import brandingRoutes from './routes/branding.js';
|
||||||
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
|
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
|
||||||
import calendarRoutes from './routes/calendar.js';
|
import calendarRoutes from './routes/calendar.js';
|
||||||
|
import notificationRoutes from './routes/notifications.js';
|
||||||
import { startFederationSync } from './jobs/federationSync.js';
|
import { startFederationSync } from './jobs/federationSync.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
@@ -55,6 +56,7 @@ async function start() {
|
|||||||
app.use('/api/branding', brandingRoutes);
|
app.use('/api/branding', brandingRoutes);
|
||||||
app.use('/api/federation', federationRoutes);
|
app.use('/api/federation', federationRoutes);
|
||||||
app.use('/api/calendar', calendarRoutes);
|
app.use('/api/calendar', calendarRoutes);
|
||||||
|
app.use('/api/notifications', notificationRoutes);
|
||||||
// Mount calendar federation receive also under /api/federation for remote instances
|
// Mount calendar federation receive also under /api/federation for remote instances
|
||||||
app.use('/api/federation', calendarRoutes);
|
app.use('/api/federation', calendarRoutes);
|
||||||
app.get('/.well-known/redlight', wellKnownHandler);
|
app.get('/.well-known/redlight', wellKnownHandler);
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ router.get('/', async (req, res) => {
|
|||||||
const logoFile = findLogoFile();
|
const logoFile = findLogoFile();
|
||||||
|
|
||||||
const registrationMode = await getSetting('registration_mode');
|
const registrationMode = await getSetting('registration_mode');
|
||||||
|
const imprintUrl = await getSetting('imprint_url');
|
||||||
|
const privacyUrl = await getSetting('privacy_url');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
appName: appName || 'Redlight',
|
appName: appName || 'Redlight',
|
||||||
@@ -90,6 +92,8 @@ router.get('/', async (req, res) => {
|
|||||||
logoUrl: logoFile ? '/api/branding/logo' : null,
|
logoUrl: logoFile ? '/api/branding/logo' : null,
|
||||||
defaultTheme: defaultTheme || null,
|
defaultTheme: defaultTheme || null,
|
||||||
registrationMode: registrationMode || 'open',
|
registrationMode: registrationMode || 'open',
|
||||||
|
imprintUrl: imprintUrl || null,
|
||||||
|
privacyUrl: privacyUrl || null,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.branding.error('Get branding error:', err);
|
log.branding.error('Get branding error:', err);
|
||||||
@@ -210,4 +214,42 @@ router.put('/registration-mode', authenticateToken, requireAdmin, async (req, re
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// PUT /api/branding/imprint-url - Set imprint URL (admin only)
|
||||||
|
router.put('/imprint-url', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { imprintUrl } = req.body;
|
||||||
|
if (imprintUrl && imprintUrl.length > 500) {
|
||||||
|
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
|
||||||
|
}
|
||||||
|
if (imprintUrl && imprintUrl.trim()) {
|
||||||
|
await setSetting('imprint_url', imprintUrl.trim());
|
||||||
|
} else {
|
||||||
|
await deleteSetting('imprint_url');
|
||||||
|
}
|
||||||
|
res.json({ imprintUrl: imprintUrl?.trim() || null });
|
||||||
|
} catch (err) {
|
||||||
|
log.branding.error('Update imprint URL error:', err);
|
||||||
|
res.status(500).json({ error: 'Could not update imprint URL' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/branding/privacy-url - Set privacy policy URL (admin only)
|
||||||
|
router.put('/privacy-url', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { privacyUrl } = req.body;
|
||||||
|
if (privacyUrl && privacyUrl.length > 500) {
|
||||||
|
return res.status(400).json({ error: 'URL must not exceed 500 characters' });
|
||||||
|
}
|
||||||
|
if (privacyUrl && privacyUrl.trim()) {
|
||||||
|
await setSetting('privacy_url', privacyUrl.trim());
|
||||||
|
} else {
|
||||||
|
await deleteSetting('privacy_url');
|
||||||
|
}
|
||||||
|
res.json({ privacyUrl: privacyUrl?.trim() || null });
|
||||||
|
} catch (err) {
|
||||||
|
log.branding.error('Update privacy URL error:', err);
|
||||||
|
res.status(500).json({ error: 'Could not update privacy URL' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getDb } from '../config/database.js';
|
|||||||
import { authenticateToken } from '../middleware/auth.js';
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
|
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
|
import { createNotification } from '../config/notifications.js';
|
||||||
|
|
||||||
// M13: rate limit the unauthenticated federation receive endpoint
|
// M13: rate limit the unauthenticated federation receive endpoint
|
||||||
const federationReceiveLimiter = rateLimit({
|
const federationReceiveLimiter = rateLimit({
|
||||||
@@ -233,6 +234,15 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-app notification
|
||||||
|
await createNotification(
|
||||||
|
targetUser.id,
|
||||||
|
'federation_invite_received',
|
||||||
|
from_user,
|
||||||
|
room_name,
|
||||||
|
'/federation/inbox',
|
||||||
|
);
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.federation.error('Federation receive error:', err);
|
log.federation.error('Federation receive error:', err);
|
||||||
|
|||||||
48
server/routes/notifications.js
Normal file
48
server/routes/notifications.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getDb } from '../config/database.js';
|
||||||
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// GET /api/notifications — List recent notifications for the current user
|
||||||
|
router.get('/', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const notifications = await db.all(
|
||||||
|
`SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 50`,
|
||||||
|
[req.user.id],
|
||||||
|
);
|
||||||
|
const unreadCount = notifications.filter(n => !n.read).length;
|
||||||
|
res.json({ notifications, unreadCount });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Failed to load notifications' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/notifications/read-all — Mark all notifications as read
|
||||||
|
// NOTE: Must be declared before /:id/read to avoid routing collision
|
||||||
|
router.post('/read-all', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
await db.run('UPDATE notifications SET read = 1 WHERE user_id = ?', [req.user.id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Failed to update notifications' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/notifications/:id/read — Mark a single notification as read
|
||||||
|
router.post('/:id/read', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
await db.run(
|
||||||
|
'UPDATE notifications SET read = 1 WHERE id = ? AND user_id = ?',
|
||||||
|
[req.params.id, req.user.id],
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Failed to update notification' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -7,6 +7,7 @@ import { rateLimit } from 'express-rate-limit';
|
|||||||
import { getDb } from '../config/database.js';
|
import { getDb } from '../config/database.js';
|
||||||
import { authenticateToken } from '../middleware/auth.js';
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
import { log } from '../config/logger.js';
|
import { log } from '../config/logger.js';
|
||||||
|
import { createNotification } from '../config/notifications.js';
|
||||||
import {
|
import {
|
||||||
createMeeting,
|
createMeeting,
|
||||||
joinMeeting,
|
joinMeeting,
|
||||||
@@ -402,6 +403,15 @@ router.post('/:uid/shares', authenticateToken, async (req, res) => {
|
|||||||
JOIN users u ON rs.user_id = u.id
|
JOIN users u ON rs.user_id = u.id
|
||||||
WHERE rs.room_id = ?
|
WHERE rs.room_id = ?
|
||||||
`, [room.id]);
|
`, [room.id]);
|
||||||
|
// Notify the user who was given access
|
||||||
|
const sharerName = req.user.display_name || req.user.name;
|
||||||
|
await createNotification(
|
||||||
|
user_id,
|
||||||
|
'room_share_added',
|
||||||
|
room.name,
|
||||||
|
sharerName,
|
||||||
|
`/rooms/${room.uid}`,
|
||||||
|
);
|
||||||
res.json({ shares });
|
res.json({ shares });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.rooms.error(`Share room error: ${err.message}`);
|
log.rooms.error(`Share room error: ${err.message}`);
|
||||||
@@ -417,13 +427,22 @@ router.delete('/:uid/shares/:userId', authenticateToken, async (req, res) => {
|
|||||||
if (!room) {
|
if (!room) {
|
||||||
return res.status(404).json({ error: 'Room not found or no permission' });
|
return res.status(404).json({ error: 'Room not found or no permission' });
|
||||||
}
|
}
|
||||||
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, parseInt(req.params.userId)]);
|
const removedUserId = parseInt(req.params.userId);
|
||||||
|
await db.run('DELETE FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, removedUserId]);
|
||||||
const shares = await db.all(`
|
const shares = await db.all(`
|
||||||
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
|
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
|
||||||
FROM room_shares rs
|
FROM room_shares rs
|
||||||
JOIN users u ON rs.user_id = u.id
|
JOIN users u ON rs.user_id = u.id
|
||||||
WHERE rs.room_id = ?
|
WHERE rs.room_id = ?
|
||||||
`, [room.id]);
|
`, [room.id]);
|
||||||
|
// Notify the user whose access was removed
|
||||||
|
await createNotification(
|
||||||
|
removedUserId,
|
||||||
|
'room_share_removed',
|
||||||
|
room.name,
|
||||||
|
null,
|
||||||
|
'/dashboard',
|
||||||
|
);
|
||||||
res.json({ shares });
|
res.json({ shares });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.rooms.error(`Remove share error: ${err.message}`);
|
log.rooms.error(`Remove share error: ${err.message}`);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useLanguage } from '../contexts/LanguageContext';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
|
import NotificationBell from './NotificationBell';
|
||||||
|
|
||||||
export default function Navbar({ onMenuClick }) {
|
export default function Navbar({ onMenuClick }) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
@@ -51,6 +52,9 @@ export default function Navbar({ onMenuClick }) {
|
|||||||
|
|
||||||
{/* Right section */}
|
{/* Right section */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Notification bell */}
|
||||||
|
<NotificationBell />
|
||||||
|
|
||||||
{/* User dropdown */}
|
{/* User dropdown */}
|
||||||
<div className="relative" ref={dropdownRef}>
|
<div className="relative" ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
|
|||||||
167
src/components/NotificationBell.jsx
Normal file
167
src/components/NotificationBell.jsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { useRef, useState, useEffect } from 'react';
|
||||||
|
import { Bell, BellOff, CheckCheck, ExternalLink } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useNotifications } from '../contexts/NotificationContext';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
|
function timeAgo(dateStr, lang) {
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime();
|
||||||
|
const m = Math.floor(diff / 60_000);
|
||||||
|
const h = Math.floor(diff / 3_600_000);
|
||||||
|
const d = Math.floor(diff / 86_400_000);
|
||||||
|
if (lang === 'de') {
|
||||||
|
if (m < 1) return 'gerade eben';
|
||||||
|
if (m < 60) return `vor ${m} Min.`;
|
||||||
|
if (h < 24) return `vor ${h} Std.`;
|
||||||
|
return `vor ${d} Tag${d !== 1 ? 'en' : ''}`;
|
||||||
|
}
|
||||||
|
if (m < 1) return 'just now';
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
return `${d}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationIcon(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'room_share_added': return '🔗';
|
||||||
|
case 'room_share_removed': return '🚫';
|
||||||
|
case 'federation_invite_received': return '📩';
|
||||||
|
default: return '🔔';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationSubtitle(n, t, lang) {
|
||||||
|
switch (n.type) {
|
||||||
|
case 'room_share_added':
|
||||||
|
return n.body
|
||||||
|
? (lang === 'de' ? `Geteilt von ${n.body}` : `Shared by ${n.body}`)
|
||||||
|
: t('notifications.roomShareAdded');
|
||||||
|
case 'room_share_removed':
|
||||||
|
return t('notifications.roomShareRemoved');
|
||||||
|
case 'federation_invite_received':
|
||||||
|
return n.body
|
||||||
|
? (lang === 'de' ? `Raum: ${n.body}` : `Room: ${n.body}`)
|
||||||
|
: t('notifications.federationInviteReceived');
|
||||||
|
default:
|
||||||
|
return n.body || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function NotificationBell() {
|
||||||
|
const { notifications, unreadCount, markRead, markAllRead } = useNotifications();
|
||||||
|
const { t, language } = useLanguage();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const containerRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleOutsideClick(e) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleOutsideClick);
|
||||||
|
return () => document.removeEventListener('mousedown', handleOutsideClick);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNotificationClick = async (n) => {
|
||||||
|
if (!n.read) await markRead(n.id);
|
||||||
|
if (n.link) navigate(n.link);
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const recent = notifications.slice(0, 20);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={containerRef}>
|
||||||
|
{/* Bell button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(prev => !prev)}
|
||||||
|
className="relative p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||||
|
title={t('notifications.bell')}
|
||||||
|
>
|
||||||
|
<Bell size={20} />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="absolute top-1 right-1 min-w-[16px] h-4 px-0.5 flex items-center justify-center rounded-full bg-th-accent text-th-accent-t text-[10px] font-bold leading-none">
|
||||||
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown */}
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 mt-2 w-80 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden z-50">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-th-border">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bell size={16} className="text-th-accent" />
|
||||||
|
<span className="text-sm font-semibold text-th-text">{t('notifications.bell')}</span>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<span className="px-1.5 py-0.5 rounded-full bg-th-accent text-th-accent-t text-xs font-bold">
|
||||||
|
{unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={markAllRead}
|
||||||
|
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||||
|
title={t('notifications.markAllRead')}
|
||||||
|
>
|
||||||
|
<CheckCheck size={14} />
|
||||||
|
{t('notifications.markAllRead')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
<div className="max-h-80 overflow-y-auto">
|
||||||
|
{recent.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-th-text-s gap-2">
|
||||||
|
<BellOff size={24} />
|
||||||
|
<span className="text-sm">{t('notifications.noNotifications')}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{recent.map(n => (
|
||||||
|
<li
|
||||||
|
key={n.id}
|
||||||
|
onClick={() => handleNotificationClick(n)}
|
||||||
|
className={`flex items-start gap-3 px-4 py-3 cursor-pointer transition-colors border-b border-th-border/50 last:border-0
|
||||||
|
${n.read ? 'hover:bg-th-hover' : 'bg-th-accent/5 hover:bg-th-accent/10'}`}
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<span className="text-lg flex-shrink-0 mt-0.5">{notificationIcon(n.type)}</span>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={`text-sm truncate ${n.read ? 'text-th-text-s' : 'text-th-text font-medium'}`}>
|
||||||
|
{n.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-th-text-s truncate">
|
||||||
|
{notificationSubtitle(n, t, language)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-th-text-s/70 mt-0.5">
|
||||||
|
{timeAgo(n.created_at, language)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Unread dot + link indicator */}
|
||||||
|
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||||
|
{!n.read && (
|
||||||
|
<span className="w-2 h-2 rounded-full bg-th-accent mt-1" />
|
||||||
|
)}
|
||||||
|
{n.link && (
|
||||||
|
<ExternalLink size={12} className="text-th-text-s/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { LayoutDashboard, Settings, Shield, X, Palette, Globe, CalendarDays } from 'lucide-react';
|
import { LayoutDashboard, Settings, Shield, X, Palette, Globe, CalendarDays, FileText, Lock } from 'lucide-react';
|
||||||
import BrandLogo from './BrandLogo';
|
import BrandLogo from './BrandLogo';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { useBranding } from '../contexts/BrandingContext';
|
||||||
import ThemeSelector from './ThemeSelector';
|
import ThemeSelector from './ThemeSelector';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
@@ -10,6 +11,7 @@ import api from '../services/api';
|
|||||||
export default function Sidebar({ open, onClose }) {
|
export default function Sidebar({ open, onClose }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
const { imprintUrl, privacyUrl } = useBranding();
|
||||||
const [themeOpen, setThemeOpen] = useState(false);
|
const [themeOpen, setThemeOpen] = useState(false);
|
||||||
const [federationCount, setFederationCount] = useState(0);
|
const [federationCount, setFederationCount] = useState(0);
|
||||||
|
|
||||||
@@ -122,6 +124,37 @@ export default function Sidebar({ open, onClose }) {
|
|||||||
<p className="text-xs text-th-text-s truncate">@{user?.name}</p>
|
<p className="text-xs text-th-text-s truncate">@{user?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Imprint / Privacy Policy links */}
|
||||||
|
{(imprintUrl || privacyUrl) && (
|
||||||
|
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-th-border/60">
|
||||||
|
{imprintUrl && (
|
||||||
|
<a
|
||||||
|
href={imprintUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||||
|
>
|
||||||
|
<FileText size={11} />
|
||||||
|
{t('nav.imprint')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{imprintUrl && privacyUrl && (
|
||||||
|
<span className="text-th-border text-xs">·</span>
|
||||||
|
)}
|
||||||
|
{privacyUrl && (
|
||||||
|
<a
|
||||||
|
href={privacyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-1 text-xs text-th-text-s hover:text-th-accent transition-colors"
|
||||||
|
>
|
||||||
|
<Lock size={11} />
|
||||||
|
{t('nav.privacy')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export function BrandingProvider({ children }) {
|
|||||||
hasLogo: false,
|
hasLogo: false,
|
||||||
logoUrl: null,
|
logoUrl: null,
|
||||||
defaultTheme: null,
|
defaultTheme: null,
|
||||||
|
imprintUrl: null,
|
||||||
|
privacyUrl: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchBranding = useCallback(async () => {
|
const fetchBranding = useCallback(async () => {
|
||||||
|
|||||||
94
src/contexts/NotificationContext.jsx
Normal file
94
src/contexts/NotificationContext.jsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const NotificationContext = createContext();
|
||||||
|
|
||||||
|
export function NotificationProvider({ children }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [notifications, setNotifications] = useState([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
// Track seen IDs to detect genuinely new arrivals and show toasts
|
||||||
|
const seenIds = useRef(new Set());
|
||||||
|
const initialized = useRef(false);
|
||||||
|
|
||||||
|
const fetch = useCallback(async () => {
|
||||||
|
if (!user) return;
|
||||||
|
try {
|
||||||
|
const res = await api.get('/notifications');
|
||||||
|
const incoming = res.data.notifications || [];
|
||||||
|
setNotifications(incoming);
|
||||||
|
setUnreadCount(res.data.unreadCount || 0);
|
||||||
|
|
||||||
|
// First fetch: just seed the seen-set without toasting
|
||||||
|
if (!initialized.current) {
|
||||||
|
incoming.forEach(n => seenIds.current.add(n.id));
|
||||||
|
initialized.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent fetches: toast new unread notifications
|
||||||
|
const newItems = incoming.filter(n => !n.read && !seenIds.current.has(n.id));
|
||||||
|
newItems.forEach(n => {
|
||||||
|
seenIds.current.add(n.id);
|
||||||
|
const icon = notificationIcon(n.type);
|
||||||
|
toast(`${icon} ${n.title}`, { duration: 5000 });
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* silent – server may not be reachable */
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
setNotifications([]);
|
||||||
|
setUnreadCount(0);
|
||||||
|
seenIds.current = new Set();
|
||||||
|
initialized.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch();
|
||||||
|
const interval = setInterval(fetch, 30_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [user, fetch]);
|
||||||
|
|
||||||
|
const markRead = async (id) => {
|
||||||
|
try {
|
||||||
|
await api.post(`/notifications/${id}/read`);
|
||||||
|
setNotifications(prev =>
|
||||||
|
prev.map(n => (n.id === id ? { ...n, read: 1 } : n)),
|
||||||
|
);
|
||||||
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||||
|
} catch { /* silent */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAllRead = async () => {
|
||||||
|
try {
|
||||||
|
await api.post('/notifications/read-all');
|
||||||
|
setNotifications(prev => prev.map(n => ({ ...n, read: 1 })));
|
||||||
|
setUnreadCount(0);
|
||||||
|
} catch { /* silent */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider value={{ notifications, unreadCount, markRead, markAllRead, refresh: fetch }}>
|
||||||
|
{children}
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotifications() {
|
||||||
|
const ctx = useContext(NotificationContext);
|
||||||
|
if (!ctx) throw new Error('useNotifications must be used within NotificationProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notificationIcon(type) {
|
||||||
|
switch (type) {
|
||||||
|
case 'room_share_added': return '🔗';
|
||||||
|
case 'room_share_removed': return '🚫';
|
||||||
|
case 'federation_invite_received': return '📩';
|
||||||
|
default: return '🔔';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,9 @@
|
|||||||
"changeTheme": "Theme ändern",
|
"changeTheme": "Theme ändern",
|
||||||
"navigation": "Navigation",
|
"navigation": "Navigation",
|
||||||
"calendar": "Kalender",
|
"calendar": "Kalender",
|
||||||
"federation": "Einladungen"
|
"federation": "Einladungen",
|
||||||
|
"imprint": "Impressum",
|
||||||
|
"privacy": "Datenschutz"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Anmelden",
|
"login": "Anmelden",
|
||||||
@@ -356,7 +358,23 @@
|
|||||||
"inviteExpired": "Abgelaufen",
|
"inviteExpired": "Abgelaufen",
|
||||||
"inviteUsedBy": "Verwendet von",
|
"inviteUsedBy": "Verwendet von",
|
||||||
"inviteExpiresAt": "Läuft ab am",
|
"inviteExpiresAt": "Läuft ab am",
|
||||||
"noInvites": "Noch keine Einladungen"
|
"noInvites": "Noch keine Einladungen",
|
||||||
|
"legalLinksTitle": "Rechtliche Links",
|
||||||
|
"legalLinksDesc": "Impressum- und Datenschutz-Links am unteren Rand der Seitenleiste anzeigen. Leer lassen zum Ausblenden.",
|
||||||
|
"imprintUrl": "Impressum-URL",
|
||||||
|
"privacyUrl": "Datenschutz-URL",
|
||||||
|
"imprintUrlSaved": "Impressum-URL gespeichert",
|
||||||
|
"privacyUrlSaved": "Datenschutz-URL gespeichert",
|
||||||
|
"imprintUrlFailed": "Impressum-URL konnte nicht gespeichert werden",
|
||||||
|
"privacyUrlFailed": "Datenschutz-URL konnte nicht gespeichert werden"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"bell": "Benachrichtigungen",
|
||||||
|
"markAllRead": "Alle als gelesen markieren",
|
||||||
|
"noNotifications": "Keine Benachrichtigungen",
|
||||||
|
"roomShareAdded": "Raum wurde mit dir geteilt",
|
||||||
|
"roomShareRemoved": "Raumzugriff wurde entfernt",
|
||||||
|
"federationInviteReceived": "Neue Meeting-Einladung"
|
||||||
},
|
},
|
||||||
"federation": {
|
"federation": {
|
||||||
"inbox": "Einladungen",
|
"inbox": "Einladungen",
|
||||||
|
|||||||
@@ -33,7 +33,9 @@
|
|||||||
"changeTheme": "Change theme",
|
"changeTheme": "Change theme",
|
||||||
"navigation": "Navigation",
|
"navigation": "Navigation",
|
||||||
"calendar": "Calendar",
|
"calendar": "Calendar",
|
||||||
"federation": "Invitations"
|
"federation": "Invitations",
|
||||||
|
"imprint": "Imprint",
|
||||||
|
"privacy": "Privacy Policy"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
"login": "Sign in",
|
"login": "Sign in",
|
||||||
@@ -356,7 +358,23 @@
|
|||||||
"inviteExpired": "Expired",
|
"inviteExpired": "Expired",
|
||||||
"inviteUsedBy": "Used by",
|
"inviteUsedBy": "Used by",
|
||||||
"inviteExpiresAt": "Expires",
|
"inviteExpiresAt": "Expires",
|
||||||
"noInvites": "No invitations yet"
|
"noInvites": "No invitations yet",
|
||||||
|
"legalLinksTitle": "Legal Links",
|
||||||
|
"legalLinksDesc": "Show Imprint and Privacy Policy links at the bottom of the sidebar. Leave blank to hide.",
|
||||||
|
"imprintUrl": "Imprint URL",
|
||||||
|
"privacyUrl": "Privacy Policy URL",
|
||||||
|
"imprintUrlSaved": "Imprint URL saved",
|
||||||
|
"privacyUrlSaved": "Privacy Policy URL saved",
|
||||||
|
"imprintUrlFailed": "Could not save Imprint URL",
|
||||||
|
"privacyUrlFailed": "Could not save Privacy Policy URL"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"bell": "Notifications",
|
||||||
|
"markAllRead": "Mark all as read",
|
||||||
|
"noNotifications": "No notifications yet",
|
||||||
|
"roomShareAdded": "Room shared with you",
|
||||||
|
"roomShareRemoved": "Room access removed",
|
||||||
|
"federationInviteReceived": "New meeting invitation"
|
||||||
},
|
},
|
||||||
"federation": {
|
"federation": {
|
||||||
"inbox": "Invitations",
|
"inbox": "Invitations",
|
||||||
|
|||||||
27
src/main.jsx
27
src/main.jsx
@@ -7,6 +7,7 @@ import { AuthProvider } from './contexts/AuthContext';
|
|||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import { LanguageProvider } from './contexts/LanguageContext';
|
import { LanguageProvider } from './contexts/LanguageContext';
|
||||||
import { BrandingProvider } from './contexts/BrandingContext';
|
import { BrandingProvider } from './contexts/BrandingContext';
|
||||||
|
import { NotificationProvider } from './contexts/NotificationContext';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
@@ -16,18 +17,20 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
|||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<BrandingProvider>
|
<BrandingProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<App />
|
<NotificationProvider>
|
||||||
<Toaster
|
<App />
|
||||||
position="top-right"
|
<Toaster
|
||||||
toastOptions={{
|
position="top-right"
|
||||||
duration: 4000,
|
toastOptions={{
|
||||||
style: {
|
duration: 4000,
|
||||||
background: 'var(--card-bg)',
|
style: {
|
||||||
color: 'var(--text-primary)',
|
background: 'var(--card-bg)',
|
||||||
border: '1px solid var(--border)',
|
color: 'var(--text-primary)',
|
||||||
},
|
border: '1px solid var(--border)',
|
||||||
}}
|
},
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
</NotificationProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrandingProvider>
|
</BrandingProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||||
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
|
Upload, X as XIcon, Image, Type, Palette, Send, Copy, Clock, Check,
|
||||||
ShieldCheck, Globe,
|
ShieldCheck, Globe, Link as LinkIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
@@ -16,7 +16,7 @@ import toast from 'react-hot-toast';
|
|||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { t, language } = useLanguage();
|
const { t, language } = useLanguage();
|
||||||
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, refreshBranding } = useBranding();
|
const { appName, hasLogo, logoUrl, defaultTheme, registrationMode, imprintUrl, privacyUrl, refreshBranding } = useBranding();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -43,6 +43,10 @@ export default function Admin() {
|
|||||||
const logoInputRef = useRef(null);
|
const logoInputRef = useRef(null);
|
||||||
const [editDefaultTheme, setEditDefaultTheme] = useState('');
|
const [editDefaultTheme, setEditDefaultTheme] = useState('');
|
||||||
const [savingDefaultTheme, setSavingDefaultTheme] = useState(false);
|
const [savingDefaultTheme, setSavingDefaultTheme] = useState(false);
|
||||||
|
const [editImprintUrl, setEditImprintUrl] = useState('');
|
||||||
|
const [savingImprintUrl, setSavingImprintUrl] = useState(false);
|
||||||
|
const [editPrivacyUrl, setEditPrivacyUrl] = useState('');
|
||||||
|
const [savingPrivacyUrl, setSavingPrivacyUrl] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
@@ -61,6 +65,14 @@ export default function Admin() {
|
|||||||
setEditDefaultTheme(defaultTheme || 'dark');
|
setEditDefaultTheme(defaultTheme || 'dark');
|
||||||
}, [defaultTheme]);
|
}, [defaultTheme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditImprintUrl(imprintUrl || '');
|
||||||
|
}, [imprintUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditPrivacyUrl(privacyUrl || '');
|
||||||
|
}, [privacyUrl]);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/admin/users');
|
const res = await api.get('/admin/users');
|
||||||
@@ -237,6 +249,32 @@ export default function Admin() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImprintUrlSave = async () => {
|
||||||
|
setSavingImprintUrl(true);
|
||||||
|
try {
|
||||||
|
await api.put('/branding/imprint-url', { imprintUrl: editImprintUrl.trim() });
|
||||||
|
toast.success(t('admin.imprintUrlSaved'));
|
||||||
|
refreshBranding();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('admin.imprintUrlFailed'));
|
||||||
|
} finally {
|
||||||
|
setSavingImprintUrl(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrivacyUrlSave = async () => {
|
||||||
|
setSavingPrivacyUrl(true);
|
||||||
|
try {
|
||||||
|
await api.put('/branding/privacy-url', { privacyUrl: editPrivacyUrl.trim() });
|
||||||
|
toast.success(t('admin.privacyUrlSaved'));
|
||||||
|
refreshBranding();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('admin.privacyUrlFailed'));
|
||||||
|
} finally {
|
||||||
|
setSavingPrivacyUrl(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const filteredUsers = users.filter(u =>
|
const filteredUsers = users.filter(u =>
|
||||||
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
|
(u.display_name || u.name).toLowerCase().includes(search.toLowerCase()) ||
|
||||||
u.email.toLowerCase().includes(search.toLowerCase())
|
u.email.toLowerCase().includes(search.toLowerCase())
|
||||||
@@ -381,6 +419,59 @@ export default function Admin() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Legal links */}
|
||||||
|
<div className="mt-6 pt-6 border-t border-th-border">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<LinkIcon size={16} className="text-th-accent" />
|
||||||
|
<label className="block text-sm font-medium text-th-text">{t('admin.legalLinksTitle')}</label>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-th-text-s mb-4">{t('admin.legalLinksDesc')}</p>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{/* Imprint */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.imprintUrl')}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={editImprintUrl}
|
||||||
|
onChange={e => setEditImprintUrl(e.target.value)}
|
||||||
|
className="input-field text-sm flex-1"
|
||||||
|
placeholder="https://example.com/imprint"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleImprintUrlSave}
|
||||||
|
disabled={savingImprintUrl || editImprintUrl === (imprintUrl || '')}
|
||||||
|
className="btn-primary text-sm px-4 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{savingImprintUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Privacy Policy */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.privacyUrl')}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={editPrivacyUrl}
|
||||||
|
onChange={e => setEditPrivacyUrl(e.target.value)}
|
||||||
|
className="input-field text-sm flex-1"
|
||||||
|
placeholder="https://example.com/privacy"
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handlePrivacyUrlSave}
|
||||||
|
disabled={savingPrivacyUrl || editPrivacyUrl === (privacyUrl || '')}
|
||||||
|
className="btn-primary text-sm px-4 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{savingPrivacyUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Registration Mode */}
|
{/* Registration Mode */}
|
||||||
|
|||||||
Reference in New Issue
Block a user