Merge branch 'main' of https://git.scrunkly.cat/Michelle/redlight
Build & Push Docker Image / build (push) Successful in 4m13s

This commit is contained in:
2026-06-21 22:16:43 +02:00
48 changed files with 2221 additions and 1796 deletions
+3
View File
@@ -1,5 +1,8 @@
node_modules/
dist/
redlight.db
redlight.db-wal
redlight.db-shm
uploads/
.env
.claude/
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+1358 -1206
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -1,7 +1,7 @@
{
"name": "redlight",
"private": true,
"version": "2.1.2",
"version": "2.3.0",
"license": "GPL-3.0-or-later",
"type": "module",
"scripts": {
@@ -20,33 +20,33 @@
"cors": "^2.8.5",
"dotenv": "^17.3.1",
"exceljs": "^4.4.0",
"express": "^4.21.0",
"express-rate-limit": "^7.5.1",
"express": "^5.2.1",
"express-rate-limit": "^8.5.2",
"flatpickr": "^4.6.13",
"ioredis": "^5.10.0",
"jsonwebtoken": "^9.0.0",
"lucide-react": "^0.460.0",
"lucide-react": "^1.16.0",
"multer": "^2.1.0",
"nodemailer": "^8.0.1",
"otpauth": "^9.5.0",
"pdfkit": "^0.17.2",
"pdfkit": "^0.18.0",
"pg": "^8.18.0",
"qrcode": "^1.5.4",
"rate-limit-redis": "^4.3.1",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"rate-limit-redis": "^5.0.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-hot-toast": "^2.4.0",
"react-router-dom": "^6.28.0",
"uuid": "^13.0.0",
"react-router-dom": "^7.15.1",
"uuid": "^14.0.0",
"xml2js": "^0.6.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@tailwindcss/postcss": "^4.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"tailwindcss": "^4.3.0",
"vite": "^8.0.0"
}
}
+1 -2
View File
@@ -1,6 +1,5 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
'@tailwindcss/postcss': {},
},
};
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Speech bubble tail -->
<path d="M 185 325 L 148 425 L 248 360 Z" fill="#2B3140" stroke="#2B3140" stroke-width="14" stroke-linejoin="round"/>
<!-- Speech bubble outline -->
<circle cx="256" cy="240" r="120" fill="none" stroke="#2B3140" stroke-width="34"/>
<!-- Red dot -->
<circle cx="262" cy="234" r="76" fill="#ED2224"/>
<!-- Sound waves right -->
<path d="M 423 100 A 218 218 0 0 1 423 380" fill="none" stroke="#ED2224" stroke-width="32" stroke-linecap="round"/>
<path d="M 413 160 A 176 176 0 0 1 413 320" fill="none" stroke="#ED2224" stroke-width="30" stroke-linecap="round"/>
<!-- Sound waves left -->
<path d="M 89 100 A 218 218 0 0 0 89 380" fill="none" stroke="#ED2224" stroke-width="32" stroke-linecap="round"/>
<path d="M 99 160 A 176 176 0 0 0 99 320" fill="none" stroke="#ED2224" stroke-width="30" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 934 B

-5
View File
@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="8" fill="#ef4444"/>
<circle cx="16" cy="16" r="8" fill="white" opacity="0.9"/>
<circle cx="16" cy="16" r="4" fill="#ef4444"/>
</svg>

Before

Width:  |  Height:  |  Size: 268 B

+20
View File
@@ -0,0 +1,20 @@
import { getDb } from './database.js';
/**
* Resolve the configured application name.
* Resolution order: admin-set 'app_name' setting → APP_NAME env var → 'Redlight'.
*
* The app name is stored in the settings table under the key 'app_name'
* (see routes/branding.js). This helper is the single source of truth so the
* configured name is used consistently across emails, the 2FA issuer, etc.
*/
export async function getAppName() {
try {
const db = getDb();
const row = await db.get("SELECT value FROM settings WHERE key = 'app_name'");
if (row?.value) return row.value;
} catch {
// fall through to env/default if the DB is unavailable
}
return process.env.APP_NAME || 'Redlight';
}
+10 -4
View File
@@ -1,6 +1,7 @@
import crypto from 'crypto';
import xml2js from 'xml2js';
import { log, fmtDuration, fmtStatus, fmtMethod, fmtReturncode, sanitizeBBBParams } from './logger.js';
import { t } from './emaili18n.js';
const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/';
const BBB_SECRET = process.env.BBB_SECRET || '';
@@ -73,15 +74,15 @@ function getRoomPasswords(uid) {
return { moderatorPW: modPw, attendeePW: attPw };
}
export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null, analyticsCallbackURL = null) {
export async function createMeeting(room, logoutURL, loginURL = null, presentationUrl = null, analyticsCallbackURL = null, lang = 'en') {
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
// Build welcome message with guest invite link
// HTML-escape user-controlled content to prevent stored XSS via BBB
let welcome = room.welcome_message ? escapeHtml(room.welcome_message) : t('defaultWelcome');
let welcome = room.welcome_message ? escapeHtml(room.welcome_message) : escapeHtml(t(lang, 'bbb.defaultWelcome'));
if (logoutURL) {
const guestLink = `${logoutURL}/join/${room.uid}`;
welcome += `<br><br>To invite other participants, share this link:<br><a href="${escapeHtml(guestLink)}">${escapeHtml(guestLink)}</a>`;
welcome += `<br><br>${escapeHtml(t(lang, 'bbb.inviteHint'))}<br><a href="${escapeHtml(guestLink)}">${escapeHtml(guestLink)}</a>`;
// Access code is intentionally NOT shown in the welcome message to prevent
// leaking it to all meeting participants.
}
@@ -129,7 +130,7 @@ export async function createMeeting(room, logoutURL, loginURL = null, presentati
return apiCall('create', params, xmlBody);
}
export async function joinMeeting(uid, name, isModerator = false, avatarURL = null) {
export async function joinMeeting(uid, name, isModerator = false, avatarURL = null, darkMode = false) {
const { moderatorPW, attendeePW } = getRoomPasswords(uid);
const params = {
meetingID: uid,
@@ -140,6 +141,11 @@ export async function joinMeeting(uid, name, isModerator = false, avatarURL = nu
if (avatarURL) {
params.avatarURL = avatarURL;
}
if (darkMode) {
// Per-user userdata override: start the client in dark theme for users who
// have a dark theme active in Redlight. Read by BBB as bbb_prefer_dark_theme.
params['userdata-bbb_prefer_dark_theme'] = 'true';
}
return buildUrl('join', params);
}
+1 -3
View File
@@ -64,7 +64,6 @@ export async function sendVerificationEmail(to, name, verifyUrl, appName = 'Redl
const from = process.env.SMTP_FROM || process.env.SMTP_USER;
const headerAppName = sanitizeHeaderValue(appName);
const safeName = escapeHtml(name);
const safeAppName = escapeHtml(appName);
await transporter.sendMail({
from: `"${headerAppName}" <${from}>`,
@@ -112,7 +111,6 @@ export async function sendFederationInviteEmail(to, name, fromUser, roomName, me
const safeFromUser = escapeHtml(fromUser);
const safeRoomName = escapeHtml(roomName);
const safeMessage = message ? escapeHtml(message) : null;
const safeAppName = escapeHtml(appName);
const introHtml = t(lang, 'email.federationInvite.intro')
.replace('{fromUser}', `<strong style="color:#cdd6f4;">${safeFromUser}</strong>`);
@@ -230,7 +228,7 @@ export async function sendCalendarInviteEmail(to, name, fromUser, title, startTi
<p>${introHtml}</p>
<div style="background:#313244;border-radius:8px;padding:16px;margin:20px 0;">
<p style="margin:0 0 4px 0;font-size:15px;font-weight:bold;color:#cdd6f4;">${safeTitle}</p>
<p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">🕐 ${escapeHtml(formatDate(startTime))} - ${escapeHtml(formatDate(endTime))}</p>
<p style="margin:6px 0 0 0;font-size:13px;color:#a6adc8;">${escapeHtml(formatDate(startTime))} - ${escapeHtml(formatDate(endTime))}</p>
${safeDesc ? `<p style="margin:10px 0 0 0;font-size:13px;color:#a6adc8;font-style:italic;">&quot;${safeDesc}&quot;</p>` : ''}
</div>
<p style="text-align:center;margin:28px 0;">
+6 -2
View File
@@ -1,6 +1,6 @@
{
"email": {
"greeting": "Hey {name} 👋",
"greeting": "Hallo {name},",
"viewInvitation": "Einladung anzeigen",
"invitationFooter": "Öffne den Link oben, um die Einladung anzunehmen oder abzulehnen.",
"linkHint": "Oder kopiere diesen Link in deinen Browser:",
@@ -13,7 +13,7 @@
},
"invite": {
"subject": "{appName} - Du wurdest eingeladen",
"title": "Du wurdest eingeladen! 🎉",
"title": "Du wurdest eingeladen",
"intro": "Du wurdest eingeladen, ein Konto auf {appName} zu erstellen.",
"prompt": "Klicke auf den Button, um dich zu registrieren:",
"button": "Konto erstellen",
@@ -42,5 +42,9 @@
"note": "Der Termin wurde automatisch aus deinem Kalender entfernt.",
"footer": "Diese Nachricht wurde automatisch von {appName} versendet."
}
},
"bbb": {
"defaultWelcome": "Willkommen im Meeting!",
"inviteHint": "Lade weitere Teilnehmer ein, indem du diesen Link teilst:"
}
}
+6 -2
View File
@@ -1,6 +1,6 @@
{
"email": {
"greeting": "Hey {name} 👋",
"greeting": "Hello {name},",
"viewInvitation": "View Invitation",
"invitationFooter": "Open the link above to accept or decline the invitation.",
"linkHint": "Or copy this link in your browser:",
@@ -13,7 +13,7 @@
},
"invite": {
"subject": "{appName} - You've been invited",
"title": "You've been invited! 🎉",
"title": "You have been invited",
"intro": "You have been invited to create an account on {appName}.",
"prompt": "Click the button below to register:",
"button": "Create Account",
@@ -42,5 +42,9 @@
"note": "The event has been automatically removed from your calendar.",
"footer": "This message was sent automatically by {appName}."
}
},
"bbb": {
"defaultWelcome": "Welcome to the meeting!",
"inviteHint": "To invite other participants, share this link:"
}
}
+9 -2
View File
@@ -51,7 +51,14 @@ const corsOptions = process.env.APP_URL
? { origin: process.env.APP_URL, credentials: true }
: { origin: false };
app.use(cors(corsOptions));
app.use(express.json({ limit: '100kb' }));
// Global JSON body limit kept tight as a hardening measure. The BBB learning
// analytics callback can send much larger payloads, so it is excluded here and
// gets its own, more generous limit on the route itself (see routes/analytics.js).
const jsonParser = express.json({ limit: '100kb' });
app.use((req, res, next) => {
if (req.path.startsWith('/api/analytics/callback/')) return next();
return jsonParser(req, res, next);
});
// Request/Response logging (filters sensitive fields)
app.use(requestResponseLogger);
@@ -100,7 +107,7 @@ async function start() {
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '..', 'dist')));
app.get('*', (req, res) => {
app.get('/*splat', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'));
});
}
+3 -3
View File
@@ -4,6 +4,7 @@ import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../config/database.js';
import { authenticateToken, requireAdmin, getBaseUrl } from '../middleware/auth.js';
import { isMailerConfigured, sendInviteEmail } from '../config/mailer.js';
import { getAppName } from '../config/appName.js';
import { log } from '../config/logger.js';
import {
getOAuthConfig,
@@ -217,9 +218,8 @@ router.post('/invites', authenticateToken, requireAdmin, async (req, res) => {
const baseUrl = getBaseUrl(req);
const inviteUrl = `${baseUrl}/register?invite=${token}`;
// Load app name
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'app_name'");
const appName = brandingSetting?.value || 'Redlight';
// Load configured app name (admin setting → env → default)
const appName = await getAppName();
if (isMailerConfigured()) {
try {
+18 -13
View File
@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router, json } from 'express';
import crypto from 'crypto';
import ExcelJS from 'exceljs';
import PDFDocument from 'pdfkit';
@@ -9,20 +9,25 @@ import { getAnalyticsToken } from '../config/bbb.js';
const router = Router();
// Token check runs BEFORE the 5mb body parser so unauthenticated callers
// cannot make the server buffer large payloads.
// Constant-time comparison to prevent timing attacks. Reject non-string tokens
// (e.g. ?token=a&token=b would yield an array and crash Buffer.from).
function verifyCallbackToken(req, res, next) {
const { token } = req.query;
const expectedToken = getAnalyticsToken(req.params.uid);
if (typeof token !== 'string' || token.length !== expectedToken.length ||
!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
return res.status(403).json({ error: 'Invalid token' });
}
next();
}
// POST /api/analytics/callback/:uid?token=... - BBB Learning Analytics callback (token-secured)
router.post('/callback/:uid', async (req, res) => {
// Excluded from the global 100kb body limit (see index.js): learning analytics
// payloads for large meetings can be several MB.
router.post('/callback/:uid', verifyCallbackToken, json({ limit: '5mb' }), async (req, res) => {
try {
const { token } = req.query;
const expectedToken = getAnalyticsToken(req.params.uid);
// Constant-time comparison to prevent timing attacks.
// Reject non-string tokens (e.g. ?token=a&token=b would yield an array and
// crash Buffer.from).
if (typeof token !== 'string' || token.length !== expectedToken.length ||
!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expectedToken))) {
return res.status(403).json({ error: 'Invalid token' });
}
const db = getDb();
const room = await db.get('SELECT id, uid, learning_analytics FROM rooms WHERE uid = ?', [req.params.uid]);
+67 -27
View File
@@ -5,7 +5,7 @@ import { v4 as uuidv4 } from 'uuid';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { rateLimit } from 'express-rate-limit';
import { rateLimit, MemoryStore } from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import * as OTPAuth from 'otpauth';
import { getDb } from '../config/database.js';
@@ -13,6 +13,7 @@ import redis from '../config/redis.js';
import { authenticateToken, generateToken, getBaseUrl } from '../middleware/auth.js';
import { isMailerConfigured, sendVerificationEmail } from '../config/mailer.js';
import { getOAuthConfig, discoverOIDC } from '../config/oauth.js';
import { getAppName } from '../config/appName.js';
import { log } from '../config/logger.js';
if (!process.env.JWT_SECRET) {
@@ -22,15 +23,65 @@ if (!process.env.JWT_SECRET) {
const JWT_SECRET = process.env.JWT_SECRET;
// ── Rate Limiting ────────────────────────────────────────────────────────────
function makeRedisStore(prefix) {
try {
return new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix,
});
} catch {
return undefined; // falls back to in-memory if Redis unavailable
// Shared Redis store with in-memory fallback. Without the fallback, a dead
// Redis connection makes every rate-limited endpoint (incl. login) return 500 —
// rate limiting must degrade, not take auth down with it.
class ResilientStore {
constructor(prefix) {
try {
this.redisStore = new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix,
});
} catch {
this.redisStore = null;
}
this.memoryStore = new MemoryStore();
}
init(options) {
try {
// RedisStore.init returns the SCRIPT LOAD promise; catch it so a dead
// Redis connection surfaces as a warning instead of an unhandled
// rejection that kills the process.
const initResult = this.redisStore?.init?.(options);
if (typeof initResult?.catch === 'function') {
initResult.catch(err => log.auth.warn(`Rate-limit Redis init failed: ${err.message}`));
}
} catch (err) {
log.auth.warn(`Rate-limit Redis init failed: ${err.message}`);
}
this.memoryStore.init(options);
}
async increment(key) {
if (this.redisStore && redis.status === 'ready') {
try {
return await this.redisStore.increment(key);
} catch (err) {
log.auth.warn(`Rate-limit Redis store failed, using in-memory fallback: ${err.message}`);
}
}
return this.memoryStore.increment(key);
}
async decrement(key) {
if (this.redisStore && redis.status === 'ready') {
try {
return await this.redisStore.decrement(key);
} catch { /* fall through to memory */ }
}
return this.memoryStore.decrement(key);
}
async resetKey(key) {
if (this.redisStore && redis.status === 'ready') {
try {
return await this.redisStore.resetKey(key);
} catch { /* fall through to memory */ }
}
return this.memoryStore.resetKey(key);
}
}
function makeRedisStore(prefix) {
return new ResilientStore(prefix);
}
// ── Validation helpers ─────────────────────────────────────────────────────
@@ -179,7 +230,8 @@ router.post('/register', registerLimiter, async (req, res) => {
return res.status(400).json({ error: `Password must not exceed ${MAX_PASSWORD_LENGTH} characters` });
}
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
// Emails are stored lowercased, so compare lowercased to catch case-variant duplicates
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) {
return res.status(409).json({ error: 'Email is already in use' });
}
@@ -213,12 +265,8 @@ router.post('/register', registerLimiter, async (req, res) => {
const baseUrl = getBaseUrl(req);
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
// Load app name from branding settings
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
let appName = 'Redlight';
if (brandingSetting?.value) {
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
}
// Load configured app name (admin setting → env → default)
const appName = await getAppName();
try {
await sendVerificationEmail(email.toLowerCase(), display_name, verifyUrl, appName, 'en');
@@ -327,11 +375,7 @@ router.post('/resend-verification', resendVerificationLimiter, async (req, res)
const baseUrl = getBaseUrl(req);
const verifyUrl = `${baseUrl}/verify-email?token=${verificationToken}`;
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
let appName = 'Redlight';
if (brandingSetting?.value) {
try { appName = JSON.parse(brandingSetting.value).appName || appName; } catch {}
}
const appName = await getAppName();
try {
await sendVerificationEmail(email.toLowerCase(), user.display_name || user.name, verifyUrl, appName, user.language || 'en');
@@ -774,12 +818,8 @@ router.post('/2fa/setup', authenticateToken, twoFaLimiter, async (req, res) => {
const secret = new OTPAuth.Secret({ size: 20 });
// Load app name from branding settings
const brandingSetting = await db.get("SELECT value FROM settings WHERE key = 'branding'");
let issuer = 'Redlight';
if (brandingSetting?.value) {
try { issuer = JSON.parse(brandingSetting.value).appName || issuer; } catch {}
}
// Use the configured app name as the TOTP issuer (admin setting → env → default)
const issuer = await getAppName();
const totp = new OTPAuth.TOTP({
issuer,
+18 -4
View File
@@ -12,12 +12,25 @@
import { Router, text } from 'express';
import crypto from 'crypto';
import { rateLimit } from 'express-rate-limit';
import { getDb } from '../config/database.js';
import { log } from '../config/logger.js';
import { getBaseUrl } from '../middleware/auth.js';
const router = Router();
// Brute-force protection for the Basic-Auth tokens: only failed requests count
// (skipSuccessfulRequests), so legitimate sync clients polling frequently are
// not throttled.
router.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 30,
skipSuccessfulRequests: true,
standardHeaders: true,
legacyHeaders: false,
message: 'Too many failed CalDAV requests. Please try again later.',
}));
// ── Body parsing for XML and iCalendar payloads ────────────────────────────
router.use(text({ type: ['application/xml', 'text/xml', 'text/calendar', 'application/octet-stream'] }));
@@ -194,9 +207,10 @@ async function caldavAuth(req, res, next) {
const token = decoded.slice(colonIdx + 1);
const db = getDb();
// Emails are stored lowercased
const user = await db.get(
'SELECT id, name, display_name, email FROM users WHERE email = ?',
[email],
[email.toLowerCase()],
);
if (!user) {
res.set('WWW-Authenticate', 'Basic realm="Redlight CalDAV"');
@@ -243,7 +257,7 @@ function setDAVHeaders(res) {
// ── CalDAV username authorization ──────────────────────────────────────────
// Ensures the :username param matches the authenticated user's email
function validateCalDAVUser(req, res, next) {
if (req.params.username && decodeURIComponent(req.params.username) !== req.caldavUser.email) {
if (req.params.username && decodeURIComponent(req.params.username).toLowerCase() !== req.caldavUser.email) {
return res.status(403).end();
}
next();
@@ -278,7 +292,7 @@ ${propXml}
}
// ── OPTIONS ────────────────────────────────────────────────────────────────
router.options('*', (req, res) => {
router.options('/*splat', (req, res) => {
setDAVHeaders(res);
res.status(200).end();
});
@@ -529,7 +543,7 @@ router.delete('/:username/calendar/:filename', caldavAuth, validateCalDAVUser, a
});
// ── Fallback ───────────────────────────────────────────────────────────────
router.all('*', caldavAuth, (req, res) => {
router.all('/*splat', caldavAuth, (req, res) => {
setDAVHeaders(res);
res.status(405).end();
});
+7 -4
View File
@@ -4,6 +4,7 @@ import { getDb } from '../config/database.js';
import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js';
import { sendCalendarInviteEmail } from '../config/mailer.js';
import { getAppName } from '../config/appName.js';
import {
isFederationEnabled,
getFederationDomain,
@@ -219,7 +220,7 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
end_time = COALESCE(?, end_time),
room_uid = ?,
color = COALESCE(?, color),
reminder_minutes = COALESCE(?, reminder_minutes),
reminder_minutes = ?,
reminder_sent_at = ${resetReminder ? 'NULL' : 'reminder_sent_at'},
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
@@ -230,7 +231,9 @@ router.put('/events/:id', authenticateToken, async (req, res) => {
end_time || null,
room_uid !== undefined ? (room_uid || null) : event.room_uid,
color || null,
validReminder !== undefined ? validReminder : null,
// Not in payload → keep existing; present (incl. null) → set/clear directly.
// COALESCE can't be used here: it would treat an explicit null as "keep".
validReminder !== undefined ? validReminder : event.reminder_minutes,
req.params.id,
]);
@@ -323,7 +326,7 @@ router.post('/events/:id/share', authenticateToken, async (req, res) => {
if (targetUser?.email) {
const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight';
const appName = await getAppName();
const senderUser = await db.get('SELECT name, display_name FROM users WHERE id = ?', [req.user.id]);
const fromDisplay = (senderUser?.display_name && senderUser.display_name !== '') ? senderUser.display_name : (senderUser?.name || req.user.name);
sendCalendarInviteEmail(
@@ -645,7 +648,7 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as
if (targetUser.email) {
const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight';
const appName = await getAppName();
sendCalendarInviteEmail(
targetUser.email, targetUser.name, from_user,
title, start_time, end_time, description || null,
+5 -4
View File
@@ -6,6 +6,7 @@ import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { sendFederationInviteEmail, sendCalendarEventDeletedEmail } from '../config/mailer.js';
import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js';
import { getAppName } from '../config/appName.js';
// M13: rate limit the unauthenticated federation receive endpoint
const federationReceiveLimiter = rateLimit({
@@ -40,7 +41,7 @@ export function wellKnownHandler(req, res) {
federation_api: '/api/federation',
public_key: getPublicKey(),
software: 'Redlight',
version: '2.1.2',
version: '2.3.0',
});
}
@@ -198,7 +199,7 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
// Look up user by name (case-insensitive)
const targetUser = await db.get(
'SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)',
'SELECT id, name, email, language FROM users WHERE LOWER(name) = LOWER(?)',
[username]
);
@@ -238,7 +239,7 @@ router.post('/receive', federationReceiveLimiter, async (req, res) => {
if (targetUser.email) {
const appUrl = getBaseUrl(req);
const inboxUrl = `${appUrl}/federation/inbox`;
const appName = process.env.APP_NAME || 'Redlight';
const appName = await getAppName();
sendFederationInviteEmail(
targetUser.email, targetUser.name, from_user,
room_name, message || null, inboxUrl, appName, targetUser.language || 'en'
@@ -627,7 +628,7 @@ router.post('/calendar-event-deleted', federationReceiveLimiter, async (req, res
// Notify affected users by email (fire-and-forget)
if (affectedUsers.length > 0) {
const appName = process.env.APP_NAME || 'Redlight';
const appName = await getAppName();
for (const u of affectedUsers) {
sendCalendarEventDeletedEmail(u.email, u.name, u.from_user, u.title, appName, u.language || 'en')
.catch(mailErr => {
+31 -9
View File
@@ -9,6 +9,7 @@ import { authenticateToken, getBaseUrl } from '../middleware/auth.js';
import { log } from '../config/logger.js';
import { createNotification } from '../config/notifications.js';
import { sendGuestInviteEmail } from '../config/mailer.js';
import { getAppName } from '../config/appName.js';
import {
createMeeting,
joinMeeting,
@@ -66,6 +67,16 @@ const guestJoinLimiter = rateLimit({
message: { error: 'Too many join attempts. Please try again later.' },
});
// Rate limit email invitations: each request may carry up to 50 addresses, so
// without a cap any registered account could be abused as an SMTP spam relay.
const inviteEmailLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many email invitations. Please try again later.' },
});
const router = Router();
// Build avatar URL for a user (uploaded image or generated initials)
@@ -424,6 +435,10 @@ router.post('/:uid/shares', authenticateToken, async (req, res) => {
if (user_id === req.user.id) {
return res.status(400).json({ error: 'You cannot share the room with yourself' });
}
const targetUser = await db.get('SELECT id FROM users WHERE id = ?', [user_id]);
if (!targetUser) {
return res.status(404).json({ error: 'User not found' });
}
// Check if already shared
const existing = await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, user_id]);
if (existing) {
@@ -513,10 +528,12 @@ router.post('/:uid/start', authenticateToken, async (req, res) => {
const analyticsCallbackURL = room.learning_analytics
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
: null;
await createMeeting(room, baseUrl, loginURL, presentationUrl, analyticsCallbackURL);
// Localise BBB's default welcome message in the room owner's language
const owner = await db.get('SELECT language FROM users WHERE id = ?', [room.user_id]);
await createMeeting(room, baseUrl, loginURL, presentationUrl, analyticsCallbackURL, owner?.language || 'en');
const avatarURL = getUserAvatarURL(req, req.user);
const displayName = req.user.display_name || req.user.name;
const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL);
const joinUrl = await joinMeeting(room.uid, displayName, true, avatarURL, !!req.body.dark_mode);
res.json({ joinUrl });
} catch (err) {
log.rooms.error(`Start meeting error: ${err.message}`);
@@ -550,7 +567,7 @@ router.post('/:uid/join', authenticateToken, async (req, res) => {
const isShared = !isOwner && await db.get('SELECT id FROM room_shares WHERE room_id = ? AND user_id = ?', [room.id, req.user.id]);
const isModerator = isOwner || !!isShared || room.all_join_moderator;
const avatarURL = getUserAvatarURL(req, req.user);
const joinUrl = await joinMeeting(room.uid, req.user.display_name || req.user.name, isModerator, avatarURL);
const joinUrl = await joinMeeting(room.uid, req.user.display_name || req.user.name, isModerator, avatarURL, !!req.body.dark_mode);
res.json({ joinUrl });
} catch (err) {
log.rooms.error(`Join meeting error: ${err.message}`);
@@ -626,7 +643,7 @@ router.get('/:uid/public', async (req, res) => {
// POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed)
router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
try {
const { name, access_code, moderator_code, avatar_image, avatar_color } = req.body;
const { name, access_code, moderator_code, avatar_image, avatar_color, dark_mode } = req.body;
if (!name || name.trim().length === 0) {
return res.status(400).json({ error: 'Name is required' });
@@ -662,7 +679,9 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
const analyticsCallbackURL = room.learning_analytics
? `${baseUrl}/api/analytics/callback/${room.uid}?token=${getAnalyticsToken(room.uid)}`
: null;
await createMeeting(room, baseUrl, loginURL, null, analyticsCallbackURL);
// Localise BBB's default welcome message in the room owner's language
const owner = await db.get('SELECT language FROM users WHERE id = ?', [room.user_id]);
await createMeeting(room, baseUrl, loginURL, null, analyticsCallbackURL, owner?.language || 'en');
}
// Check moderator code
@@ -686,7 +705,7 @@ router.post('/:uid/guest-join', guestJoinLimiter, async (req, res) => {
} else {
guestAvatarURL = `${baseUrl}/api/auth/avatar/initials/${encodeURIComponent(name.trim())}`;
}
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL);
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator, guestAvatarURL, !!dark_mode);
res.json({ joinUrl });
} catch (err) {
log.rooms.error(`Guest join error: ${err.message}`);
@@ -735,7 +754,10 @@ router.get('/presentations/:token/:expires/:roomUid/:filename', (req, res) => {
.update(`${roomUid}/${filename}:${expires}`)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(token), Buffer.from(expected))) {
// timingSafeEqual throws on length mismatch, so compare lengths first
const tokenBuf = Buffer.from(token);
const expectedBuf = Buffer.from(expected);
if (tokenBuf.length !== expectedBuf.length || !crypto.timingSafeEqual(tokenBuf, expectedBuf)) {
return res.status(403).json({ error: 'Invalid token' });
}
@@ -863,7 +885,7 @@ router.delete('/:uid/presentation', authenticateToken, async (req, res) => {
});
// ── POST /api/rooms/invite-email — Send email invitation to guest(s) ────────
router.post('/invite-email', authenticateToken, async (req, res) => {
router.post('/invite-email', authenticateToken, inviteEmailLimiter, async (req, res) => {
try {
const { room_uid, emails, message } = req.body;
if (!room_uid || !emails || !emails.length) {
@@ -908,7 +930,7 @@ router.post('/invite-email', authenticateToken, async (req, res) => {
? `${baseUrl}/join/${room.uid}?ac=${encodeURIComponent(room.access_code)}`
: `${baseUrl}/join/${room.uid}`;
const appName = process.env.APP_NAME || 'Redlight';
const appName = await getAppName();
const fromUser = req.user.display_name || req.user.name;
const lang = req.user.language || 'en';
+1 -1
View File
@@ -152,7 +152,7 @@ export default function AnalyticsList({ analytics, onRefresh, isOwner = true })
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<div className="flex items-center gap-1 shrink-0">
<div className="relative">
<button
onClick={() => toggleExportMenu(entry.id)}
+3 -3
View File
@@ -2,9 +2,9 @@ import { Video } from 'lucide-react';
import { useBranding } from '../contexts/BrandingContext';
const sizes = {
sm: { box: 'w-8 h-8', h: 'h-8', maxW: 'max-w-[8rem]', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
md: { box: 'w-9 h-9', h: 'h-12', maxW: 'max-w-[10rem]', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
lg: { box: 'w-10 h-10', h: 'h-10', maxW: 'max-w-[12rem]', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
sm: { box: 'w-8 h-8', h: 'h-8', maxW: 'max-w-32', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
md: { box: 'w-9 h-9', h: 'h-12', maxW: 'max-w-40', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
lg: { box: 'w-10 h-10', h: 'h-10', maxW: 'max-w-48', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
};
export default function BrandLogo({ size = 'md', className = '' }) {
+1 -1
View File
@@ -90,7 +90,7 @@ export default function DateTimePicker({
</label>
)}
<div className="relative">
<Icon size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none z-[1]" />
<Icon size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none z-1" />
<input
ref={inputRef}
type="text"
+22 -8
View File
@@ -30,22 +30,35 @@ export default function FederatedRoomCard({ room, onRemove }) {
const recordingOn = room.allow_recording === 1 || room.allow_recording === true;
const openRoom = () => navigate(`/federation/rooms/${room.id}`);
return (
<div className={`card-hover group p-5 cursor-pointer ${isDeleted ? 'opacity-60' : ''}`} onClick={() => navigate(`/federation/rooms/${room.id}`)}>
<div
role="link"
tabIndex={0}
aria-label={room.room_name}
onClick={openRoom}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRoom(); }
}}
className={`card-hover group p-5 cursor-pointer focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2 ${isDeleted ? 'opacity-60' : ''}`}
style={{ '--tw-ring-offset-color': 'var(--bg-primary)' }}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Globe size={14} className="text-th-accent flex-shrink-0" />
<Globe size={14} className="text-th-accent shrink-0" />
<h3 className="text-base font-semibold text-th-text truncate group-hover:text-th-accent transition-colors">
{room.room_name}
</h3>
{isDeleted ? (
<span className="flex-shrink-0 px-2 py-0.5 bg-red-500/15 text-red-500 rounded-full text-xs font-medium flex items-center gap-1">
<span className="shrink-0 px-2 py-0.5 bg-red-500/15 text-red-500 rounded-full text-xs font-medium flex items-center gap-1">
<AlertTriangle size={10} />
{t('federation.roomDeleted')}
</span>
) : (
<span className="flex-shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
<span className="shrink-0 px-2 py-0.5 bg-th-accent/15 text-th-accent rounded-full text-xs font-medium">
{t('federation.federated')}
</span>
)}
@@ -60,12 +73,12 @@ export default function FederatedRoomCard({ room, onRemove }) {
<div className="grid grid-cols-2 gap-2 mb-4">
{room.meet_id && (
<div className="flex items-center gap-1.5 text-xs text-th-text-s">
<Hash size={12} className="text-th-accent flex-shrink-0" />
<Hash size={12} className="text-th-accent shrink-0" />
<span className="truncate font-mono" title={room.meet_id}>{room.meet_id.slice(0, 10)}…</span>
</div>
)}
<div className="flex items-center gap-1.5 text-xs text-th-text-s">
<Users size={12} className="text-th-accent flex-shrink-0" />
<Users size={12} className="text-th-accent shrink-0" />
<span>
{t('federation.maxParticipants')}:{' '}
<span className="text-th-text font-medium">
@@ -76,12 +89,12 @@ export default function FederatedRoomCard({ room, onRemove }) {
<div className="flex items-center gap-1.5 text-xs col-span-2">
{recordingOn ? (
<>
<Video size={12} className="text-amber-500 flex-shrink-0" />
<Video size={12} className="text-amber-500 shrink-0" />
<span className="text-amber-500 font-medium">{t('federation.recordingOn')}</span>
</>
) : (
<>
<VideoOff size={12} className="text-th-text-s flex-shrink-0" />
<VideoOff size={12} className="text-th-text-s shrink-0" />
<span className="text-th-text-s">{t('federation.recordingOff')}</span>
</>
)}
@@ -107,6 +120,7 @@ export default function FederatedRoomCard({ room, onRemove }) {
<button
onClick={handleRemove}
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
aria-label={t('federation.removeRoom')}
title={t('federation.removeRoom')}
>
<Trash2 size={14} />
+1 -1
View File
@@ -52,7 +52,7 @@ export default function Layout() {
{/* Email verification banner */}
{user && user.email_verified === 0 && (
<div className="bg-amber-500/15 border-b border-amber-500/30 px-4 py-2.5 flex items-center justify-center gap-3 text-sm">
<AlertTriangle size={15} className="text-amber-400 flex-shrink-0" />
<AlertTriangle size={15} className="text-amber-400 shrink-0" />
<span className="text-amber-200">{t('auth.emailVerificationBanner')}</span>
<button
onClick={handleResendVerification}
+44 -3
View File
@@ -1,15 +1,56 @@
import { useEffect, useId, useRef } from 'react';
import { X } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext';
export default function Modal({ title, children, onClose, maxWidth = 'max-w-lg' }) {
const { t } = useLanguage();
const titleId = useId();
const dialogRef = useRef(null);
const previouslyFocused = useRef(null);
// Keep the latest onClose in a ref so the mount-only effect below can call it
// without listing onClose as a dependency. Callers pass a fresh inline arrow
// each render; depending on it would re-run the effect on every keystroke and
// steal focus back to the dialog (kicking the user out of input fields).
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
useEffect(() => {
previouslyFocused.current = document.activeElement;
const handleKey = (e) => {
if (e.key === 'Escape') onCloseRef.current?.();
};
document.addEventListener('keydown', handleKey);
// Focus the dialog so screen readers announce it and keyboard focus lands inside
dialogRef.current?.focus();
return () => {
document.removeEventListener('keydown', handleKey);
previouslyFocused.current?.focus?.();
};
}, []);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className={`relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full ${maxWidth}`}>
<div
className="fixed inset-0 bg-black/60 backdrop-blur-xs"
onClick={onClose}
aria-hidden="true"
/>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className={`relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full ${maxWidth} focus:outline-hidden`}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border rounded-t-2xl">
<h2 className="text-lg font-semibold text-th-text">{title}</h2>
<h2 id={titleId} className="text-lg font-semibold text-th-text">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label={t('common.close')}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
>
<X size={20} />
+6 -2
View File
@@ -38,12 +38,13 @@ export default function Navbar({ onMenuClick }) {
: '?';
return (
<header className="sticky top-0 z-20 bg-th-nav border-b border-th-border backdrop-blur-sm">
<header className="sticky top-0 z-20 bg-th-nav border-b border-th-border backdrop-blur-xs">
<div className="flex items-center justify-between h-16 px-4 md:px-6">
{/* Left section */}
<div className="flex items-center gap-3">
<button
onClick={onMenuClick}
aria-label={t('nav.navigation')}
className="lg:hidden p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
>
<Menu size={20} />
@@ -59,6 +60,9 @@ export default function Navbar({ onMenuClick }) {
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setDropdownOpen(!dropdownOpen)}
aria-label={user?.display_name || user?.name}
aria-haspopup="menu"
aria-expanded={dropdownOpen}
className="flex items-center gap-2 p-1.5 rounded-lg hover:bg-th-hover transition-colors"
>
<div
@@ -68,7 +72,7 @@ export default function Navbar({ onMenuClick }) {
{user?.avatar_image ? (
<img
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
alt="Avatar"
alt=""
className="w-full h-full object-cover"
/>
) : (
+9 -3
View File
@@ -83,6 +83,11 @@ export default function NotificationBell() {
<button
onClick={() => setOpen(prev => !prev)}
className="relative p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
aria-label={unreadCount > 0
? `${t('notifications.bell')} (${unreadCount})`
: t('notifications.bell')}
aria-haspopup="true"
aria-expanded={open}
title={t('notifications.bell')}
>
<Bell size={20} />
@@ -148,7 +153,7 @@ export default function NotificationBell() {
${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>
<span className="text-lg shrink-0 mt-0.5">{notificationIcon(n.type)}</span>
{/* Content */}
<div className="flex-1 min-w-0">
@@ -164,7 +169,7 @@ export default function NotificationBell() {
</div>
{/* Right side: unread dot, link icon, delete button */}
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<div className="flex flex-col items-end gap-1 shrink-0">
{!n.read && (
<span className="w-2 h-2 rounded-full bg-th-accent mt-1" />
)}
@@ -173,7 +178,8 @@ export default function NotificationBell() {
)}
<button
onClick={(e) => handleDelete(e, n.id)}
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:text-th-error transition-all text-th-text-s/50"
className="opacity-0 group-hover:opacity-100 p-0.5 rounded-sm hover:text-th-error transition-all text-th-text-s/50"
aria-label={t('notifications.delete')}
title={t('notifications.delete')}
>
<X size={13} />
+1 -1
View File
@@ -133,7 +133,7 @@ export default function RecordingList({ recordings, onRefresh }) {
</div>
{/* Actions */}
<div className="flex items-center gap-1 flex-shrink-0">
<div className="flex items-center gap-1 shrink-0">
<button
onClick={() => handlePublish(rec.recordID, !rec.published)}
disabled={loading[rec.recordID] === 'publishing'}
+23 -4
View File
@@ -2,6 +2,7 @@ import { Users, Play, Trash2, Radio, Loader2, Share2, Copy, Link } from 'lucide-
import { useNavigate } from 'react-router-dom';
import { useState, useEffect, useRef } from 'react';
import api from '../services/api';
import { isCurrentThemeDark } from '../themes';
import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast';
@@ -43,8 +44,21 @@ export default function RoomCard({ room, onDelete }) {
return () => clearInterval(interval);
}, [room.uid]);
const openRoom = () => navigate(`/rooms/${room.uid}`);
return (
<div className="card-hover group p-5 cursor-pointer" onClick={() => navigate(`/rooms/${room.uid}`)}>
<div
role="link"
tabIndex={0}
aria-label={room.name}
onClick={openRoom}
onKeyDown={(e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRoom(); }
}}
className="card-hover group p-5 cursor-pointer focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2"
style={{ '--tw-ring-offset-color': 'var(--bg-primary)' }}
>
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
@@ -80,7 +94,7 @@ export default function RoomCard({ room, onDelete }) {
<span>Max: {room.max_participants}</span>
)}
{room.access_code && (
<span className="px-1.5 py-0.5 bg-th-warning/15 text-th-warning rounded text-[10px] font-medium">
<span className="px-1.5 py-0.5 bg-th-warning/15 text-th-warning rounded-sm text-[10px] font-medium">
{t('common.protected')}
</span>
)}
@@ -94,11 +108,12 @@ export default function RoomCard({ room, onDelete }) {
setStarting(true);
try {
if (status.running) {
const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {};
const data = { dark_mode: isCurrentThemeDark() };
if (room.access_code) data.access_code = prompt(t('room.enterAccessCode'));
const res = await api.post(`/rooms/${room.uid}/join`, data);
if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank');
} else {
const res = await api.post(`/rooms/${room.uid}/start`);
const res = await api.post(`/rooms/${room.uid}/start`, { dark_mode: isCurrentThemeDark() });
if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank');
toast.success(t('room.meetingStarted'));
setTimeout(() => {
@@ -121,6 +136,9 @@ export default function RoomCard({ room, onDelete }) {
<button
onClick={(e) => { e.stopPropagation(); setShowCopyMenu(v => !v); }}
className="btn-ghost text-xs py-1.5 px-2"
aria-label={t('room.copyLink')}
aria-haspopup="menu"
aria-expanded={showCopyMenu}
title={t('room.copyLink')}
>
<Copy size={14} />
@@ -148,6 +166,7 @@ export default function RoomCard({ room, onDelete }) {
<button
onClick={(e) => { e.stopPropagation(); onDelete(room); }}
className="btn-ghost text-xs py-1.5 px-2 text-th-error hover:text-th-error"
aria-label={t('common.delete')}
title={t('common.delete')}
>
<Trash2 size={14} />
+4 -3
View File
@@ -43,7 +43,7 @@ export default function Sidebar({ open, onClose }) {
const linkClasses = ({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 ${isActive
? 'bg-th-accent text-th-accent-t shadow-sm'
? 'bg-th-accent text-th-accent-t shadow-xs'
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
}`;
@@ -60,6 +60,7 @@ export default function Sidebar({ open, onClose }) {
<BrandLogo size="md" className="flex-1 min-w-0" />
<button
onClick={onClose}
aria-label={t('common.close')}
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
>
<X size={18} />
@@ -106,13 +107,13 @@ export default function Sidebar({ open, onClose }) {
<div className="p-4 border-t border-th-border">
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0 overflow-hidden"
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold shrink-0 overflow-hidden"
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
>
{user?.avatar_image ? (
<img
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
alt="Avatar"
alt=""
className="w-full h-full object-cover"
/>
) : (
+9 -3
View File
@@ -10,17 +10,23 @@ export default function ThemeSelector({ onClose }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={onClose} aria-hidden="true" />
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden">
<div
role="dialog"
aria-modal="true"
aria-labelledby="theme-selector-title"
className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border">
<div>
<h2 className="text-lg font-semibold text-th-text">{t('themes.selectTheme')}</h2>
<h2 id="theme-selector-title" className="text-lg font-semibold text-th-text">{t('themes.selectTheme')}</h2>
<p className="text-sm text-th-text-s mt-0.5">{t('themes.selectThemeSubtitle')}</p>
</div>
<button
onClick={onClose}
aria-label={t('common.close')}
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
>
<X size={20} />
+36 -50
View File
@@ -23,7 +23,9 @@
"protected": "Geschützt",
"live": "Live",
"error": "Fehler",
"success": "Erfolg"
"success": "Erfolg",
"gridView": "Rasteransicht",
"listView": "Listenansicht"
},
"nav": {
"dashboard": "Dashboard",
@@ -62,16 +64,16 @@
"passwordMismatch": "Passwörter stimmen nicht überein",
"passwordTooShort": "Passwort muss mindestens 6 Zeichen lang sein",
"loginSuccess": "Willkommen zurück!",
"registerSuccess": "Registrierung erfolgreich!",
"registerSuccess": "Registrierung erfolgreich",
"loginFailed": "Anmeldung fehlgeschlagen",
"registerFailed": "Registrierung fehlgeschlagen",
"allFieldsRequired": "Alle Felder sind erforderlich",
"verificationSent": "Verifizierungs-E-Mail wurde gesendet!",
"verificationSent": "Verifizierungs-E-Mail wurde gesendet",
"verificationSentDesc": "Wir haben dir eine E-Mail mit einem Bestätigungslink geschickt. Bitte klicke auf den Link, um dein Konto zu aktivieren.",
"checkYourEmail": "Prüfe dein Postfach",
"verifying": "E-Mail wird verifiziert...",
"verifySuccess": "Deine E-Mail-Adresse wurde erfolgreich bestätigt. Du kannst dich jetzt anmelden.",
"verifySuccessTitle": "E-Mail bestätigt!",
"verifySuccessTitle": "E-Mail bestätigt",
"verifyFailed": "Verifizierung fehlgeschlagen",
"verifyFailedTitle": "Verifizierung fehlgeschlagen",
"verifyTokenMissing": "Kein Verifizierungstoken vorhanden.",
@@ -88,7 +90,7 @@
"emailVerificationBanner": "Deine E-Mail-Adresse wurde noch nicht verifiziert.",
"emailVerificationResend": "Hier klicken um eine neue Verifizierungsmail zu erhalten",
"emailVerificationResendCooldown": "Erneut senden in {seconds}s",
"emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet!",
"emailVerificationResendSuccess": "Verifizierungsmail wurde gesendet",
"emailVerificationResendFailed": "Verifizierungsmail konnte nicht gesendet werden",
"inviteOnly": "Nur mit Einladung",
"inviteOnlyDesc": "Die Registrierung ist derzeit eingeschränkt. Sie benötigen einen Einladungslink von einem Administrator, um ein Konto zu erstellen.",
@@ -110,38 +112,22 @@
}
},
"home": {
"madeFor": "Made for BigBlueButton",
"heroTitle": "Meetings neu ",
"heroTitleHighlight": "definiert",
"heroSubtitle": "Das moderne, selbst gehostete BigBlueButton-Frontend. Erstellen Sie Räume, verwalten Sie Aufnahmen, verbinden Sie sich mit anderen Instanzen und genießen Sie ein wunderschönes Interface mit über 15 Themes.",
"getStarted": "Jetzt starten",
"features": "Alles was Sie brauchen",
"featuresSubtitle": "Redlight bietet alle Funktionen, die Sie für professionelle Videokonferenzen benötigen.",
"featureVideoTitle": "Videokonferenzen",
"featureVideoDesc": "Erstellen und verwalten Sie Meetings direkt über BigBlueButton.",
"heroTitle": "Selbst gehostetes Frontend für BigBlueButton",
"heroSubtitle": "Redlight verwaltet Räume, Aufnahmen und Termine für deinen BigBlueButton-Server – mit Federation zwischen Instanzen, CalDAV-Sync und SSO-Anbindung.",
"getStarted": "Konto erstellen",
"features": "Funktionen",
"featureRoomsTitle": "Raumverwaltung",
"featureRoomsDesc": "Unbegrenzte Räume mit individuellen Einstellungen und Zugangscodes.",
"featureRoomsDesc": "Räume mit eigenen Einstellungen, Zugangscodes und geteiltem Zugriff für Kolleginnen und Kollegen.",
"featureRecordingsTitle": "Aufnahmen",
"featureRecordingsDesc": "Alle Aufnahmen pro Raum einsehen, veröffentlichen oder löschen.",
"featureRecordingsDesc": "Aufnahmen pro Raum einsehen, veröffentlichen oder löschen.",
"featureFederationTitle": "Federation",
"featureFederationDesc": "Laden Sie Nutzer anderer Redlight-Instanzen in Ihre Räume ein und nehmen Sie instanzübergreifende Einladungen an.",
"featureCalendarTitle": "Kalender & Erinnerungen",
"featureCalendarDesc": "Meetings planen, per CalDAV synchronisieren und E-Mail-Erinnerungen vor dem Start erhalten.",
"featureNotificationsTitle": "Benachrichtigungen",
"featureNotificationsDesc": "Echtzeit-Benachrichtigungen in der App und per E-Mail für Einladungen und bevorstehende Meetings.",
"featureOAuthTitle": "OAuth / SSO",
"featureOAuthDesc": "Melden Sie sich über OAuth 2.0 mit Ihrem bestehenden Identity Provider an – kein separates Konto nötig.",
"featureAnalyticsTitle": "Analytics",
"featureAnalyticsDesc": "Meeting-Statistiken einsehen und die Nutzung über alle Räume hinweg verfolgen.",
"featureThemesTitle": "15+ Themes",
"featureThemesDesc": "Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox und viele mehr – plus eigenes Branding.",
"featureUsersTitle": "Benutzerverwaltung",
"featureUsersDesc": "Registrierung, Login und Rollenverwaltung für Ihre Organisation.",
"featureOpenSourceTitle": "Open Source",
"featureOpenSourceDesc": "Vollständig quelloffen und selbst gehostet. Ihre Daten bleiben bei Ihnen.",
"statThemes": "Themes",
"statRooms": "Räume",
"statOpenSource": "Open Source",
"featureFederationDesc": "Nutzer anderer Redlight-Instanzen einladen und instanzübergreifende Einladungen annehmen.",
"featureCalendarTitle": "Kalender",
"featureCalendarDesc": "Meetings planen, per CalDAV mit Thunderbird, Apple Calendar oder DAVx⁵ synchronisieren, Erinnerungen erhalten.",
"featureVideoTitle": "Videokonferenzen",
"featureVideoDesc": "Meetings direkt über BigBlueButton erstellen, starten und verwalten.",
"featureAnalyticsTitle": "Learning Analytics",
"featureAnalyticsDesc": "Teilnahme- und Aktivitätsdaten aus Meetings einsehen und als CSV, Excel oder PDF exportieren.",
"footer": "© {year} Redlight. Ein Open-Source BigBlueButton Frontend."
},
"dashboard": {
@@ -162,7 +148,7 @@
"accessCode": "Zugangscode",
"muteOnJoin": "Teilnehmer beim Beitritt stummschalten",
"allowRecording": "Aufnahme erlauben",
"roomCreated": "Raum erstellt!",
"roomCreated": "Raum erstellt",
"roomCreateFailed": "Raum konnte nicht erstellt werden",
"roomDeleted": "Raum gelöscht",
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
@@ -186,7 +172,7 @@
"copyLink": "Link kopieren",
"copyRoomLink": "Raum-Link",
"copyGuestLink": "Gast-Link",
"linkCopied": "Link kopiert!",
"linkCopied": "Link kopiert",
"meetingDetails": "Meeting-Details",
"meetingId": "Meeting ID",
"status": "Status",
@@ -211,7 +197,7 @@
"emptyNoCode": "Leer = kein Code",
"settingsSaved": "Einstellungen gespeichert",
"settingsSaveFailed": "Einstellungen konnten nicht gespeichert werden",
"meetingStarted": "Meeting gestartet!",
"meetingStarted": "Meeting gestartet",
"meetingStartFailed": "Meeting konnte nicht gestartet werden",
"meetingEnded": "Meeting beendet",
"meetingEndFailed": "Meeting konnte nicht beendet werden",
@@ -226,7 +212,7 @@
"moderatorCodeHint": "Optionaler Code für Moderator-Rechte",
"moderatorCodeDesc": "Gäste, die diesen Code eingeben, erhalten Moderator-Rechte.",
"guestLink": "Gast-Einladungslink",
"guestLinkCopied": "Gast-Link kopiert!",
"guestLinkCopied": "Gast-Link kopiert",
"guestJoinTitle": "Meeting beitreten",
"guestCreatedBy": "Erstellt von",
"guestMeetingRunning": "Meeting läuft",
@@ -361,7 +347,7 @@
"statusDisabledDesc": "Aktiviere die Zwei-Faktor-Authentifizierung für zusätzliche Sicherheit.",
"enable": "2FA aktivieren",
"disable": "2FA deaktivieren",
"enabled": "Zwei-Faktor-Authentifizierung aktiviert!",
"enabled": "Zwei-Faktor-Authentifizierung aktiviert",
"disabled": "Zwei-Faktor-Authentifizierung deaktiviert.",
"enableFailed": "2FA konnte nicht aktiviert werden",
"disableFailed": "2FA konnte nicht deaktiviert werden",
@@ -390,7 +376,7 @@
"revoked": "Token widerrufen",
"revokeFailed": "Token konnte nicht widerrufen werden",
"createFailed": "Token konnte nicht erstellt werden",
"newTokenCreated": "Token erstellt — jetzt kopieren!",
"newTokenCreated": "Token erstellt — jetzt kopieren",
"newTokenHint": "Dieses Token wird nur einmal angezeigt. Kopiere es und trage es als Passwort in deiner Kalender-App ein.",
"dismiss": "Ich habe das Token kopiert"
}
@@ -458,11 +444,11 @@
"inviteTitle": "Benutzer-Einladungen",
"inviteDescription": "Laden Sie neue Benutzer per E-Mail ein. Sie erhalten einen Registrierungslink, der 7 Tage gültig ist.",
"sendInvite": "Einladung senden",
"inviteSent": "Einladung gesendet!",
"inviteSent": "Einladung gesendet",
"inviteFailed": "Einladung konnte nicht gesendet werden",
"inviteDeleted": "Einladung gelöscht",
"inviteDeleteFailed": "Einladung konnte nicht gelöscht werden",
"inviteLinkCopied": "Einladungslink kopiert!",
"inviteLinkCopied": "Einladungslink kopiert",
"copyInviteLink": "Einladungslink kopieren",
"inviteExpired": "Abgelaufen",
"inviteUsedBy": "Verwendet von",
@@ -534,8 +520,8 @@
"messageLabel": "Nachricht (optional)",
"messagePlaceholder": "Hallo, ich lade dich zu unserem Meeting ein!",
"send": "Einladung senden",
"sent": "Einladung gesendet!",
"emailSent": "E-Mail-Einladung(en) gesendet!",
"sent": "Einladung gesendet",
"emailSent": "E-Mail-Einladung(en) gesendet",
"emailSendFailed": "E-Mail-Einladung konnte nicht gesendet werden",
"sendFailed": "Einladung konnte nicht gesendet werden",
"from": "Von",
@@ -561,7 +547,7 @@
"removeRoomConfirm": "Raum wirklich entfernen?",
"roomRemoved": "Raum entfernt",
"roomRemoveFailed": "Raum konnte nicht entfernt werden",
"acceptedSaved": "Einladung angenommen - Raum wurde in deinem Dashboard gespeichert!",
"acceptedSaved": "Einladung angenommen - Raum wurde in deinem Dashboard gespeichert",
"meetingId": "Meeting ID",
"maxParticipants": "Max. Teilnehmer",
"recordingOn": "Aufnahme aktiviert",
@@ -577,9 +563,9 @@
"roomDeleted": "Gelöscht",
"roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar.",
"calendarEvent": "Kalendereinladung",
"calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen!",
"calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen",
"localCalendarEvent": "Lokale Kalendereinladung",
"calendarLocalAccepted": "Einladung angenommen - Event wurde in deinen Kalender eingetragen!",
"calendarLocalAccepted": "Einladung angenommen - Event wurde in deinen Kalender eingetragen",
"invitationRemoved": "Einladung entfernt",
"removeInvitation": "Einladung entfernen"
},
@@ -608,8 +594,8 @@
"reminder1440": "1 Tag vorher",
"timezone": "Zeitzone",
"color": "Farbe",
"eventCreated": "Event erstellt!",
"eventUpdated": "Event aktualisiert!",
"eventCreated": "Event erstellt",
"eventUpdated": "Event aktualisiert",
"eventDeleted": "Event gelöscht",
"saveFailed": "Event konnte nicht gespeichert werden",
"deleteFailed": "Event konnte nicht gelöscht werden",
@@ -636,7 +622,7 @@
"shareAdded": "Benutzer zum Event hinzugefügt",
"shareRemoved": "Freigabe entfernt",
"shareFailed": "Event konnte nicht geteilt werden",
"invitationSent": "Einladung gesendet!",
"invitationSent": "Einladung gesendet",
"invitationCancelled": "Einladung widerrufen",
"invitationPending": "Einladung ausstehend",
"pendingInvitations": "Ausstehende Einladungen",
+37 -51
View File
@@ -23,7 +23,9 @@
"protected": "Protected",
"live": "Live",
"error": "Error",
"success": "Success"
"success": "Success",
"gridView": "Grid view",
"listView": "List view"
},
"nav": {
"dashboard": "Dashboard",
@@ -62,16 +64,16 @@
"passwordMismatch": "Passwords do not match",
"passwordTooShort": "Password must be at least 6 characters",
"loginSuccess": "Welcome back!",
"registerSuccess": "Registration successful!",
"registerSuccess": "Registration successful",
"loginFailed": "Login failed",
"registerFailed": "Registration failed",
"allFieldsRequired": "All fields are required",
"verificationSent": "Verification email sent!",
"verificationSent": "Verification email sent",
"verificationSentDesc": "We've sent you an email with a verification link. Please click the link to activate your account.",
"checkYourEmail": "Check your inbox",
"verifying": "Verifying your email...",
"verifySuccess": "Your email has been verified successfully. You can now sign in.",
"verifySuccessTitle": "Email verified!",
"verifySuccessTitle": "Email verified",
"verifyFailed": "Verification failed",
"verifyFailedTitle": "Verification failed",
"verifyTokenMissing": "No verification token provided.",
@@ -88,7 +90,7 @@
"emailVerificationBanner": "Your email address has not been verified yet.",
"emailVerificationResend": "Click here to receive a new verification email",
"emailVerificationResendCooldown": "Resend in {seconds}s",
"emailVerificationResendSuccess": "Verification email sent!",
"emailVerificationResendSuccess": "Verification email sent",
"emailVerificationResendFailed": "Could not send verification email",
"inviteOnly": "Invite Only",
"inviteOnlyDesc": "Registration is currently restricted. You need an invitation link from an administrator to create an account.",
@@ -110,38 +112,22 @@
}
},
"home": {
"madeFor": "Made for BigBlueButton",
"heroTitle": "Meetings re",
"heroTitleHighlight": "defined",
"heroSubtitle": "The modern, self-hosted BigBlueButton frontend. Create rooms, manage recordings, federate with other instances and enjoy a beautiful interface with over 15 themes.",
"getStarted": "Get started",
"features": "Everything you need",
"featuresSubtitle": "Redlight provides all the features you need for professional video conferencing.",
"featureVideoTitle": "Video Conferencing",
"featureVideoDesc": "Create and manage meetings directly via BigBlueButton.",
"featureRoomsTitle": "Room Management",
"featureRoomsDesc": "Unlimited rooms with individual settings and access codes.",
"heroTitle": "Self-hosted frontend for BigBlueButton",
"heroSubtitle": "Redlight manages rooms, recordings and scheduling for your BigBlueButton server – with federation between instances, CalDAV sync and SSO support.",
"getStarted": "Create account",
"features": "Features",
"featureRoomsTitle": "Room management",
"featureRoomsDesc": "Rooms with individual settings, access codes and shared access for colleagues.",
"featureRecordingsTitle": "Recordings",
"featureRecordingsDesc": "View, publish or delete all recordings per room.",
"featureRecordingsDesc": "View, publish or delete recordings per room.",
"featureFederationTitle": "Federation",
"featureFederationDesc": "Invite users from other Redlight instances into your rooms and accept cross-instance meeting invitations.",
"featureCalendarTitle": "Calendar & Reminders",
"featureCalendarDesc": "Schedule meetings with your rooms, sync via CalDAV and receive email reminders before they start.",
"featureNotificationsTitle": "Notifications",
"featureNotificationsDesc": "Real-time in-app and email notifications for room invitations and upcoming meetings.",
"featureOAuthTitle": "OAuth / SSO",
"featureOAuthDesc": "Sign in with your existing identity provider via OAuth 2.0 — no separate account needed.",
"featureAnalyticsTitle": "Analytics",
"featureAnalyticsDesc": "Track meeting statistics and monitor usage across all your rooms.",
"featureThemesTitle": "15+ Themes",
"featureThemesDesc": "Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox and many more — plus custom branding.",
"featureUsersTitle": "User Management",
"featureUsersDesc": "Registration, login and role management for your organization.",
"featureOpenSourceTitle": "Open Source",
"featureOpenSourceDesc": "Fully open source and self-hosted. Your data stays with you.",
"statThemes": "Themes",
"statRooms": "Rooms",
"statOpenSource": "Open Source",
"featureFederationDesc": "Invite users from other Redlight instances and accept cross-instance invitations.",
"featureCalendarTitle": "Calendar",
"featureCalendarDesc": "Schedule meetings, sync via CalDAV with Thunderbird, Apple Calendar or DAVx⁵, get reminders.",
"featureVideoTitle": "Video conferencing",
"featureVideoDesc": "Create, start and manage meetings directly via BigBlueButton.",
"featureAnalyticsTitle": "Learning analytics",
"featureAnalyticsDesc": "Review attendance and activity data from meetings and export it as CSV, Excel or PDF.",
"footer": "© {year} Redlight. An open source BigBlueButton frontend."
},
"dashboard": {
@@ -162,7 +148,7 @@
"accessCode": "Access code",
"muteOnJoin": "Mute participants on join",
"allowRecording": "Allow recording",
"roomCreated": "Room created!",
"roomCreated": "Room created",
"roomCreateFailed": "Room could not be created",
"roomDeleted": "Room deleted",
"roomDeleteFailed": "Room could not be deleted",
@@ -186,7 +172,7 @@
"copyLink": "Copy link",
"copyRoomLink": "Room Link",
"copyGuestLink": "Guest Link",
"linkCopied": "Link copied!",
"linkCopied": "Link copied",
"meetingDetails": "Meeting details",
"meetingId": "Meeting ID",
"status": "Status",
@@ -211,7 +197,7 @@
"emptyNoCode": "Empty = no code",
"settingsSaved": "Settings saved",
"settingsSaveFailed": "Settings could not be saved",
"meetingStarted": "Meeting started!",
"meetingStarted": "Meeting started",
"meetingStartFailed": "Meeting could not be started",
"meetingEnded": "Meeting ended",
"meetingEndFailed": "Meeting could not be ended",
@@ -226,7 +212,7 @@
"moderatorCodeHint": "Optional code for moderator rights",
"moderatorCodeDesc": "Guests who enter this code will receive moderator rights.",
"guestLink": "Guest Invite Link",
"guestLinkCopied": "Guest link copied!",
"guestLinkCopied": "Guest link copied",
"guestJoinTitle": "Join Meeting",
"guestCreatedBy": "Created by",
"guestMeetingRunning": "Meeting in progress",
@@ -361,7 +347,7 @@
"statusDisabledDesc": "Enable two-factor authentication for an extra layer of security.",
"enable": "Enable 2FA",
"disable": "Disable 2FA",
"enabled": "Two-factor authentication enabled!",
"enabled": "Two-factor authentication enabled",
"disabled": "Two-factor authentication disabled.",
"enableFailed": "Could not enable 2FA",
"disableFailed": "Could not disable 2FA",
@@ -390,7 +376,7 @@
"revoked": "Token revoked",
"revokeFailed": "Could not revoke token",
"createFailed": "Could not create token",
"newTokenCreated": "Token created — copy it now!",
"newTokenCreated": "Token created — copy it now",
"newTokenHint": "This token will only be shown once. Copy it and enter it as the password in your calendar app.",
"dismiss": "I have copied the token"
}
@@ -458,11 +444,11 @@
"inviteTitle": "User Invitations",
"inviteDescription": "Invite new users by email. They will receive a registration link valid for 7 days.",
"sendInvite": "Send invite",
"inviteSent": "Invitation sent!",
"inviteSent": "Invitation sent",
"inviteFailed": "Could not send invitation",
"inviteDeleted": "Invitation deleted",
"inviteDeleteFailed": "Could not delete invitation",
"inviteLinkCopied": "Invite link copied!",
"inviteLinkCopied": "Invite link copied",
"copyInviteLink": "Copy invite link",
"inviteExpired": "Expired",
"inviteUsedBy": "Used by",
@@ -534,8 +520,8 @@
"messageLabel": "Message (optional)",
"messagePlaceholder": "Hi, I'd like to invite you to our meeting!",
"send": "Send invitation",
"sent": "Invitation sent!",
"emailSent": "Email invitation(s) sent!",
"sent": "Invitation sent",
"emailSent": "Email invitation(s) sent",
"emailSendFailed": "Could not send email invitation",
"sendFailed": "Could not send invitation",
"from": "From",
@@ -561,7 +547,7 @@
"removeRoomConfirm": "Really remove this room?",
"roomRemoved": "Room removed",
"roomRemoveFailed": "Could not remove room",
"acceptedSaved": "Invitation accepted - room saved to your dashboard!",
"acceptedSaved": "Invitation accepted - room saved to your dashboard",
"meetingId": "Meeting ID",
"maxParticipants": "Max. participants",
"recordingOn": "Recording enabled",
@@ -577,9 +563,9 @@
"roomDeleted": "Deleted",
"roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available.",
"calendarEvent": "Calendar Invitation",
"calendarAccepted": "Calendar event accepted and added to your calendar!",
"calendarAccepted": "Calendar event accepted and added to your calendar",
"localCalendarEvent": "Local Calendar Invitation",
"calendarLocalAccepted": "Invitation accepted - event added to your calendar!",
"calendarLocalAccepted": "Invitation accepted - event added to your calendar",
"invitationRemoved": "Invitation removed",
"removeInvitation": "Remove invitation"
},
@@ -608,8 +594,8 @@
"reminder1440": "1 day before",
"timezone": "Timezone",
"color": "Color",
"eventCreated": "Event created!",
"eventUpdated": "Event updated!",
"eventCreated": "Event created",
"eventUpdated": "Event updated",
"eventDeleted": "Event deleted",
"saveFailed": "Could not save event",
"deleteFailed": "Could not delete event",
@@ -636,7 +622,7 @@
"shareAdded": "User added to event",
"shareRemoved": "Share removed",
"shareFailed": "Could not share event",
"invitationSent": "Invitation sent!",
"invitationSent": "Invitation sent",
"invitationCancelled": "Invitation cancelled",
"invitationPending": "Invitation pending",
"pendingInvitations": "Pending Invitations",
+140 -84
View File
@@ -1,6 +1,132 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import 'tailwindcss';
@theme {
--color-th-bg: var(--bg-primary);
--color-th-bg-s: var(--bg-secondary);
--color-th-bg-t: var(--bg-tertiary);
--color-th-text: var(--text-primary);
--color-th-text-s: var(--text-secondary);
--color-th-accent: var(--accent);
--color-th-accent-h: var(--accent-hover);
--color-th-accent-t: var(--accent-text);
--color-th-border: var(--border);
--color-th-card: var(--card-bg);
--color-th-input: var(--input-bg);
--color-th-input-b: var(--input-border);
--color-th-nav: var(--nav-bg);
--color-th-side: var(--sidebar-bg);
--color-th-hover: var(--hover-bg);
--color-th-success: var(--success);
--color-th-warning: var(--warning);
--color-th-error: var(--error);
--color-th-ring: var(--ring);
--font-sans: Inter, system-ui, -apple-system, sans-serif;
--shadow-th:
0 1px 3px 0 var(--shadow-color), 0 1px 2px -1px var(--shadow-color);
--shadow-th-lg:
0 10px 15px -3px var(--shadow-color), 0 4px 6px -4px var(--shadow-color);
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
/* Restore Tailwind v3 default: buttons get a pointer cursor */
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]) {
cursor: pointer;
}
}
@utility btn-primary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-accent text-th-accent-t hover:bg-th-accent-h
transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility btn-secondary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-bg-s text-th-text border border-th-border
hover:bg-th-hover transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility btn-danger {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-error text-white hover:opacity-90
transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-error focus:ring-offset-2
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility btn-ghost {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
text-th-text-s hover:bg-th-hover hover:text-th-text
transition-all duration-200 ease-out
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-2
cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
@utility input-field {
@apply w-full px-4 py-2.5 rounded-lg
bg-th-input text-th-text placeholder-th-text-s
border border-th-input-b
focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:border-transparent
transition-all duration-200;
}
@utility card {
@apply bg-th-card rounded-xl border border-th-border
shadow-th transition-all duration-200;
}
@utility card-hover {
@apply card hover:shadow-th-lg cursor-pointer;
&:hover {
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}
}
@utility gradient-text {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-end)
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
@utility gradient-bg {
background: linear-gradient(
135deg,
var(--gradient-start),
var(--gradient-end)
);
}
@layer base {
/* ===== DEFAULT LIGHT ===== */
@@ -444,8 +570,9 @@
}
}
@layer utilities {
/* ===== SCRUNKLY.CAT DARK ===== */
[data-theme="scrunkly-cat"] {
[data-theme='scrunkly-cat'] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #161924;
@@ -472,8 +599,8 @@
--gradient-end: #d6336a;
}
/* ===== RED MODULAR LIGHT ===== */
[data-theme="red-modular-light"] {
/* ===== RED MODULAR LIGHT ===== */
[data-theme='red-modular-light'] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #ffffff;
@@ -501,7 +628,7 @@
}
/* ===== EVERFOREST DARK ===== */
[data-theme="everforest-dark"] {
[data-theme='everforest-dark'] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #2d353b;
@@ -529,7 +656,7 @@
}
/* ===== EVERFOREST LIGHT ===== */
[data-theme="everforest-light"] {
[data-theme='everforest-light'] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #fdf6e3;
@@ -557,7 +684,7 @@
}
/* ===== KANAGAWA ===== */
[data-theme="kanagawa"] {
[data-theme='kanagawa'] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #1f1f28;
@@ -585,7 +712,7 @@
}
/* ===== AYU DARK ===== */
[data-theme="ayu-dark"] {
[data-theme='ayu-dark'] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #0d1017;
@@ -613,7 +740,7 @@
}
/* ===== MOONLIGHT ===== */
[data-theme="moonlight"] {
[data-theme='moonlight'] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #212337;
@@ -641,7 +768,7 @@
}
/* ===== CYBERPUNK ===== */
[data-theme="cyberpunk"] {
[data-theme='cyberpunk'] {
color-scheme: dark;
--picker-icon-filter: invert(0.8);
--bg-primary: #0a0a0f;
@@ -669,7 +796,7 @@
}
/* ===== COTTON CANDY LIGHT ===== */
[data-theme="cotton-candy-light"] {
[data-theme='cotton-candy-light'] {
color-scheme: light;
--picker-icon-filter: none;
--bg-primary: #fff5f9;
@@ -695,77 +822,6 @@
--gradient-start: #ff85a2;
--gradient-end: #c084fc;
}
@layer components {
.btn-primary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-accent text-th-accent-t hover:bg-th-accent-h
transition-all duration-200 ease-out
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
.btn-secondary {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-bg-s text-th-text border border-th-border
hover:bg-th-hover transition-all duration-200 ease-out
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
.btn-danger {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
bg-th-error text-white hover:opacity-90
transition-all duration-200 ease-out
focus:outline-none focus:ring-2 focus:ring-th-error focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
.btn-ghost {
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium
text-th-text-s hover:bg-th-hover hover:text-th-text
transition-all duration-200 ease-out
focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
--tw-ring-offset-color: var(--bg-primary);
}
.input-field {
@apply w-full px-4 py-2.5 rounded-lg
bg-th-input text-th-text placeholder-th-text-s
border border-th-input-b
focus:outline-none focus:ring-2 focus:ring-th-ring focus:border-transparent
transition-all duration-200;
}
.card {
@apply bg-th-card rounded-xl border border-th-border
shadow-th transition-all duration-200;
}
.card-hover {
@apply card hover:shadow-th-lg cursor-pointer;
}
.card-hover:hover {
border-color: color-mix(in srgb, var(--accent) 30%, transparent);
}
}
@layer utilities {
.gradient-text {
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.gradient-bg {
background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end));
}
}
/* ═══════════════════════════════════════════════════════════════
+69 -45
View File
@@ -423,17 +423,18 @@ export default function Admin() {
<div className="grid gap-6 sm:grid-cols-2">
{/* Logo upload */}
<div>
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.logoLabel')}</label>
<p className="block text-sm font-medium text-th-text mb-2">{t('admin.logoLabel')}</p>
<div className="flex items-center gap-4">
{hasLogo && logoUrl ? (
<div className="relative group">
<img
src={`${logoUrl}?t=${Date.now()}`}
alt="Logo"
alt=""
className="w-14 h-14 rounded-xl object-contain border border-th-border bg-th-bg p-1"
/>
<button
onClick={handleLogoRemove}
aria-label={t('common.delete')}
className="absolute -top-2 -right-2 w-5 h-5 bg-th-error text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<XIcon size={12} />
@@ -471,11 +472,12 @@ export default function Admin() {
{/* App name */}
<div>
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.appNameLabel')}</label>
<label htmlFor="admin-app-name" className="block text-sm font-medium text-th-text mb-2">{t('admin.appNameLabel')}</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Type size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
<Type size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="admin-app-name"
type="text"
value={editAppName}
onChange={e => setEditAppName(e.target.value)}
@@ -502,7 +504,7 @@ export default function Admin() {
type="button"
disabled={savingHideAppName}
onClick={() => handleHideAppNameToggle(!hideAppName)}
className={`relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-th-ring focus:ring-offset-1 disabled:opacity-50 ml-4 ${
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-hidden focus:ring-2 focus:ring-th-ring focus:ring-offset-1 disabled:opacity-50 ml-4 ${
hideAppName ? 'bg-th-accent' : 'bg-th-border'
}`}
aria-checked={hideAppName}
@@ -520,12 +522,13 @@ export default function Admin() {
{/* Default theme */}
<div className="mt-6 pt-6 border-t border-th-border">
<div className="flex items-center gap-2 mb-1">
<Palette size={16} className="text-th-accent" />
<label className="block text-sm font-medium text-th-text">{t('admin.defaultThemeLabel')}</label>
<Palette size={16} className="text-th-accent" aria-hidden="true" />
<label htmlFor="admin-default-theme" className="block text-sm font-medium text-th-text">{t('admin.defaultThemeLabel')}</label>
</div>
<p className="text-xs text-th-text-s mb-3">{t('admin.defaultThemeDesc')}</p>
<div className="flex items-center gap-2">
<select
id="admin-default-theme"
value={editDefaultTheme}
onChange={e => setEditDefaultTheme(e.target.value)}
className="input-field text-sm flex-1"
@@ -539,7 +542,7 @@ export default function Admin() {
<button
onClick={handleDefaultThemeSave}
disabled={savingDefaultTheme || editDefaultTheme === (defaultTheme || 'dark')}
className="btn-primary text-sm px-4 flex-shrink-0"
className="btn-primary text-sm px-4 shrink-0"
>
{savingDefaultTheme ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
@@ -549,16 +552,17 @@ export default function Admin() {
{/* 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>
<LinkIcon size={16} className="text-th-accent" aria-hidden="true" />
<p className="block text-sm font-medium text-th-text">{t('admin.legalLinksTitle')}</p>
</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>
<label htmlFor="admin-imprint-url" className="block text-xs font-medium text-th-text-s mb-1">{t('admin.imprintUrl')}</label>
<div className="flex items-center gap-2">
<input
id="admin-imprint-url"
type="url"
value={editImprintUrl}
onChange={e => setEditImprintUrl(e.target.value)}
@@ -569,7 +573,7 @@ export default function Admin() {
<button
onClick={handleImprintUrlSave}
disabled={savingImprintUrl || editImprintUrl === (imprintUrl || '')}
className="btn-primary text-sm px-4 flex-shrink-0"
className="btn-primary text-sm px-4 shrink-0"
>
{savingImprintUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
@@ -577,9 +581,10 @@ export default function Admin() {
</div>
{/* Privacy Policy */}
<div>
<label className="block text-xs font-medium text-th-text-s mb-1">{t('admin.privacyUrl')}</label>
<label htmlFor="admin-privacy-url" className="block text-xs font-medium text-th-text-s mb-1">{t('admin.privacyUrl')}</label>
<div className="flex items-center gap-2">
<input
id="admin-privacy-url"
type="url"
value={editPrivacyUrl}
onChange={e => setEditPrivacyUrl(e.target.value)}
@@ -590,7 +595,7 @@ export default function Admin() {
<button
onClick={handlePrivacyUrlSave}
disabled={savingPrivacyUrl || editPrivacyUrl === (privacyUrl || '')}
className="btn-primary text-sm px-4 flex-shrink-0"
className="btn-primary text-sm px-4 shrink-0"
>
{savingPrivacyUrl ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
</button>
@@ -647,20 +652,21 @@ export default function Admin() {
{/* Send invite form */}
<form onSubmit={handleSendInvite} className="flex items-center gap-2 mb-6">
<div className="relative flex-1">
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
<Mail size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
type="email"
value={inviteEmail}
onChange={e => setInviteEmail(e.target.value)}
className="input-field pl-9 text-sm"
placeholder={t('auth.emailPlaceholder')}
aria-label={t('auth.email')}
required
/>
</div>
<button
type="submit"
disabled={sendingInvite || !inviteEmail.trim()}
className="btn-primary text-sm px-4 flex-shrink-0"
className="btn-primary text-sm px-4 shrink-0"
>
{sendingInvite ? <Loader2 size={14} className="animate-spin" /> : <Send size={14} />}
{t('admin.sendInvite')}
@@ -676,7 +682,7 @@ export default function Admin() {
return (
<div key={inv.id} className="flex items-center justify-between gap-3 p-3 rounded-xl bg-th-bg border border-th-border">
<div className="flex items-center gap-3 min-w-0">
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${
isUsed ? 'bg-green-500/15 text-green-400' : isExpired ? 'bg-red-500/15 text-red-400' : 'bg-th-accent/15 text-th-accent'
}`}>
{isUsed ? <Check size={14} /> : isExpired ? <XIcon size={14} /> : <Clock size={14} />}
@@ -693,7 +699,7 @@ export default function Admin() {
</p>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<div className="flex items-center gap-1 shrink-0">
{!isUsed && !isExpired && (
<button
onClick={() => handleCopyInviteLink(inv.token)}
@@ -738,8 +744,9 @@ export default function Admin() {
<form onSubmit={handleOauthSave} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthIssuer')}</label>
<label htmlFor="admin-oauth-issuer" className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthIssuer')}</label>
<input
id="admin-oauth-issuer"
type="url"
value={oauthForm.issuer}
onChange={e => setOauthForm(f => ({ ...f, issuer: e.target.value }))}
@@ -750,8 +757,9 @@ export default function Admin() {
<p className="text-xs text-th-text-s mt-1">{t('admin.oauthIssuerHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientId')}</label>
<label htmlFor="admin-oauth-client-id" className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientId')}</label>
<input
id="admin-oauth-client-id"
type="text"
value={oauthForm.clientId}
onChange={e => setOauthForm(f => ({ ...f, clientId: e.target.value }))}
@@ -764,9 +772,11 @@ export default function Admin() {
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientSecret')}</label>
<label htmlFor="admin-oauth-client-secret" className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthClientSecret')}</label>
<input
id="admin-oauth-client-secret"
type="password"
autoComplete="new-password"
value={oauthForm.clientSecret}
onChange={e => setOauthForm(f => ({ ...f, clientSecret: e.target.value }))}
className="input-field text-sm"
@@ -777,8 +787,9 @@ export default function Admin() {
)}
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthDisplayName')}</label>
<label htmlFor="admin-oauth-display-name" className="block text-sm font-medium text-th-text mb-1">{t('admin.oauthDisplayName')}</label>
<input
id="admin-oauth-display-name"
type="text"
value={oauthForm.displayName}
onChange={e => setOauthForm(f => ({ ...f, displayName: e.target.value }))}
@@ -941,13 +952,14 @@ export default function Admin() {
{/* Search */}
<div className="card p-4 mb-6">
<div className="relative">
<Search size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Search size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
type="text"
type="search"
value={search}
onChange={e => setSearch(e.target.value)}
className="input-field pl-11"
placeholder={t('admin.searchUsers')}
aria-label={t('admin.searchUsers')}
/>
</div>
</div>
@@ -981,7 +993,7 @@ export default function Admin() {
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold flex-shrink-0 overflow-hidden"
className="w-9 h-9 rounded-full flex items-center justify-center text-white text-sm font-bold shrink-0 overflow-hidden"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{u.avatar_image ? (
@@ -1099,14 +1111,16 @@ export default function Admin() {
{/* Reset password modal */}
{resetPwModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setResetPwModal(null)} />
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-sm p-6">
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.resetPasswordTitle')}</h3>
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={() => setResetPwModal(null)} aria-hidden="true" />
<div role="dialog" aria-modal="true" aria-labelledby="reset-pw-title" className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-sm p-6">
<h3 id="reset-pw-title" className="text-lg font-semibold text-th-text mb-4">{t('admin.resetPasswordTitle')}</h3>
<form onSubmit={handleResetPassword}>
<div className="mb-4">
<label className="block text-sm font-medium text-th-text mb-1.5">{t('admin.newPasswordLabel')}</label>
<label htmlFor="admin-reset-pw" className="block text-sm font-medium text-th-text mb-1.5">{t('admin.newPasswordLabel')}</label>
<input
id="admin-reset-pw"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
className="input-field"
@@ -1131,16 +1145,18 @@ export default function Admin() {
{/* Create user modal */}
{showCreateUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowCreateUser(false)} />
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-md p-6">
<h3 className="text-lg font-semibold text-th-text mb-4">{t('admin.createUserTitle')}</h3>
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={() => setShowCreateUser(false)} aria-hidden="true" />
<div role="dialog" aria-modal="true" aria-labelledby="create-user-title" className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-md p-6">
<h3 id="create-user-title" className="text-lg font-semibold text-th-text mb-4">{t('admin.createUserTitle')}</h3>
<form onSubmit={handleCreateUser} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<label htmlFor="admin-new-username" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="admin-new-username"
type="text"
autoComplete="off"
value={newUser.name}
onChange={e => setNewUser({ ...newUser, name: e.target.value })}
className="input-field pl-11"
@@ -1151,11 +1167,13 @@ export default function Admin() {
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<label htmlFor="admin-new-display-name" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="admin-new-display-name"
type="text"
autoComplete="off"
value={newUser.display_name}
onChange={e => setNewUser({ ...newUser, display_name: e.target.value })}
className="input-field pl-11"
@@ -1164,11 +1182,13 @@ export default function Admin() {
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<label htmlFor="admin-new-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative">
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="admin-new-email"
type="email"
autoComplete="off"
value={newUser.email}
onChange={e => setNewUser({ ...newUser, email: e.target.value })}
className="input-field pl-11"
@@ -1178,11 +1198,13 @@ export default function Admin() {
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
<label htmlFor="admin-new-password" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="admin-new-password"
type="password"
autoComplete="new-password"
value={newUser.password}
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
className="input-field pl-11"
@@ -1193,8 +1215,9 @@ export default function Admin() {
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('admin.role')}</label>
<label htmlFor="admin-new-role" className="block text-sm font-medium text-th-text mb-1.5">{t('admin.role')}</label>
<select
id="admin-new-role"
value={newUser.role}
onChange={e => setNewUser({ ...newUser, role: e.target.value })}
className="input-field"
@@ -1219,7 +1242,7 @@ export default function Admin() {
{/* All rooms modal */}
{showAllRoomsModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setShowAllRoomsModal(false)} />
<div className="fixed inset-0 bg-black/60 backdrop-blur-xs" onClick={() => setShowAllRoomsModal(false)} />
<div className="relative bg-th-card rounded-2xl border border-th-border shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col">
<div className="flex items-center justify-between p-6 border-b border-th-border">
<div className="flex items-center gap-2">
@@ -1227,19 +1250,20 @@ export default function Admin() {
<h3 className="text-lg font-semibold text-th-text">{t('admin.roomsTitle')}</h3>
<span className="text-sm text-th-text-s">({adminRooms.length})</span>
</div>
<button onClick={() => setShowAllRoomsModal(false)} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors">
<button onClick={() => setShowAllRoomsModal(false)} aria-label={t('common.close')} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors">
<XIcon size={18} />
</button>
</div>
<div className="p-4 border-b border-th-border">
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
type="text"
type="search"
value={allRoomsSearch}
onChange={e => setAllRoomsSearch(e.target.value)}
className="input-field pl-9 text-sm"
placeholder={t('admin.searchRooms')}
aria-label={t('admin.searchRooms')}
autoFocus
/>
</div>
+23 -15
View File
@@ -454,7 +454,7 @@ export default function Calendar() {
<div
key={ev.id}
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
className="text-[10px] leading-tight px-1.5 py-0.5 rounded truncate text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
className="text-[10px] leading-tight px-1.5 py-0.5 rounded-sm truncate text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }}
title={ev.title}
>
@@ -492,11 +492,11 @@ export default function Calendar() {
<div
key={ev.id}
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
className="text-xs px-2 py-1.5 rounded text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
className="text-xs px-2 py-1.5 rounded-sm text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }}
>
<div className="flex items-center gap-1 truncate">
{ev.reminder_minutes && <Bell size={9} className="flex-shrink-0 opacity-70" />}
{ev.reminder_minutes && <Bell size={9} className="shrink-0 opacity-70" />}
<span className="truncate">{ev.title}</span>
</div>
<div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} - {formatTime(ev.end_time)}</div>
@@ -515,8 +515,9 @@ export default function Calendar() {
<Modal title={editingEvent ? t('calendar.editEvent') : t('calendar.createEvent')} onClose={() => { setShowCreate(false); setEditingEvent(null); }}>
<form onSubmit={handleSave} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.eventTitle')} *</label>
<label htmlFor="calendar-event-title" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.eventTitle')} *</label>
<input
id="calendar-event-title"
type="text"
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
@@ -527,8 +528,9 @@ export default function Calendar() {
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.description')}</label>
<label htmlFor="calendar-event-description" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.description')}</label>
<textarea
id="calendar-event-description"
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
className="input-field resize-none"
@@ -555,13 +557,14 @@ export default function Calendar() {
/>
</div>
<div className="flex items-center gap-1.5 -mt-2 text-xs text-th-text-s">
<Globe size={12} className="flex-shrink-0" />
<Globe size={12} className="shrink-0" />
<span>{getLocalTimezone()}</span>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.linkedRoom')}</label>
<label htmlFor="calendar-event-room" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.linkedRoom')}</label>
<select
id="calendar-event-room"
value={form.room_uid}
onChange={e => setForm({ ...form, room_uid: e.target.value })}
className="input-field"
@@ -575,10 +578,11 @@ export default function Calendar() {
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.reminderLabel')}</label>
<label htmlFor="calendar-event-reminder" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.reminderLabel')}</label>
<div className="relative">
<Bell size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none" />
<Bell size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none" aria-hidden="true" />
<select
id="calendar-event-reminder"
value={form.reminder_minutes ?? ''}
onChange={e => setForm({ ...form, reminder_minutes: e.target.value === '' ? null : Number(e.target.value) })}
className="input-field pl-9"
@@ -595,13 +599,16 @@ export default function Calendar() {
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</label>
<div className="flex gap-2">
<p id="calendar-event-color-label" className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</p>
<div role="radiogroup" aria-labelledby="calendar-event-color-label" className="flex gap-2">
{COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => setForm({ ...form, color: c })}
role="radio"
aria-checked={form.color === c}
aria-label={c}
className={`w-7 h-7 rounded-full border-2 transition-all ${form.color === c ? 'border-th-text scale-110' : 'border-transparent'}`}
style={{ backgroundColor: c }}
/>
@@ -750,7 +757,7 @@ export default function Calendar() {
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
@@ -772,7 +779,7 @@ export default function Calendar() {
<div key={u.user_id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border border-dashed">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
@@ -799,7 +806,7 @@ export default function Calendar() {
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
@@ -826,8 +833,9 @@ export default function Calendar() {
<p className="text-sm text-th-text-s mb-4">{t('calendar.sendFederatedDesc')}</p>
<form onSubmit={handleFedSend} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
<label htmlFor="calendar-fed-share-address" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
<input
id="calendar-fed-share-address"
type="text"
value={fedAddress}
onChange={e => setFedAddress(e.target.value)}
+15 -7
View File
@@ -103,9 +103,11 @@ export default function Dashboard() {
</div>
<div className="flex items-center gap-2">
{/* View toggle */}
<div className="hidden sm:flex items-center bg-th-bg-s rounded-lg border border-th-border p-0.5">
<div role="group" aria-label={t('common.gridView') + ' / ' + t('common.listView')} className="hidden sm:flex items-center bg-th-bg-s rounded-lg border border-th-border p-0.5">
<button
onClick={() => setViewMode('grid')}
aria-label={t('common.gridView')}
aria-pressed={viewMode === 'grid'}
className={`p-1.5 rounded-md transition-colors ${
viewMode === 'grid' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
}`}
@@ -114,6 +116,8 @@ export default function Dashboard() {
</button>
<button
onClick={() => setViewMode('list')}
aria-label={t('common.listView')}
aria-pressed={viewMode === 'list'}
className={`p-1.5 rounded-md transition-colors ${
viewMode === 'list' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
}`}
@@ -197,8 +201,9 @@ export default function Dashboard() {
<Modal title={t('dashboard.createRoom')} onClose={() => setShowCreate(false)}>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')} *</label>
<label htmlFor="create-room-name" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')} *</label>
<input
id="create-room-name"
type="text"
value={newRoom.name}
onChange={e => setNewRoom({ ...newRoom, name: e.target.value })}
@@ -210,8 +215,9 @@ export default function Dashboard() {
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.welcomeMessage')}</label>
<label htmlFor="create-room-welcome" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.welcomeMessage')}</label>
<textarea
id="create-room-welcome"
value={newRoom.welcome_message}
onChange={e => setNewRoom({ ...newRoom, welcome_message: e.target.value })}
className="input-field resize-none"
@@ -222,8 +228,9 @@ export default function Dashboard() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.maxParticipants')}</label>
<label htmlFor="create-room-max" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.maxParticipants')}</label>
<input
id="create-room-max"
type="number"
value={newRoom.max_participants}
onChange={e => setNewRoom({ ...newRoom, max_participants: parseInt(e.target.value) || 0 })}
@@ -233,8 +240,9 @@ export default function Dashboard() {
/>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.accessCode')}</label>
<label htmlFor="create-room-access-code" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.accessCode')}</label>
<input
id="create-room-access-code"
type="text"
value={newRoom.access_code}
onChange={e => setNewRoom({ ...newRoom, access_code: e.target.value })}
@@ -250,7 +258,7 @@ export default function Dashboard() {
type="checkbox"
checked={newRoom.mute_on_join}
onChange={e => setNewRoom({ ...newRoom, mute_on_join: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('dashboard.muteOnJoin')}</span>
</label>
@@ -259,7 +267,7 @@ export default function Dashboard() {
type="checkbox"
checked={newRoom.record_meeting}
onChange={e => setNewRoom({ ...newRoom, record_meeting: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('dashboard.allowRecording')}</span>
</label>
+5 -5
View File
@@ -93,7 +93,7 @@ export default function FederatedRoomDetail() {
{isDeleted && (
<div className="card p-4 mb-4 border-red-500/30 bg-red-500/10">
<div className="flex items-center gap-3">
<AlertTriangle size={20} className="text-red-500 flex-shrink-0" />
<AlertTriangle size={20} className="text-red-500 shrink-0" />
<div>
<p className="text-sm font-semibold text-red-500">{t('federation.roomDeleted')}</p>
<p className="text-xs text-th-text-s mt-0.5">{t('federation.roomDeletedNotice')}</p>
@@ -106,7 +106,7 @@ export default function FederatedRoomDetail() {
<div className="card p-6 mb-4">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-start gap-3 min-w-0">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5 ${isDeleted ? 'bg-red-500/15' : 'bg-th-accent/15'}`}>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 mt-0.5 ${isDeleted ? 'bg-red-500/15' : 'bg-th-accent/15'}`}>
{isDeleted ? <AlertTriangle size={20} className="text-red-500" /> : <Globe size={20} className="text-th-accent" />}
</div>
<div className="min-w-0">
@@ -128,7 +128,7 @@ export default function FederatedRoomDetail() {
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
{!isDeleted && (
<button
onClick={handleJoin}
@@ -189,7 +189,7 @@ export default function FederatedRoomDetail() {
{room.meet_id && (
<div className="flex items-start gap-3">
<Hash size={16} className="text-th-accent flex-shrink-0 mt-0.5" />
<Hash size={16} className="text-th-accent shrink-0 mt-0.5" />
<div className="min-w-0">
<p className="text-xs text-th-text-s mb-0.5">{t('federation.meetingId')}</p>
<p className="text-sm font-mono text-th-text break-all">{room.meet_id}</p>
@@ -198,7 +198,7 @@ export default function FederatedRoomDetail() {
)}
<div className="flex items-start gap-3">
<Link2 size={16} className="text-th-accent flex-shrink-0 mt-0.5" />
<Link2 size={16} className="text-th-accent shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<p className="text-xs text-th-text-s mb-0.5">{t('federation.joinUrl')}</p>
<p className="text-sm font-mono text-th-text break-all opacity-60 select-all">{room.join_url}</p>
+12 -12
View File
@@ -167,7 +167,7 @@ export default function FederationInbox() {
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<Mail size={16} className="text-th-accent flex-shrink-0" />
<Mail size={16} className="text-th-accent shrink-0" />
<h3 className="text-base font-semibold text-th-text truncate">{inv.room_name}</h3>
</div>
<p className="text-sm text-th-text-s">
@@ -180,7 +180,7 @@ export default function FederationInbox() {
{new Date(inv.created_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => handleAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} />
{t('federation.accept')}
@@ -200,7 +200,7 @@ export default function FederationInbox() {
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<Calendar size={16} className="text-th-success flex-shrink-0" />
<Calendar size={16} className="text-th-success shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wide text-th-success mr-1">
{t('federation.calendarEvent')}
</span>
@@ -219,7 +219,7 @@ export default function FederationInbox() {
{new Date(inv.created_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => handleCalAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} />
{t('federation.accept')}
@@ -239,7 +239,7 @@ export default function FederationInbox() {
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<Calendar size={16} className="text-th-accent flex-shrink-0" />
<Calendar size={16} className="text-th-accent shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wide text-th-accent mr-1">
{t('federation.localCalendarEvent')}
</span>
@@ -258,7 +258,7 @@ export default function FederationInbox() {
{new Date(inv.created_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => handleLocalCalAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} />
{t('federation.accept')}
@@ -287,13 +287,13 @@ export default function FederationInbox() {
<div key={`room-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex items-center gap-2">
<Mail size={14} className="text-th-text-s flex-shrink-0" />
<Mail size={14} className="text-th-text-s shrink-0" />
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
<p className="text-xs text-th-text-s">{inv.from_user}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
@@ -326,13 +326,13 @@ export default function FederationInbox() {
<div key={`cal-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex items-center gap-2">
<Calendar size={14} className="text-th-text-s flex-shrink-0" />
<Calendar size={14} className="text-th-text-s shrink-0" />
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
<p className="text-xs text-th-text-s">{inv.from_user} · {new Date(inv.start_time).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
@@ -365,13 +365,13 @@ export default function FederationInbox() {
<div key={`localcal-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex items-center gap-2">
<Calendar size={14} className="text-th-text-s flex-shrink-0" />
<Calendar size={14} className="text-th-text-s shrink-0" />
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
<p className="text-xs text-th-text-s">{inv.from_name} · {new Date(inv.start_time).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
+16 -8
View File
@@ -3,6 +3,7 @@ import { useParams, Link, useSearchParams, useNavigate } from 'react-router-dom'
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio, AlertCircle, FileText, Clock, X } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import api from '../services/api';
import { isCurrentThemeDark } from '../themes';
import toast from 'react-hot-toast';
import { useLanguage } from '../contexts/LanguageContext';
import { useAuth } from '../contexts/AuthContext';
@@ -35,6 +36,7 @@ export default function GuestJoin() {
name: name.trim(),
access_code: accessCode || undefined,
moderator_code: moderatorCode || undefined,
dark_mode: isCurrentThemeDark(),
};
// If logged in, send avatar data
if (isLoggedIn && user) {
@@ -230,11 +232,13 @@ export default function GuestJoin() {
) : (
<form onSubmit={handleJoin} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
<label htmlFor="guest-name" className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestYourName')} *</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="guest-name"
type="text"
autoComplete="name"
value={name}
onChange={e => !isLoggedIn && setName(e.target.value)}
readOnly={isLoggedIn}
@@ -248,11 +252,13 @@ export default function GuestJoin() {
{roomInfo.has_access_code && (
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label>
<label htmlFor="guest-access-code" className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestAccessCode')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="guest-access-code"
type="text"
autoComplete="off"
value={accessCode}
onChange={e => setAccessCode(e.target.value)}
className="input-field pl-11"
@@ -263,14 +269,16 @@ export default function GuestJoin() {
)}
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">
<label htmlFor="guest-moderator-code" className="block text-sm font-medium text-th-text mb-1.5">
{t('room.guestModeratorCode')}
<span className="text-th-text-s font-normal ml-1">{t('room.guestModeratorOptional')}</span>
</label>
<div className="relative">
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="guest-moderator-code"
type="text"
autoComplete="off"
value={moderatorCode}
onChange={e => setModeratorCode(e.target.value)}
className="input-field pl-11"
@@ -283,7 +291,7 @@ export default function GuestJoin() {
{roomInfo.allow_recording && (
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-3">
<div className="flex items-start gap-2">
<AlertCircle size={16} className="text-amber-500 flex-shrink-0 mt-0.5" />
<AlertCircle size={16} className="text-amber-500 shrink-0 mt-0.5" />
<p className="text-sm text-amber-400">{t('room.guestRecordingNotice')}</p>
</div>
<label className="flex items-center gap-2.5 cursor-pointer">
@@ -291,7 +299,7 @@ export default function GuestJoin() {
type="checkbox"
checked={recordingConsent}
onChange={e => setRecordingConsent(e.target.checked)}
className="w-4 h-4 rounded accent-amber-500 cursor-pointer"
className="w-4 h-4 rounded-sm accent-amber-500 cursor-pointer"
/>
<span className="text-sm text-th-text">{t('room.guestRecordingConsent')}</span>
</label>
+26 -74
View File
@@ -1,5 +1,5 @@
import { Link } from 'react-router-dom';
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe, FileText, Lock, Network, CalendarDays, Bell, KeyRound, BarChart2, Settings2 } from 'lucide-react';
import { Video, Users, FileVideo, Network, CalendarDays, BarChart2, ArrowRight, FileText, Lock } from 'lucide-react';
import BrandLogo from '../components/BrandLogo';
import { useLanguage } from '../contexts/LanguageContext';
import { useBranding } from '../contexts/BrandingContext';
@@ -21,7 +21,7 @@ export default function Home() {
desc: t('home.featureRoomsDesc'),
},
{
icon: Zap,
icon: FileVideo,
title: t('home.featureRecordingsTitle'),
desc: t('home.featureRecordingsDesc'),
},
@@ -35,38 +35,21 @@ export default function Home() {
title: t('home.featureCalendarTitle'),
desc: t('home.featureCalendarDesc'),
},
{
icon: Bell,
title: t('home.featureNotificationsTitle'),
desc: t('home.featureNotificationsDesc'),
},
{
icon: KeyRound,
title: t('home.featureOAuthTitle'),
desc: t('home.featureOAuthDesc'),
},
{
icon: BarChart2,
title: t('home.featureAnalyticsTitle'),
desc: t('home.featureAnalyticsDesc'),
},
{
icon: Palette,
title: t('home.featureThemesTitle'),
desc: t('home.featureThemesDesc'),
},
];
return (
<div className="min-h-screen bg-th-bg">
{/* Hero Section */}
<div className="relative overflow-hidden">
{/* Background gradient */}
<div className="absolute inset-0 gradient-bg opacity-5" />
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[600px] gradient-bg opacity-10 blur-3xl rounded-full" />
{/* Navbar */}
<nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-4">
<nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-4 border-b border-th-border">
<BrandLogo size="md" />
<div className="flex items-center gap-3">
<Link to="/login" className="btn-ghost text-sm">
@@ -75,81 +58,50 @@ export default function Home() {
{!isInviteOnly && (
<Link to="/register" className="btn-primary text-sm">
{t('auth.register')}
<ArrowRight size={16} />
</Link>
)}
</div>
</nav>
{/* Hero content */}
<div className="relative z-10 max-w-4xl mx-auto text-center px-6 pt-20 pb-32">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-th-accent/10 text-th-accent text-sm font-medium mb-6">
<Zap size={14} />
{t('home.madeFor')}
</div>
<h1 className="text-4xl md:text-6xl lg:text-7xl font-extrabold text-th-text mb-6 leading-tight tracking-tight">
{t('home.heroTitle')}{' '}
<span className="gradient-text">{t('home.heroTitleHighlight')}</span>
{/* Hero */}
<div className="relative z-10 max-w-3xl mx-auto px-6 pt-24 pb-20">
<h1 className="text-3xl md:text-4xl font-bold text-th-text mb-4">
{t('home.heroTitle')}
</h1>
<p className="text-lg md:text-xl text-th-text-s max-w-2xl mx-auto mb-10 leading-relaxed">
<p className="text-lg text-th-text-s mb-8 leading-relaxed">
{t('home.heroSubtitle')}
</p>
<div className="flex items-center gap-4 justify-center">
<div className="flex items-center gap-3">
{!isInviteOnly && (
<Link to="/register" className="btn-primary text-base px-8 py-3">
<Link to="/register" className="btn-primary px-6 py-2.5">
{t('home.getStarted')}
<ArrowRight size={18} />
<ArrowRight size={16} />
</Link>
)}
<Link to="/login" className={`${isInviteOnly ? 'btn-primary' : 'btn-secondary'} text-base px-8 py-3`}>
<Link to="/login" className={`${isInviteOnly ? 'btn-primary' : 'btn-secondary'} px-6 py-2.5`}>
{t('auth.login')}
</Link>
</div>
{/* Stats */}
<div className="flex items-center justify-center gap-8 md:gap-16 mt-16">
{[
{ value: '15+', label: t('home.statThemes') },
{ value: '∞', label: t('home.statRooms') },
{ value: '100%', label: t('home.statOpenSource') },
].map(stat => (
<div key={stat.label} className="text-center">
<div className="text-2xl md:text-3xl font-bold gradient-text">{stat.value}</div>
<div className="text-sm text-th-text-s mt-1">{stat.label}</div>
</div>
))}
</div>
</div>
</div>
{/* Features Section */}
<div className="max-w-6xl mx-auto px-6 py-20">
<div className="text-center mb-16">
<h2 className="text-3xl md:text-4xl font-bold text-th-text mb-4">
{/* Features */}
<div className="border-t border-th-border">
<div className="max-w-5xl mx-auto px-6 py-16">
<h2 className="text-xl font-semibold text-th-text mb-10">
{t('home.features')}
</h2>
<p className="text-lg text-th-text-s max-w-2xl mx-auto">
{t('home.featuresSubtitle')}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((feature, idx) => (
<div key={idx} className="card p-6 hover:shadow-th-lg transition-all duration-300 group">
<div className="w-12 h-12 rounded-xl gradient-bg/10 flex items-center justify-center mb-4 group-hover:scale-110 transition-transform"
style={{ background: `linear-gradient(135deg, var(--gradient-start), var(--gradient-end))`, opacity: 0.15 }}>
<feature.icon size={24} className="text-th-accent" style={{ opacity: 1 }} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map(feature => (
<div key={feature.title} className="card p-6 hover:shadow-th-lg transition-shadow">
<div className="w-12 h-12 rounded-xl bg-th-accent/10 flex items-center justify-center mb-4">
<feature.icon size={24} className="text-th-accent" />
</div>
<h3 className="text-base font-semibold text-th-text mb-2">{feature.title}</h3>
<p className="text-sm text-th-text-s leading-relaxed">{feature.desc}</p>
</div>
<div className="w-12 h-12 rounded-xl flex items-center justify-center mb-4 -mt-16 relative">
<feature.icon size={24} className="text-th-accent" />
</div>
<h3 className="text-base font-semibold text-th-text mb-2">{feature.title}</h3>
<p className="text-sm text-th-text-s leading-relaxed">{feature.desc}</p>
</div>
))}
))}
</div>
</div>
</div>
+9 -5
View File
@@ -189,11 +189,13 @@ export default function Login() {
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<label htmlFor="login-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative">
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="login-email"
type="email"
autoComplete="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="input-field pl-11"
@@ -204,11 +206,13 @@ export default function Login() {
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
<label htmlFor="login-password" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="login-password"
type="password"
autoComplete="current-password"
value={password}
onChange={e => setPassword(e.target.value)}
className="input-field pl-11"
@@ -257,7 +261,7 @@ export default function Login() {
{needsVerification && (
<div className="mt-4 p-4 rounded-xl bg-amber-500/10 border border-amber-500/30 space-y-2">
<div className="flex items-start gap-2">
<AlertTriangle size={16} className="text-amber-400 flex-shrink-0 mt-0.5" />
<AlertTriangle size={16} className="text-amber-400 shrink-0 mt-0.5" />
<p className="text-sm text-amber-200">{t('auth.emailVerificationBanner')}</p>
</div>
<button
+20 -10
View File
@@ -104,11 +104,13 @@ export default function Register() {
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<label htmlFor="register-username" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="register-username"
type="text"
autoComplete="username"
value={username}
onChange={e => setUsername(e.target.value)}
className="input-field pl-11"
@@ -120,11 +122,13 @@ export default function Register() {
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<label htmlFor="register-display-name" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="register-display-name"
type="text"
autoComplete="name"
value={displayName}
onChange={e => setDisplayName(e.target.value)}
className="input-field pl-11"
@@ -135,11 +139,13 @@ export default function Register() {
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<label htmlFor="register-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative">
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="register-email"
type="email"
autoComplete="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="input-field pl-11"
@@ -150,11 +156,13 @@ export default function Register() {
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
<label htmlFor="register-password" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.password')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="register-password"
type="password"
autoComplete="new-password"
value={password}
onChange={e => setPassword(e.target.value)}
className="input-field pl-11"
@@ -166,11 +174,13 @@ export default function Register() {
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.confirmPassword')}</label>
<label htmlFor="register-confirm-password" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.confirmPassword')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="register-confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className="input-field pl-11"
+35 -24
View File
@@ -8,6 +8,7 @@ import {
} from 'lucide-react';
import Modal from '../components/Modal';
import api from '../services/api';
import { isCurrentThemeDark } from '../themes';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import RecordingList from '../components/RecordingList';
@@ -124,7 +125,7 @@ export default function RoomDetail() {
toast.success(t('room.meetingStarted'));
setWaitingToJoin(false);
setActionLoading('join');
api.post(`/rooms/${uid}/join`, {})
api.post(`/rooms/${uid}/join`, { dark_mode: isCurrentThemeDark() })
.then(res => { if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank'); })
.catch(err => toast.error(err.response?.data?.error || t('room.joinFailed')))
.finally(() => setActionLoading(null));
@@ -135,7 +136,7 @@ export default function RoomDetail() {
const handleStart = async () => {
setActionLoading('start');
try {
const res = await api.post(`/rooms/${uid}/start`);
const res = await api.post(`/rooms/${uid}/start`, { dark_mode: isCurrentThemeDark() });
if (res.data.joinUrl) {
window.open(res.data.joinUrl, '_blank');
}
@@ -157,7 +158,8 @@ export default function RoomDetail() {
setWaitingToJoin(false);
setActionLoading('join');
try {
const data = room.access_code ? { access_code: prompt(t('room.enterAccessCode')) } : {};
const data = { dark_mode: isCurrentThemeDark() };
if (room.access_code) data.access_code = prompt(t('room.enterAccessCode'));
const res = await api.post(`/rooms/${uid}/join`, data);
if (res.data.joinUrl) {
window.open(res.data.joinUrl, '_blank');
@@ -511,7 +513,7 @@ export default function RoomDetail() {
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-th-text-s">{t('room.meetingId')}</span>
<code className="bg-th-bg-s px-2 py-0.5 rounded text-xs text-th-text font-mono">{room.uid}</code>
<code className="bg-th-bg-s px-2 py-0.5 rounded-sm text-xs text-th-text font-mono">{room.uid}</code>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-th-text-s">{t('room.status')}</span>
@@ -580,8 +582,9 @@ export default function RoomDetail() {
{activeTab === 'settings' && isOwner && editRoom && (
<form onSubmit={handleSaveSettings} className="card p-6 space-y-5 max-w-2xl">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')}</label>
<label htmlFor="room-settings-name" className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.roomName')}</label>
<input
id="room-settings-name"
type="text"
value={editRoom.name}
onChange={e => setEditRoom({ ...editRoom, name: e.target.value })}
@@ -592,8 +595,9 @@ export default function RoomDetail() {
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.welcomeMsg')}</label>
<label htmlFor="room-settings-welcome" className="block text-sm font-medium text-th-text mb-1.5">{t('room.welcomeMsg')}</label>
<textarea
id="room-settings-welcome"
value={editRoom.welcome_message || ''}
onChange={e => setEditRoom({ ...editRoom, welcome_message: e.target.value })}
className="input-field resize-none"
@@ -603,8 +607,9 @@ export default function RoomDetail() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.maxParticipants')}</label>
<label htmlFor="room-settings-max" className="block text-sm font-medium text-th-text mb-1.5">{t('room.maxParticipants')}</label>
<input
id="room-settings-max"
type="number"
value={editRoom.max_participants}
onChange={e => setEditRoom({ ...editRoom, max_participants: parseInt(e.target.value) || 0 })}
@@ -613,8 +618,9 @@ export default function RoomDetail() {
/>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.accessCode')}</label>
<label htmlFor="room-settings-access-code" className="block text-sm font-medium text-th-text mb-1.5">{t('room.accessCode')}</label>
<input
id="room-settings-access-code"
type="text"
value={editRoom.access_code || ''}
onChange={e => setEditRoom({ ...editRoom, access_code: e.target.value })}
@@ -630,7 +636,7 @@ export default function RoomDetail() {
type="checkbox"
checked={!!editRoom.mute_on_join}
onChange={e => setEditRoom({ ...editRoom, mute_on_join: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.muteOnJoin')}</span>
</label>
@@ -639,7 +645,7 @@ export default function RoomDetail() {
type="checkbox"
checked={!!editRoom.require_approval}
onChange={e => setEditRoom({ ...editRoom, require_approval: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.requireApproval')}</span>
</label>
@@ -648,7 +654,7 @@ export default function RoomDetail() {
type="checkbox"
checked={!!editRoom.anyone_can_start}
onChange={e => setEditRoom({ ...editRoom, anyone_can_start: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.anyoneCanStart')}</span>
</label>
@@ -657,7 +663,7 @@ export default function RoomDetail() {
type="checkbox"
checked={!!editRoom.all_join_moderator}
onChange={e => setEditRoom({ ...editRoom, all_join_moderator: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.allJoinModerator')}</span>
</label>
@@ -666,7 +672,7 @@ export default function RoomDetail() {
type="checkbox"
checked={!!editRoom.record_meeting}
onChange={e => setEditRoom({ ...editRoom, record_meeting: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.allowRecording')}</span>
</label>
@@ -675,14 +681,15 @@ export default function RoomDetail() {
type="checkbox"
checked={!!editRoom.learning_analytics}
onChange={e => setEditRoom({ ...editRoom, learning_analytics: e.target.checked })}
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
className="w-4 h-4 rounded-sm border-th-border text-th-accent focus:ring-th-ring"
/>
<span className="text-sm text-th-text">{t('room.enableAnalytics')}</span>
</label>
{!!editRoom.learning_analytics && (
<div className="ml-7">
<label className="block text-xs font-medium text-th-text-s mb-1">{t('room.analyticsVisibility')}</label>
<label htmlFor="room-settings-analytics-visibility" className="block text-xs font-medium text-th-text-s mb-1">{t('room.analyticsVisibility')}</label>
<select
id="room-settings-analytics-visibility"
value={editRoom.analytics_visibility || 'owner'}
onChange={e => setEditRoom({ ...editRoom, analytics_visibility: e.target.value })}
className="input-field text-sm py-1.5 max-w-xs"
@@ -700,8 +707,9 @@ export default function RoomDetail() {
<h3 className="text-sm font-semibold text-th-text">{t('room.guestAccessTitle')}</h3>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.moderatorCode')}</label>
<label htmlFor="room-settings-moderator-code" className="block text-sm font-medium text-th-text mb-1.5">{t('room.moderatorCode')}</label>
<input
id="room-settings-moderator-code"
type="text"
value={editRoom.moderator_code || ''}
onChange={e => setEditRoom({ ...editRoom, moderator_code: e.target.value })}
@@ -743,7 +751,7 @@ export default function RoomDetail() {
{room.presentation_file ? (
<div className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-2 min-w-0">
<FileText size={16} className="text-th-accent flex-shrink-0" />
<FileText size={16} className="text-th-accent shrink-0" />
<div className="min-w-0">
<p className="text-xs text-th-text-s">{t('room.presentationCurrent')}</p>
<p className="text-sm text-th-text font-medium truncate">
@@ -755,7 +763,7 @@ export default function RoomDetail() {
type="button"
onClick={handlePresentationRemove}
disabled={removingPresentation}
className="btn-ghost text-th-error hover:bg-th-error/10 flex-shrink-0 text-xs py-1.5 px-3"
className="btn-ghost text-th-error hover:bg-th-error/10 shrink-0 text-xs py-1.5 px-3"
>
{removingPresentation ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
{t('room.presentationRemove')}
@@ -814,7 +822,7 @@ export default function RoomDetail() {
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden"
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0 overflow-hidden"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{u.avatar_image ? (
@@ -840,7 +848,7 @@ export default function RoomDetail() {
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 overflow-hidden"
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold shrink-0 overflow-hidden"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{u.avatar_image ? (
@@ -857,7 +865,7 @@ export default function RoomDetail() {
<button
type="button"
onClick={() => handleUnshare(u.id)}
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors flex-shrink-0"
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors shrink-0"
title={t('room.shareRemove')}
>
<X size={16} />
@@ -883,8 +891,9 @@ export default function RoomDetail() {
<p className="text-sm text-th-text-s mb-4">{t('federation.inviteSubtitle')}</p>
<form onSubmit={handleFedInvite} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
<label htmlFor="fed-invite-address" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
<input
id="fed-invite-address"
type="text"
value={fedAddress}
onChange={e => { setFedAddress(e.target.value); if (e.target.value) setFedEmails(''); }}
@@ -902,8 +911,9 @@ export default function RoomDetail() {
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.emailLabel')}</label>
<label htmlFor="fed-invite-emails" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.emailLabel')}</label>
<input
id="fed-invite-emails"
type="text"
value={fedEmails}
onChange={e => { setFedEmails(e.target.value); if (e.target.value) setFedAddress(''); }}
@@ -915,8 +925,9 @@ export default function RoomDetail() {
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
<label htmlFor="fed-invite-message" className="block text-sm font-medium text-th-text mb-1.5">{t('federation.messageLabel')}</label>
<textarea
id="fed-invite-message"
value={fedMessage}
onChange={e => setFedMessage(e.target.value)}
className="input-field resize-none"
+50 -29
View File
@@ -267,7 +267,7 @@ export default function Settings() {
<div className="flex flex-col md:flex-row gap-6">
{/* Section nav */}
<div className="md:w-56 flex-shrink-0">
<div className="md:w-56 shrink-0">
<nav className="flex md:flex-col gap-1">
{sections.map(s => (
<button
@@ -301,7 +301,7 @@ export default function Settings() {
{user?.avatar_image ? (
<img
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
alt="Avatar"
alt=""
className="w-16 h-16 rounded-full object-cover"
/>
) : (
@@ -316,6 +316,7 @@ export default function Settings() {
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingAvatar}
aria-label={t('settings.uploadImage')}
className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
>
{uploadingAvatar ? (
@@ -360,12 +361,15 @@ export default function Settings() {
{/* Avatar Color (fallback) */}
<div className="mb-6">
<label className="block text-sm font-medium text-th-text mb-3">{t('settings.avatarColor')}</label>
<div className="flex flex-wrap gap-2">
<p id="avatar-color-label" className="block text-sm font-medium text-th-text mb-3">{t('settings.avatarColor')}</p>
<div role="radiogroup" aria-labelledby="avatar-color-label" className="flex flex-wrap gap-2">
{avatarColors.map(color => (
<button
key={color}
onClick={() => handleAvatarColor(color)}
role="radio"
aria-checked={user?.avatar_color === color}
aria-label={color}
className={`w-7 h-7 rounded-full ring-2 ring-offset-2 transition-all ${
user?.avatar_color === color ? 'ring-th-accent' : 'ring-transparent hover:ring-th-border'
}`}
@@ -378,11 +382,13 @@ export default function Settings() {
<form onSubmit={handleProfileSave} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<label htmlFor="settings-username" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.username')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="settings-username"
type="text"
autoComplete="username"
value={profile.name}
onChange={e => setProfile({ ...profile, name: e.target.value })}
className="input-field pl-11"
@@ -392,11 +398,13 @@ export default function Settings() {
<p className="text-xs text-th-text-s mt-1">{t('auth.usernameHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<label htmlFor="settings-display-name" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.displayName')}</label>
<div className="relative">
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="settings-display-name"
type="text"
autoComplete="name"
value={profile.display_name}
onChange={e => setProfile({ ...profile, display_name: e.target.value })}
className="input-field pl-11"
@@ -404,11 +412,13 @@ export default function Settings() {
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<label htmlFor="settings-email" className="block text-sm font-medium text-th-text mb-1.5">{t('auth.email')}</label>
<div className="relative">
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="settings-email"
type="email"
autoComplete="email"
value={profile.email}
onChange={e => setProfile({ ...profile, email: e.target.value })}
className="input-field pl-11"
@@ -430,11 +440,13 @@ export default function Settings() {
<h2 className="text-lg font-semibold text-th-text mb-6">{t('settings.changePassword')}</h2>
<form onSubmit={handlePasswordSave} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
<label htmlFor="settings-current-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="settings-current-password"
type="password"
autoComplete="current-password"
value={passwords.currentPassword}
onChange={e => setPasswords({ ...passwords, currentPassword: e.target.value })}
className="input-field pl-11"
@@ -443,11 +455,13 @@ export default function Settings() {
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.newPassword')}</label>
<label htmlFor="settings-new-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.newPassword')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="settings-new-password"
type="password"
autoComplete="new-password"
value={passwords.newPassword}
onChange={e => setPasswords({ ...passwords, newPassword: e.target.value })}
className="input-field pl-11"
@@ -458,11 +472,13 @@ export default function Settings() {
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.confirmNewPassword')}</label>
<label htmlFor="settings-confirm-new-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.confirmNewPassword')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="settings-confirm-new-password"
type="password"
autoComplete="new-password"
value={passwords.confirmPassword}
onChange={e => setPasswords({ ...passwords, confirmPassword: e.target.value })}
className="input-field pl-11"
@@ -495,7 +511,7 @@ export default function Settings() {
/* 2FA is enabled */
<div>
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 mb-5">
<ShieldCheck size={22} className="text-emerald-400 flex-shrink-0" />
<ShieldCheck size={22} className="text-emerald-400 shrink-0" />
<div>
<p className="text-sm font-medium text-emerald-300">{t('settings.security.statusEnabled')}</p>
<p className="text-xs text-emerald-400/70">{t('settings.security.statusEnabledDesc')}</p>
@@ -514,11 +530,13 @@ export default function Settings() {
<form onSubmit={handleDisable2FA} className="space-y-4 p-4 rounded-xl bg-th-bg-t border border-th-border">
<p className="text-sm text-th-text-s">{t('settings.security.disableConfirm')}</p>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
<label htmlFor="twofa-disable-password" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.currentPassword')}</label>
<div className="relative">
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" aria-hidden="true" />
<input
id="twofa-disable-password"
type="password"
autoComplete="current-password"
value={twoFaDisablePassword}
onChange={e => setTwoFaDisablePassword(e.target.value)}
className="input-field pl-11"
@@ -527,8 +545,9 @@ export default function Settings() {
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.codeLabel')}</label>
<label htmlFor="twofa-disable-code" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.codeLabel')}</label>
<input
id="twofa-disable-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
@@ -569,7 +588,8 @@ export default function Settings() {
</code>
<button
onClick={() => { navigator.clipboard.writeText(twoFaSetupData.secret); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0"
className="btn-ghost py-1.5 px-2 shrink-0"
aria-label={t('room.copyLink')}
>
<Copy size={14} />
</button>
@@ -577,8 +597,9 @@ export default function Settings() {
</div>
<form onSubmit={handleEnable2FA} className="space-y-3">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.verifyCode')}</label>
<label htmlFor="twofa-enable-code" className="block text-sm font-medium text-th-text mb-1.5">{t('settings.security.verifyCode')}</label>
<input
id="twofa-enable-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
@@ -605,7 +626,7 @@ export default function Settings() {
/* 2FA is disabled — show enable button */
<div>
<div className="flex items-center gap-3 p-4 rounded-xl bg-th-bg-t border border-th-border mb-5">
<ShieldOff size={22} className="text-th-text-s flex-shrink-0" />
<ShieldOff size={22} className="text-th-text-s shrink-0" />
<div>
<p className="text-sm font-medium text-th-text">{t('settings.security.statusDisabled')}</p>
<p className="text-xs text-th-text-s">{t('settings.security.statusDisabledDesc')}</p>
@@ -667,7 +688,7 @@ export default function Settings() {
>
{/* Color preview */}
<div
className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 border"
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 border"
style={{ backgroundColor: th.colors.bg, borderColor: th.colors.accent + '40' }}
>
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: th.colors.accent }} />
@@ -699,7 +720,7 @@ export default function Settings() {
</code>
<button
onClick={() => { navigator.clipboard.writeText(`${window.location.origin}/caldav/`); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0"
className="btn-ghost py-1.5 px-2 shrink-0"
>
<Copy size={14} />
</button>
@@ -713,7 +734,7 @@ export default function Settings() {
</code>
<button
onClick={() => { navigator.clipboard.writeText(user?.email || ''); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0"
className="btn-ghost py-1.5 px-2 shrink-0"
>
<Copy size={14} />
</button>
@@ -732,12 +753,12 @@ export default function Settings() {
<code className="flex-1 text-xs bg-th-bg-t px-3 py-2 rounded-lg font-mono text-th-text break-all">
{tokenVisible ? newlyCreatedToken : '•'.repeat(48)}
</code>
<button onClick={() => setTokenVisible(v => !v)} className="btn-ghost py-1.5 px-2 flex-shrink-0">
<button onClick={() => setTokenVisible(v => !v)} className="btn-ghost py-1.5 px-2 shrink-0">
{tokenVisible ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button
onClick={() => { navigator.clipboard.writeText(newlyCreatedToken); toast.success(t('room.linkCopied')); }}
className="btn-ghost py-1.5 px-2 flex-shrink-0"
className="btn-ghost py-1.5 px-2 shrink-0"
>
<Copy size={14} />
</button>
@@ -790,7 +811,7 @@ export default function Settings() {
</div>
<button
onClick={() => handleRevokeToken(tk.id)}
className="btn-ghost py-1 px-2 text-th-error hover:text-th-error flex-shrink-0"
className="btn-ghost py-1 px-2 text-th-error hover:text-th-error shrink-0"
title={t('settings.caldav.revoke')}
>
<Trash2 size={14} />
+12
View File
@@ -173,6 +173,18 @@ export function getThemeById(id) {
return themes.find(t => t.id === id) || themes[1]; // default to dark
}
// Whether the currently applied theme is a dark one. Reads the resolved theme
// from the <html data-theme> attribute (set by ThemeContext) with a localStorage
// fallback, so it works without React context — handy for one-off API payloads
// such as the BBB join call that forwards dark mode to the meeting.
export function isCurrentThemeDark() {
const id = (typeof document !== 'undefined'
&& document.documentElement.getAttribute('data-theme'))
|| (typeof localStorage !== 'undefined' && localStorage.getItem('theme'))
|| 'dark';
return getThemeById(id).type === 'dark';
}
export function getThemeGroups() {
const groups = {};
themes.forEach(theme => {
-39
View File
@@ -1,39 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
th: {
bg: 'var(--bg-primary)',
'bg-s': 'var(--bg-secondary)',
'bg-t': 'var(--bg-tertiary)',
text: 'var(--text-primary)',
'text-s': 'var(--text-secondary)',
accent: 'var(--accent)',
'accent-h': 'var(--accent-hover)',
'accent-t': 'var(--accent-text)',
border: 'var(--border)',
card: 'var(--card-bg)',
input: 'var(--input-bg)',
'input-b': 'var(--input-border)',
nav: 'var(--nav-bg)',
side: 'var(--sidebar-bg)',
hover: 'var(--hover-bg)',
success: 'var(--success)',
warning: 'var(--warning)',
error: 'var(--error)',
ring: 'var(--ring)',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
},
boxShadow: {
'th': '0 1px 3px 0 var(--shadow-color), 0 1px 2px -1px var(--shadow-color)',
'th-lg': '0 10px 15px -3px var(--shadow-color), 0 4px 6px -4px var(--shadow-color)',
},
},
},
plugins: [],
};