This commit is contained in:
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
redlight.db
|
||||
uploads/
|
||||
.git/
|
||||
.gitignore
|
||||
.vscode/
|
||||
*.md
|
||||
20
.env.example
Normal file
20
.env.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# BigBlueButton API Configuration
|
||||
BBB_URL=https://your-bbb-server.com/bigbluebutton/api/
|
||||
BBB_SECRET=your-bbb-shared-secret
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this
|
||||
|
||||
# Database Configuration
|
||||
# Leave DATABASE_URL empty or unset to use SQLite (default: redlight.db)
|
||||
# Set a PostgreSQL connection string to use PostgreSQL instead:
|
||||
# DATABASE_URL=postgres://user:password@localhost:5432/redlight
|
||||
DATABASE_URL=
|
||||
|
||||
# SQLite file path (only used when DATABASE_URL is not set)
|
||||
# SQLITE_PATH=./redlight.db
|
||||
|
||||
# Default Admin Account (created on first run)
|
||||
ADMIN_EMAIL=admin@example.com
|
||||
ADMIN_PASSWORD=admin123
|
||||
47
.gitea/workflows/docker.yaml
Normal file
47
.gitea/workflows/docker.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Build & Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ github.server_url }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ github.server_url }}/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
redlight.db
|
||||
uploads/
|
||||
.env
|
||||
44
Dockerfile
Normal file
44
Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# ── Stage 1: Build frontend ──────────────────────────────────────────────────
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# ── Stage 2: Production image ───────────────────────────────────────────────
|
||||
FROM node:20-alpine
|
||||
|
||||
# better-sqlite3 needs build tools for native compilation
|
||||
RUN apk add --no-cache python3 make g++
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
# Remove build tools after install to keep image smaller
|
||||
RUN apk del python3 make g++
|
||||
|
||||
# Copy server code
|
||||
COPY server/ ./server/
|
||||
|
||||
# Copy built frontend from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p uploads/avatars
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3001
|
||||
ENV SQLITE_PATH=/app/data/redlight.db
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
# Data volumes for persistent storage
|
||||
VOLUME ["/app/uploads", "/app/data"]
|
||||
|
||||
CMD ["node", "server/index.js"]
|
||||
16
index.html
Normal file
16
index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.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 />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<title>Redlight - BigBlueButton Frontend</title>
|
||||
</head>
|
||||
<body class="bg-th-bg text-th-text transition-colors duration-200">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
4694
package-lock.json
generated
Normal file
4694
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
package.json
Normal file
40
package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "redlight",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently -n client,server -c blue,green \"vite\" \"node --watch server/index.js\"",
|
||||
"dev:client": "vite",
|
||||
"dev:server": "node --watch server/index.js",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "NODE_ENV=production node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"concurrently": "^9.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.0",
|
||||
"express": "^4.21.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"lucide-react": "^0.460.0",
|
||||
"pg": "^8.18.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"xml2js": "^0.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
5
public/vite.svg
Normal file
5
public/vite.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 268 B |
BIN
redlight.db-shm
Normal file
BIN
redlight.db-shm
Normal file
Binary file not shown.
BIN
redlight.db-wal
Normal file
BIN
redlight.db-wal
Normal file
Binary file not shown.
114
server/config/bbb.js
Normal file
114
server/config/bbb.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import crypto from 'crypto';
|
||||
import xml2js from 'xml2js';
|
||||
|
||||
const BBB_URL = process.env.BBB_URL || 'https://your-bbb-server.com/bigbluebutton/api/';
|
||||
const BBB_SECRET = process.env.BBB_SECRET || '';
|
||||
|
||||
function getChecksum(apiCall, params) {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
const raw = apiCall + queryString + BBB_SECRET;
|
||||
return crypto.createHash('sha256').update(raw).digest('hex');
|
||||
}
|
||||
|
||||
function buildUrl(apiCall, params = {}) {
|
||||
const checksum = getChecksum(apiCall, params);
|
||||
const queryString = new URLSearchParams({ ...params, checksum }).toString();
|
||||
return `${BBB_URL}${apiCall}?${queryString}`;
|
||||
}
|
||||
|
||||
async function apiCall(apiCallName, params = {}) {
|
||||
const url = buildUrl(apiCallName, params);
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const xml = await response.text();
|
||||
const result = await xml2js.parseStringPromise(xml, {
|
||||
explicitArray: false,
|
||||
trim: true,
|
||||
});
|
||||
return result.response;
|
||||
} catch (error) {
|
||||
console.error(`BBB API error (${apiCallName}):`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate deterministic passwords from room UID
|
||||
function getRoomPasswords(uid) {
|
||||
const modPw = crypto.createHash('sha256').update(uid + '_mod_' + BBB_SECRET).digest('hex').substring(0, 16);
|
||||
const attPw = crypto.createHash('sha256').update(uid + '_att_' + BBB_SECRET).digest('hex').substring(0, 16);
|
||||
return { moderatorPW: modPw, attendeePW: attPw };
|
||||
}
|
||||
|
||||
export async function createMeeting(room) {
|
||||
const { moderatorPW, attendeePW } = getRoomPasswords(room.uid);
|
||||
const params = {
|
||||
meetingID: room.uid,
|
||||
name: room.name,
|
||||
attendeePW,
|
||||
moderatorPW,
|
||||
welcome: room.welcome_message || 'Willkommen!',
|
||||
record: room.record_meeting ? 'true' : 'false',
|
||||
autoStartRecording: 'false',
|
||||
allowStartStopRecording: 'true',
|
||||
muteOnStart: room.mute_on_join ? 'true' : 'false',
|
||||
'meta_bbb-origin': 'Redlight',
|
||||
'meta_bbb-origin-server-name': 'Redlight',
|
||||
};
|
||||
if (room.max_participants > 0) {
|
||||
params.maxParticipants = room.max_participants.toString();
|
||||
}
|
||||
if (room.access_code) {
|
||||
params.lockSettingsLockOnJoin = 'true';
|
||||
}
|
||||
return apiCall('create', params);
|
||||
}
|
||||
|
||||
export async function joinMeeting(uid, name, isModerator = false) {
|
||||
const { moderatorPW, attendeePW } = getRoomPasswords(uid);
|
||||
const params = {
|
||||
meetingID: uid,
|
||||
fullName: name,
|
||||
password: isModerator ? moderatorPW : attendeePW,
|
||||
redirect: 'true',
|
||||
};
|
||||
return buildUrl('join', params);
|
||||
}
|
||||
|
||||
export async function endMeeting(uid) {
|
||||
const { moderatorPW } = getRoomPasswords(uid);
|
||||
return apiCall('end', { meetingID: uid, password: moderatorPW });
|
||||
}
|
||||
|
||||
export async function getMeetingInfo(uid) {
|
||||
return apiCall('getMeetingInfo', { meetingID: uid });
|
||||
}
|
||||
|
||||
export async function isMeetingRunning(uid) {
|
||||
const result = await apiCall('isMeetingRunning', { meetingID: uid });
|
||||
return result.running === 'true';
|
||||
}
|
||||
|
||||
export async function getMeetings() {
|
||||
return apiCall('getMeetings', {});
|
||||
}
|
||||
|
||||
export async function getRecordings(meetingID) {
|
||||
const params = meetingID ? { meetingID } : {};
|
||||
const result = await apiCall('getRecordings', params);
|
||||
if (result.returncode !== 'SUCCESS' || !result.recordings) {
|
||||
return [];
|
||||
}
|
||||
const recordings = result.recordings.recording;
|
||||
if (!recordings) return [];
|
||||
return Array.isArray(recordings) ? recordings : [recordings];
|
||||
}
|
||||
|
||||
export async function deleteRecording(recordID) {
|
||||
return apiCall('deleteRecordings', { recordID });
|
||||
}
|
||||
|
||||
export async function publishRecording(recordID, publish) {
|
||||
return apiCall('publishRecordings', { recordID, publish: publish ? 'true' : 'false' });
|
||||
}
|
||||
|
||||
export { getRoomPasswords };
|
||||
230
server/config/database.js
Normal file
230
server/config/database.js
Normal file
@@ -0,0 +1,230 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
const isPostgres = !!(DATABASE_URL && DATABASE_URL.startsWith('postgres'));
|
||||
|
||||
let db;
|
||||
|
||||
// Convert ? placeholders to $1, $2, ... for PostgreSQL
|
||||
function convertPlaceholders(sql) {
|
||||
let index = 0;
|
||||
return sql.replace(/\?/g, () => `$${++index}`);
|
||||
}
|
||||
|
||||
// ── SQLite Adapter ──────────────────────────────────────────────────────────
|
||||
class SqliteAdapter {
|
||||
async init() {
|
||||
const { default: Database } = await import('better-sqlite3');
|
||||
const dbPath = process.env.SQLITE_PATH || path.join(__dirname, '..', '..', 'redlight.db');
|
||||
this.db = Database(dbPath);
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
this.db.pragma('foreign_keys = ON');
|
||||
}
|
||||
|
||||
async get(sql, params = []) {
|
||||
return this.db.prepare(sql).get(...params);
|
||||
}
|
||||
|
||||
async all(sql, params = []) {
|
||||
return this.db.prepare(sql).all(...params);
|
||||
}
|
||||
|
||||
async run(sql, params = []) {
|
||||
const result = this.db.prepare(sql).run(...params);
|
||||
return { lastInsertRowid: Number(result.lastInsertRowid), changes: result.changes };
|
||||
}
|
||||
|
||||
async exec(sql) {
|
||||
this.db.exec(sql);
|
||||
}
|
||||
|
||||
async columnExists(table, column) {
|
||||
const columns = this.db.pragma(`table_info(${table})`);
|
||||
return !!columns.find(c => c.name === column);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ── PostgreSQL Adapter ──────────────────────────────────────────────────────
|
||||
class PostgresAdapter {
|
||||
async init() {
|
||||
const pg = await import('pg');
|
||||
// Parse int8 (bigint / COUNT) as JS number instead of string
|
||||
pg.default.types.setTypeParser(20, val => parseInt(val, 10));
|
||||
this.pool = new pg.default.Pool({ connectionString: DATABASE_URL });
|
||||
}
|
||||
|
||||
async get(sql, params = []) {
|
||||
const result = await this.pool.query(convertPlaceholders(sql), params);
|
||||
return result.rows[0] || undefined;
|
||||
}
|
||||
|
||||
async all(sql, params = []) {
|
||||
const result = await this.pool.query(convertPlaceholders(sql), params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async run(sql, params = []) {
|
||||
let pgSql = convertPlaceholders(sql);
|
||||
const isInsert = /^\s*INSERT/i.test(pgSql);
|
||||
if (isInsert && !/RETURNING/i.test(pgSql)) {
|
||||
pgSql += ' RETURNING id';
|
||||
}
|
||||
const result = await this.pool.query(pgSql, params);
|
||||
return {
|
||||
lastInsertRowid: isInsert ? result.rows[0]?.id : undefined,
|
||||
changes: result.rowCount,
|
||||
};
|
||||
}
|
||||
|
||||
async exec(sql) {
|
||||
await this.pool.query(sql);
|
||||
}
|
||||
|
||||
async columnExists(table, column) {
|
||||
const result = await this.pool.query(
|
||||
'SELECT column_name FROM information_schema.columns WHERE table_name = $1 AND column_name = $2',
|
||||
[table, column]
|
||||
);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.pool?.end();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ──────────────────────────────────────────────────────────────
|
||||
export function getDb() {
|
||||
if (!db) {
|
||||
throw new Error('Database not initialised – call initDatabase() first');
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
export async function initDatabase() {
|
||||
// Create the right adapter
|
||||
if (isPostgres) {
|
||||
console.log('📦 Using PostgreSQL database');
|
||||
db = new PostgresAdapter();
|
||||
} else {
|
||||
console.log('📦 Using SQLite database');
|
||||
db = new SqliteAdapter();
|
||||
}
|
||||
await db.init();
|
||||
|
||||
// ── Schema creation ─────────────────────────────────────────────────────
|
||||
if (isPostgres) {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')),
|
||||
language TEXT DEFAULT 'de',
|
||||
theme TEXT DEFAULT 'dark',
|
||||
avatar_color TEXT DEFAULT '#6366f1',
|
||||
avatar_image TEXT DEFAULT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id SERIAL PRIMARY KEY,
|
||||
uid TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
welcome_message TEXT DEFAULT 'Willkommen im Meeting!',
|
||||
max_participants INTEGER DEFAULT 0,
|
||||
access_code TEXT,
|
||||
mute_on_join INTEGER DEFAULT 1,
|
||||
require_approval INTEGER DEFAULT 0,
|
||||
anyone_can_start INTEGER DEFAULT 0,
|
||||
all_join_moderator INTEGER DEFAULT 0,
|
||||
record_meeting INTEGER DEFAULT 1,
|
||||
guest_access INTEGER DEFAULT 0,
|
||||
moderator_code TEXT,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
`);
|
||||
} else {
|
||||
await db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT DEFAULT 'user' CHECK(role IN ('user', 'admin')),
|
||||
language TEXT DEFAULT 'de',
|
||||
theme TEXT DEFAULT 'dark',
|
||||
avatar_color TEXT DEFAULT '#6366f1',
|
||||
avatar_image TEXT DEFAULT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uid TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
welcome_message TEXT DEFAULT 'Willkommen im Meeting!',
|
||||
max_participants INTEGER DEFAULT 0,
|
||||
access_code TEXT,
|
||||
mute_on_join INTEGER DEFAULT 1,
|
||||
require_approval INTEGER DEFAULT 0,
|
||||
anyone_can_start INTEGER DEFAULT 0,
|
||||
all_join_moderator INTEGER DEFAULT 0,
|
||||
record_meeting INTEGER DEFAULT 1,
|
||||
guest_access INTEGER DEFAULT 0,
|
||||
moderator_code TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_rooms_user_id ON rooms(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_rooms_uid ON rooms(uid);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Migrations ──────────────────────────────────────────────────────────
|
||||
if (!(await db.columnExists('users', 'avatar_image'))) {
|
||||
await db.exec('ALTER TABLE users ADD COLUMN avatar_image TEXT DEFAULT NULL');
|
||||
}
|
||||
if (!(await db.columnExists('rooms', 'guest_access'))) {
|
||||
await db.exec('ALTER TABLE rooms ADD COLUMN guest_access INTEGER DEFAULT 0');
|
||||
}
|
||||
if (!(await db.columnExists('rooms', 'moderator_code'))) {
|
||||
await db.exec('ALTER TABLE rooms ADD COLUMN moderator_code TEXT');
|
||||
}
|
||||
|
||||
// ── Default admin ───────────────────────────────────────────────────────
|
||||
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
|
||||
const existingAdmin = await db.get('SELECT id FROM users WHERE email = ?', [adminEmail]);
|
||||
if (!existingAdmin) {
|
||||
const hash = bcrypt.hashSync(adminPassword, 12);
|
||||
await db.run(
|
||||
'INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)',
|
||||
['Administrator', adminEmail, hash, 'admin']
|
||||
);
|
||||
console.log(`✅ Default admin created: ${adminEmail}`);
|
||||
}
|
||||
}
|
||||
48
server/index.js
Normal file
48
server/index.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { initDatabase } from './config/database.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import roomRoutes from './routes/rooms.js';
|
||||
import recordingRoutes from './routes/recordings.js';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Initialize database & start server
|
||||
async function start() {
|
||||
await initDatabase();
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/rooms', roomRoutes);
|
||||
app.use('/api/recordings', recordingRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
// Serve static files in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
app.use(express.static(path.join(__dirname, '..', 'dist')));
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🔴 Redlight server running on http://localhost:${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
start().catch(err => {
|
||||
console.error('❌ Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
37
server/middleware/auth.js
Normal file
37
server/middleware/auth.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { getDb } from '../config/database.js';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'fallback-secret-change-me';
|
||||
|
||||
export async function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Authentifizierung erforderlich' });
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
const db = getDb();
|
||||
const user = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [decoded.userId]);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Benutzer nicht gefunden' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(403).json({ error: 'Ungültiges Token' });
|
||||
}
|
||||
}
|
||||
|
||||
export function requireAdmin(req, res, next) {
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Administratorrechte erforderlich' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export function generateToken(userId) {
|
||||
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '7d' });
|
||||
}
|
||||
139
server/routes/admin.js
Normal file
139
server/routes/admin.js
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/admin/users - Create user (admin)
|
||||
router.post('/users', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, role } = req.body;
|
||||
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({ error: 'Alle Felder sind erforderlich' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
}
|
||||
|
||||
const validRole = ['user', 'admin'].includes(role) ? role : 'user';
|
||||
const db = getDb();
|
||||
|
||||
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'E-Mail wird bereits verwendet' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
const result = await db.run(
|
||||
'INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)',
|
||||
[name, email.toLowerCase(), hash, validRole]
|
||||
);
|
||||
|
||||
const user = await db.get('SELECT id, name, email, role, created_at FROM users WHERE id = ?', [result.lastInsertRowid]);
|
||||
res.status(201).json({ user });
|
||||
} catch (err) {
|
||||
console.error('Create user error:', err);
|
||||
res.status(500).json({ error: 'Benutzer konnte nicht erstellt werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/admin/users - List all users
|
||||
router.get('/users', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const users = await db.all(`
|
||||
SELECT id, name, email, role, language, theme, avatar_color, avatar_image, created_at,
|
||||
(SELECT COUNT(*) FROM rooms WHERE rooms.user_id = users.id) as room_count
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`);
|
||||
|
||||
res.json({ users });
|
||||
} catch (err) {
|
||||
console.error('List users error:', err);
|
||||
res.status(500).json({ error: 'Benutzer konnten nicht geladen werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/role - Update user role
|
||||
router.put('/users/:id/role', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { role } = req.body;
|
||||
if (!['user', 'admin'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Ungültige Rolle' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Prevent demoting last admin
|
||||
if (role === 'user') {
|
||||
const adminCount = await db.get('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']);
|
||||
const currentUser = await db.get('SELECT role FROM users WHERE id = ?', [req.params.id]);
|
||||
if (currentUser?.role === 'admin' && adminCount.count <= 1) {
|
||||
return res.status(400).json({ error: 'Der letzte Admin kann nicht herabgestuft werden' });
|
||||
}
|
||||
}
|
||||
|
||||
await db.run('UPDATE users SET role = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [role, req.params.id]);
|
||||
const updated = await db.get('SELECT id, name, email, role, created_at FROM users WHERE id = ?', [req.params.id]);
|
||||
|
||||
res.json({ user: updated });
|
||||
} catch (err) {
|
||||
console.error('Update role error:', err);
|
||||
res.status(500).json({ error: 'Rolle konnte nicht aktualisiert werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/admin/users/:id - Delete user
|
||||
router.delete('/users/:id', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
if (parseInt(req.params.id) === req.user.id) {
|
||||
return res.status(400).json({ error: 'Sie können sich nicht selbst löschen' });
|
||||
}
|
||||
|
||||
const user = await db.get('SELECT id, role FROM users WHERE id = ?', [req.params.id]);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
}
|
||||
|
||||
// Check if it's the last admin
|
||||
if (user.role === 'admin') {
|
||||
const adminCount = await db.get('SELECT COUNT(*) as count FROM users WHERE role = ?', ['admin']);
|
||||
if (adminCount.count <= 1) {
|
||||
return res.status(400).json({ error: 'Der letzte Admin kann nicht gelöscht werden' });
|
||||
}
|
||||
}
|
||||
|
||||
await db.run('DELETE FROM users WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'Benutzer gelöscht' });
|
||||
} catch (err) {
|
||||
console.error('Delete user error:', err);
|
||||
res.status(500).json({ error: 'Benutzer konnte nicht gelöscht werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/admin/users/:id/password - Reset user password (admin)
|
||||
router.put('/users/:id/password', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { newPassword } = req.body;
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const hash = bcrypt.hashSync(newPassword, 12);
|
||||
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.params.id]);
|
||||
|
||||
res.json({ message: 'Passwort zurückgesetzt' });
|
||||
} catch (err) {
|
||||
console.error('Reset password error:', err);
|
||||
res.status(500).json({ error: 'Passwort konnte nicht zurückgesetzt werden' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
219
server/routes/auth.js
Normal file
219
server/routes/auth.js
Normal file
@@ -0,0 +1,219 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken, generateToken } from '../middleware/auth.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const uploadsDir = path.join(__dirname, '..', '..', 'uploads', 'avatars');
|
||||
|
||||
// Ensure uploads directory exists
|
||||
if (!fs.existsSync(uploadsDir)) {
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/auth/register
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { name, email, password } = req.body;
|
||||
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({ error: 'Alle Felder sind erforderlich' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const existing = await db.get('SELECT id FROM users WHERE email = ?', [email]);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'E-Mail wird bereits verwendet' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(password, 12);
|
||||
const result = await db.run(
|
||||
'INSERT INTO users (name, email, password_hash) VALUES (?, ?, ?)',
|
||||
[name, email.toLowerCase(), hash]
|
||||
);
|
||||
|
||||
const token = generateToken(result.lastInsertRowid);
|
||||
const user = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [result.lastInsertRowid]);
|
||||
|
||||
res.status(201).json({ token, user });
|
||||
} catch (err) {
|
||||
console.error('Register error:', err);
|
||||
res.status(500).json({ error: 'Registrierung fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'E-Mail und Passwort sind erforderlich' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = await db.get('SELECT * FROM users WHERE email = ?', [email.toLowerCase()]);
|
||||
|
||||
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
||||
}
|
||||
|
||||
const token = generateToken(user.id);
|
||||
const { password_hash, ...safeUser } = user;
|
||||
|
||||
res.json({ token, user: safeUser });
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
res.status(500).json({ error: 'Anmeldung fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/me
|
||||
router.get('/me', authenticateToken, (req, res) => {
|
||||
res.json({ user: req.user });
|
||||
});
|
||||
|
||||
// PUT /api/auth/profile
|
||||
router.put('/profile', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { name, email, theme, language, avatar_color } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
if (email && email !== req.user.email) {
|
||||
const existing = await db.get('SELECT id FROM users WHERE email = ? AND id != ?', [email.toLowerCase(), req.user.id]);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'E-Mail wird bereits verwendet' });
|
||||
}
|
||||
}
|
||||
|
||||
await db.run(`
|
||||
UPDATE users SET
|
||||
name = COALESCE(?, name),
|
||||
email = COALESCE(?, email),
|
||||
theme = COALESCE(?, theme),
|
||||
language = COALESCE(?, language),
|
||||
avatar_color = COALESCE(?, avatar_color),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`, [name, email?.toLowerCase(), theme, language, avatar_color, req.user.id]);
|
||||
|
||||
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
|
||||
res.json({ user: updated });
|
||||
} catch (err) {
|
||||
console.error('Profile update error:', err);
|
||||
res.status(500).json({ error: 'Profil konnte nicht aktualisiert werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/auth/password
|
||||
router.put('/password', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
const user = await db.get('SELECT password_hash FROM users WHERE id = ?', [req.user.id]);
|
||||
if (!bcrypt.compareSync(currentPassword, user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Aktuelles Passwort ist falsch' });
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({ error: 'Neues Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
}
|
||||
|
||||
const hash = bcrypt.hashSync(newPassword, 12);
|
||||
await db.run('UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [hash, req.user.id]);
|
||||
|
||||
res.json({ message: 'Passwort erfolgreich geändert' });
|
||||
} catch (err) {
|
||||
console.error('Password change error:', err);
|
||||
res.status(500).json({ error: 'Passwort konnte nicht geändert werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/avatar - Upload avatar image
|
||||
router.post('/avatar', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const buffer = await new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on('data', chunk => chunks.push(chunk));
|
||||
req.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
req.on('error', reject);
|
||||
});
|
||||
|
||||
// Validate content type
|
||||
const contentType = req.headers['content-type'];
|
||||
if (!contentType || !contentType.startsWith('image/')) {
|
||||
return res.status(400).json({ error: 'Nur Bilddateien sind erlaubt' });
|
||||
}
|
||||
|
||||
// Max 2MB
|
||||
if (buffer.length > 2 * 1024 * 1024) {
|
||||
return res.status(400).json({ error: 'Bild darf maximal 2MB groß sein' });
|
||||
}
|
||||
|
||||
const ext = contentType.includes('png') ? 'png' : contentType.includes('gif') ? 'gif' : contentType.includes('webp') ? 'webp' : 'jpg';
|
||||
const filename = `${req.user.id}_${Date.now()}.${ext}`;
|
||||
const filepath = path.join(uploadsDir, filename);
|
||||
|
||||
// Remove old avatar if exists
|
||||
const db = getDb();
|
||||
const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]);
|
||||
if (current?.avatar_image) {
|
||||
const oldPath = path.join(uploadsDir, current.avatar_image);
|
||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filepath, buffer);
|
||||
|
||||
await db.run('UPDATE users SET avatar_image = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [filename, req.user.id]);
|
||||
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
|
||||
|
||||
res.json({ user: updated });
|
||||
} catch (err) {
|
||||
console.error('Avatar upload error:', err);
|
||||
res.status(500).json({ error: 'Avatar konnte nicht hochgeladen werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/auth/avatar - Remove avatar image
|
||||
router.delete('/avatar', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const current = await db.get('SELECT avatar_image FROM users WHERE id = ?', [req.user.id]);
|
||||
if (current?.avatar_image) {
|
||||
const oldPath = path.join(uploadsDir, current.avatar_image);
|
||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
}
|
||||
await db.run('UPDATE users SET avatar_image = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [req.user.id]);
|
||||
const updated = await db.get('SELECT id, name, email, role, theme, language, avatar_color, avatar_image FROM users WHERE id = ?', [req.user.id]);
|
||||
res.json({ user: updated });
|
||||
} catch (err) {
|
||||
console.error('Avatar delete error:', err);
|
||||
res.status(500).json({ error: 'Avatar konnte nicht entfernt werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/avatar/:filename - Serve avatar image
|
||||
router.get('/avatar/:filename', (req, res) => {
|
||||
const filepath = path.join(uploadsDir, req.params.filename);
|
||||
if (!fs.existsSync(filepath)) {
|
||||
return res.status(404).json({ error: 'Avatar nicht gefunden' });
|
||||
}
|
||||
const ext = path.extname(filepath).slice(1);
|
||||
const mimeMap = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp' };
|
||||
res.setHeader('Content-Type', mimeMap[ext] || 'image/jpeg');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
fs.createReadStream(filepath).pipe(res);
|
||||
});
|
||||
|
||||
export default router;
|
||||
120
server/routes/recordings.js
Normal file
120
server/routes/recordings.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import { getDb } from '../config/database.js';
|
||||
import {
|
||||
getRecordings,
|
||||
deleteRecording,
|
||||
publishRecording,
|
||||
} from '../config/bbb.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/recordings - Get recordings for a room (by meetingID/uid)
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { meetingID } = req.query;
|
||||
const recordings = await getRecordings(meetingID || undefined);
|
||||
|
||||
// Format recordings
|
||||
const formatted = recordings.map(rec => {
|
||||
const playback = rec.playback?.format;
|
||||
let formats = [];
|
||||
if (playback) {
|
||||
formats = Array.isArray(playback) ? playback : [playback];
|
||||
}
|
||||
|
||||
return {
|
||||
recordID: rec.recordID,
|
||||
meetingID: rec.meetingID,
|
||||
name: rec.name || 'Aufnahme',
|
||||
state: rec.state,
|
||||
published: rec.published === 'true',
|
||||
startTime: rec.startTime,
|
||||
endTime: rec.endTime,
|
||||
participants: rec.participants,
|
||||
size: rec.size,
|
||||
formats: formats.map(f => ({
|
||||
type: f.type,
|
||||
url: f.url,
|
||||
length: f.length,
|
||||
size: f.size,
|
||||
})),
|
||||
metadata: rec.metadata || {},
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ recordings: formatted });
|
||||
} catch (err) {
|
||||
console.error('Get recordings error:', err);
|
||||
res.status(500).json({ error: 'Aufnahmen konnten nicht geladen werden', recordings: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/recordings/room/:uid - Get recordings for a specific room
|
||||
router.get('/room/:uid', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
}
|
||||
|
||||
const recordings = await getRecordings(room.uid);
|
||||
const formatted = recordings.map(rec => {
|
||||
const playback = rec.playback?.format;
|
||||
let formats = [];
|
||||
if (playback) {
|
||||
formats = Array.isArray(playback) ? playback : [playback];
|
||||
}
|
||||
|
||||
return {
|
||||
recordID: rec.recordID,
|
||||
meetingID: rec.meetingID,
|
||||
name: rec.name || room.name,
|
||||
state: rec.state,
|
||||
published: rec.published === 'true',
|
||||
startTime: rec.startTime,
|
||||
endTime: rec.endTime,
|
||||
participants: rec.participants,
|
||||
size: rec.size,
|
||||
formats: formats.map(f => ({
|
||||
type: f.type,
|
||||
url: f.url,
|
||||
length: f.length,
|
||||
size: f.size,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
res.json({ recordings: formatted });
|
||||
} catch (err) {
|
||||
console.error('Get room recordings error:', err);
|
||||
res.status(500).json({ error: 'Aufnahmen konnten nicht geladen werden', recordings: [] });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/recordings/:recordID
|
||||
router.delete('/:recordID', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
await deleteRecording(req.params.recordID);
|
||||
res.json({ message: 'Aufnahme gelöscht' });
|
||||
} catch (err) {
|
||||
console.error('Delete recording error:', err);
|
||||
res.status(500).json({ error: 'Aufnahme konnte nicht gelöscht werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/recordings/:recordID/publish
|
||||
router.put('/:recordID/publish', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { publish } = req.body;
|
||||
await publishRecording(req.params.recordID, publish);
|
||||
res.json({ message: publish ? 'Aufnahme veröffentlicht' : 'Aufnahme nicht mehr öffentlich' });
|
||||
} catch (err) {
|
||||
console.error('Publish recording error:', err);
|
||||
res.status(500).json({ error: 'Aufnahme konnte nicht aktualisiert werden' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
368
server/routes/rooms.js
Normal file
368
server/routes/rooms.js
Normal file
@@ -0,0 +1,368 @@
|
||||
import { Router } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { getDb } from '../config/database.js';
|
||||
import { authenticateToken } from '../middleware/auth.js';
|
||||
import {
|
||||
createMeeting,
|
||||
joinMeeting,
|
||||
endMeeting,
|
||||
getMeetingInfo,
|
||||
isMeetingRunning,
|
||||
} from '../config/bbb.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/rooms - List user's rooms
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const rooms = await db.all(`
|
||||
SELECT r.*, u.name as owner_name
|
||||
FROM rooms r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
WHERE r.user_id = ?
|
||||
ORDER BY r.created_at DESC
|
||||
`, [req.user.id]);
|
||||
|
||||
res.json({ rooms });
|
||||
} catch (err) {
|
||||
console.error('List rooms error:', err);
|
||||
res.status(500).json({ error: 'Räume konnten nicht geladen werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/rooms/:uid - Get room details
|
||||
router.get('/:uid', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get(`
|
||||
SELECT r.*, u.name as owner_name
|
||||
FROM rooms r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
WHERE r.uid = ?
|
||||
`, [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json({ room });
|
||||
} catch (err) {
|
||||
console.error('Get room error:', err);
|
||||
res.status(500).json({ error: 'Raum konnte nicht geladen werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rooms - Create room
|
||||
router.post('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
welcome_message,
|
||||
max_participants,
|
||||
access_code,
|
||||
mute_on_join,
|
||||
require_approval,
|
||||
anyone_can_start,
|
||||
all_join_moderator,
|
||||
record_meeting,
|
||||
guest_access,
|
||||
moderator_code,
|
||||
} = req.body;
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'Raumname ist erforderlich' });
|
||||
}
|
||||
|
||||
const uid = crypto.randomBytes(8).toString('hex');
|
||||
const db = getDb();
|
||||
|
||||
const result = await db.run(`
|
||||
INSERT INTO rooms (uid, name, user_id, welcome_message, max_participants, access_code, mute_on_join, require_approval, anyone_can_start, all_join_moderator, record_meeting, guest_access, moderator_code)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
uid,
|
||||
name.trim(),
|
||||
req.user.id,
|
||||
welcome_message || 'Willkommen im Meeting!',
|
||||
max_participants || 0,
|
||||
access_code || null,
|
||||
mute_on_join !== false ? 1 : 0,
|
||||
require_approval ? 1 : 0,
|
||||
anyone_can_start ? 1 : 0,
|
||||
all_join_moderator ? 1 : 0,
|
||||
record_meeting !== false ? 1 : 0,
|
||||
guest_access ? 1 : 0,
|
||||
moderator_code || null,
|
||||
]);
|
||||
|
||||
const room = await db.get('SELECT * FROM rooms WHERE id = ?', [result.lastInsertRowid]);
|
||||
res.status(201).json({ room });
|
||||
} catch (err) {
|
||||
console.error('Create room error:', err);
|
||||
res.status(500).json({ error: 'Raum konnte nicht erstellt werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/rooms/:uid - Update room
|
||||
router.put('/:uid', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
welcome_message,
|
||||
max_participants,
|
||||
access_code,
|
||||
mute_on_join,
|
||||
require_approval,
|
||||
anyone_can_start,
|
||||
all_join_moderator,
|
||||
record_meeting,
|
||||
guest_access,
|
||||
moderator_code,
|
||||
} = req.body;
|
||||
|
||||
await db.run(`
|
||||
UPDATE rooms SET
|
||||
name = COALESCE(?, name),
|
||||
welcome_message = COALESCE(?, welcome_message),
|
||||
max_participants = COALESCE(?, max_participants),
|
||||
access_code = ?,
|
||||
mute_on_join = COALESCE(?, mute_on_join),
|
||||
require_approval = COALESCE(?, require_approval),
|
||||
anyone_can_start = COALESCE(?, anyone_can_start),
|
||||
all_join_moderator = COALESCE(?, all_join_moderator),
|
||||
record_meeting = COALESCE(?, record_meeting),
|
||||
guest_access = COALESCE(?, guest_access),
|
||||
moderator_code = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE uid = ?
|
||||
`, [
|
||||
name,
|
||||
welcome_message,
|
||||
max_participants,
|
||||
access_code ?? room.access_code,
|
||||
mute_on_join !== undefined ? (mute_on_join ? 1 : 0) : null,
|
||||
require_approval !== undefined ? (require_approval ? 1 : 0) : null,
|
||||
anyone_can_start !== undefined ? (anyone_can_start ? 1 : 0) : null,
|
||||
all_join_moderator !== undefined ? (all_join_moderator ? 1 : 0) : null,
|
||||
record_meeting !== undefined ? (record_meeting ? 1 : 0) : null,
|
||||
guest_access !== undefined ? (guest_access ? 1 : 0) : null,
|
||||
moderator_code !== undefined ? (moderator_code || null) : room.moderator_code,
|
||||
req.params.uid,
|
||||
]);
|
||||
|
||||
const updated = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
res.json({ room: updated });
|
||||
} catch (err) {
|
||||
console.error('Update room error:', err);
|
||||
res.status(500).json({ error: 'Raum konnte nicht aktualisiert werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/rooms/:uid - Delete room
|
||||
router.delete('/:uid', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
}
|
||||
|
||||
if (room.user_id !== req.user.id && req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Keine Berechtigung' });
|
||||
}
|
||||
|
||||
await db.run('DELETE FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
res.json({ message: 'Raum erfolgreich gelöscht' });
|
||||
} catch (err) {
|
||||
console.error('Delete room error:', err);
|
||||
res.status(500).json({ error: 'Raum konnte nicht gelöscht werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rooms/:uid/start - Start meeting
|
||||
router.post('/:uid/start', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
|
||||
}
|
||||
|
||||
await createMeeting(room);
|
||||
const joinUrl = await joinMeeting(room.uid, req.user.name, true);
|
||||
res.json({ joinUrl });
|
||||
} catch (err) {
|
||||
console.error('Start meeting error:', err);
|
||||
res.status(500).json({ error: 'Meeting konnte nicht gestartet werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rooms/:uid/join - Join meeting
|
||||
router.post('/:uid/join', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
}
|
||||
|
||||
// Check access code if set
|
||||
if (room.access_code && req.body.access_code !== room.access_code) {
|
||||
return res.status(403).json({ error: 'Falscher Zugangscode' });
|
||||
}
|
||||
|
||||
// Check if meeting is running
|
||||
const running = await isMeetingRunning(room.uid);
|
||||
if (!running) {
|
||||
return res.status(400).json({ error: 'Meeting läuft nicht. Bitte warten Sie, bis der Moderator das Meeting gestartet hat.' });
|
||||
}
|
||||
|
||||
const isModerator = room.user_id === req.user.id || room.all_join_moderator;
|
||||
const joinUrl = await joinMeeting(room.uid, req.user.name, isModerator);
|
||||
res.json({ joinUrl });
|
||||
} catch (err) {
|
||||
console.error('Join meeting error:', err);
|
||||
res.status(500).json({ error: 'Meeting konnte nicht beigetreten werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rooms/:uid/end - End meeting
|
||||
router.post('/:uid/end', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ? AND user_id = ?', [req.params.uid, req.user.id]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden oder keine Berechtigung' });
|
||||
}
|
||||
|
||||
await endMeeting(room.uid);
|
||||
res.json({ message: 'Meeting beendet' });
|
||||
} catch (err) {
|
||||
console.error('End meeting error:', err);
|
||||
res.status(500).json({ error: 'Meeting konnte nicht beendet werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/rooms/:uid/public - Get public room info (no auth needed)
|
||||
router.get('/:uid/public', async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const room = await db.get(`
|
||||
SELECT r.uid, r.name, r.guest_access, r.welcome_message, r.access_code,
|
||||
u.name as owner_name
|
||||
FROM rooms r
|
||||
JOIN users u ON r.user_id = u.id
|
||||
WHERE r.uid = ?
|
||||
`, [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
}
|
||||
|
||||
if (!room.guest_access) {
|
||||
return res.status(403).json({ error: 'Gastzugang ist für diesen Raum nicht aktiviert' });
|
||||
}
|
||||
|
||||
const running = await isMeetingRunning(room.uid);
|
||||
|
||||
res.json({
|
||||
room: {
|
||||
uid: room.uid,
|
||||
name: room.name,
|
||||
owner_name: room.owner_name,
|
||||
welcome_message: room.welcome_message,
|
||||
has_access_code: !!room.access_code,
|
||||
},
|
||||
running,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Public room info error:', err);
|
||||
res.status(500).json({ error: 'Rauminfos konnten nicht geladen werden' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rooms/:uid/guest-join - Join meeting as guest (no auth needed)
|
||||
router.post('/:uid/guest-join', async (req, res) => {
|
||||
try {
|
||||
const { name, access_code, moderator_code } = req.body;
|
||||
|
||||
if (!name || name.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'Name ist erforderlich' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const room = await db.get('SELECT * FROM rooms WHERE uid = ?', [req.params.uid]);
|
||||
|
||||
if (!room) {
|
||||
return res.status(404).json({ error: 'Raum nicht gefunden' });
|
||||
}
|
||||
|
||||
if (!room.guest_access) {
|
||||
return res.status(403).json({ error: 'Gastzugang ist für diesen Raum nicht aktiviert' });
|
||||
}
|
||||
|
||||
// Check access code if set
|
||||
if (room.access_code && access_code !== room.access_code) {
|
||||
return res.status(403).json({ error: 'Falscher Zugangscode' });
|
||||
}
|
||||
|
||||
// Check if meeting is running (or if anyone_can_start is enabled)
|
||||
const running = await isMeetingRunning(room.uid);
|
||||
if (!running && !room.anyone_can_start) {
|
||||
return res.status(400).json({ error: 'Meeting läuft nicht. Bitte warten Sie, bis der Moderator das Meeting gestartet hat.' });
|
||||
}
|
||||
|
||||
// If meeting not running but anyone_can_start, create it
|
||||
if (!running && room.anyone_can_start) {
|
||||
await createMeeting(room);
|
||||
}
|
||||
|
||||
// Check moderator code
|
||||
let isModerator = !!room.all_join_moderator;
|
||||
if (!isModerator && moderator_code && room.moderator_code && moderator_code === room.moderator_code) {
|
||||
isModerator = true;
|
||||
}
|
||||
|
||||
const joinUrl = await joinMeeting(room.uid, name.trim(), isModerator);
|
||||
res.json({ joinUrl });
|
||||
} catch (err) {
|
||||
console.error('Guest join error:', err);
|
||||
res.status(500).json({ error: 'Beitritt als Gast fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/rooms/:uid/status - Check if meeting is running (public, no guard needed)
|
||||
router.get('/:uid/status', async (req, res) => {
|
||||
try {
|
||||
const running = await isMeetingRunning(req.params.uid);
|
||||
let info = null;
|
||||
if (running) {
|
||||
try {
|
||||
info = await getMeetingInfo(req.params.uid);
|
||||
} catch (e) {
|
||||
// Meeting info might fail
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
running,
|
||||
participantCount: info?.participantCount || 0,
|
||||
moderatorCount: info?.moderatorCount || 0,
|
||||
});
|
||||
} catch (err) {
|
||||
res.json({ running: false, participantCount: 0, moderatorCount: 0 });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
45
src/App.jsx
Normal file
45
src/App.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from './contexts/AuthContext';
|
||||
import Layout from './components/Layout';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import Home from './pages/Home';
|
||||
import Login from './pages/Login';
|
||||
import Register from './pages/Register';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import RoomDetail from './pages/RoomDetail';
|
||||
import Settings from './pages/Settings';
|
||||
import Admin from './pages/Admin';
|
||||
import GuestJoin from './pages/GuestJoin';
|
||||
|
||||
export default function App() {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-th-bg flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-th-accent border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/" element={user ? <Navigate to="/dashboard" /> : <Home />} />
|
||||
<Route path="/login" element={user ? <Navigate to="/dashboard" /> : <Login />} />
|
||||
<Route path="/register" element={user ? <Navigate to="/dashboard" /> : <Register />} />
|
||||
<Route path="/join/:uid" element={<GuestJoin />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/rooms/:uid" element={<RoomDetail />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
</Route>
|
||||
|
||||
{/* Catch all */}
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
31
src/components/Layout.jsx
Normal file
31
src/components/Layout.jsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import Navbar from './Navbar';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
export default function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-th-bg flex">
|
||||
{/* Sidebar */}
|
||||
<Sidebar open={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-h-screen lg:ml-64">
|
||||
<Navbar onMenuClick={() => setSidebarOpen(true)} />
|
||||
<main className="flex-1 p-4 md:p-6 lg:p-8 max-w-7xl w-full mx-auto">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-30 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/components/Modal.jsx
Normal file
25
src/components/Modal.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
export default function Modal({ title, children, onClose, maxWidth = 'max-w-lg' }) {
|
||||
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} overflow-hidden`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-th-border">
|
||||
<h2 className="text-lg font-semibold text-th-text">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src/components/Navbar.jsx
Normal file
118
src/components/Navbar.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Menu, Search, LogOut, User } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
|
||||
export default function Navbar({ onMenuClick }) {
|
||||
const { user, logout } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClick(e) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, []);
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const initials = user?.name
|
||||
? user.name
|
||||
.split(' ')
|
||||
.map(n => n[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
: '?';
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-20 bg-th-nav border-b border-th-border backdrop-blur-sm">
|
||||
<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}
|
||||
className="lg:hidden p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
{/* Search */}
|
||||
<div className="hidden md:flex items-center gap-2 bg-th-bg-s border border-th-border rounded-lg px-3 py-2 w-64 lg:w-80">
|
||||
<Search size={16} className="text-th-text-s flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('common.search')}
|
||||
className="bg-transparent border-none outline-none text-sm text-th-text placeholder-th-text-s w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right section */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* User dropdown */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
className="flex items-center gap-2 p-1.5 rounded-lg hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold overflow-hidden"
|
||||
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
|
||||
>
|
||||
{user?.avatar_image ? (
|
||||
<img
|
||||
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
|
||||
alt="Avatar"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden md:block text-sm font-medium text-th-text">
|
||||
{user?.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 mt-2 w-56 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-th-border">
|
||||
<p className="text-sm font-medium text-th-text">{user?.name}</p>
|
||||
<p className="text-xs text-th-text-s">{user?.email}</p>
|
||||
</div>
|
||||
<div className="py-1">
|
||||
<button
|
||||
onClick={() => { navigate('/settings'); setDropdownOpen(false); }}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<User size={16} />
|
||||
{t('nav.settings')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-error hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
{t('auth.logout')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
20
src/components/ProtectedRoute.jsx
Normal file
20
src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
export default function ProtectedRoute({ children }) {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-th-bg flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-4 border-th-accent border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
159
src/components/RecordingList.jsx
Normal file
159
src/components/RecordingList.jsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Play, Trash2, Eye, EyeOff, Download, Clock, Users, FileVideo } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function RecordingList({ recordings, onRefresh }) {
|
||||
const [loading, setLoading] = useState({});
|
||||
const { t, language } = useLanguage();
|
||||
|
||||
const formatDuration = (startTime, endTime) => {
|
||||
if (!startTime || !endTime) return '—';
|
||||
const ms = parseInt(endTime) - parseInt(startTime);
|
||||
const minutes = Math.floor(ms / 60000);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
if (hours > 0) return `${hours}h ${mins}m`;
|
||||
return `${mins}m`;
|
||||
};
|
||||
|
||||
const formatDate = (timestamp) => {
|
||||
if (!timestamp) return '—';
|
||||
return new Date(parseInt(timestamp)).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '';
|
||||
const mb = parseInt(bytes) / (1024 * 1024);
|
||||
if (mb > 1024) return `${(mb / 1024).toFixed(1)} GB`;
|
||||
return `${mb.toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
const handleDelete = async (recordID) => {
|
||||
if (!confirm(t('recordings.deleteConfirm'))) return;
|
||||
setLoading(prev => ({ ...prev, [recordID]: 'deleting' }));
|
||||
try {
|
||||
await api.delete(`/recordings/${recordID}`);
|
||||
toast.success(t('recordings.deleted'));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
toast.error(t('recordings.deleteFailed'));
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, [recordID]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async (recordID, publish) => {
|
||||
setLoading(prev => ({ ...prev, [recordID]: 'publishing' }));
|
||||
try {
|
||||
await api.put(`/recordings/${recordID}/publish`, { publish });
|
||||
toast.success(publish ? t('recordings.publishSuccess') : t('recordings.unpublishSuccess'));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
toast.error(t('recordings.publishFailed'));
|
||||
} finally {
|
||||
setLoading(prev => ({ ...prev, [recordID]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
if (!recordings || recordings.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<FileVideo size={48} className="mx-auto text-th-text-s/40 mb-3" />
|
||||
<p className="text-th-text-s text-sm">{t('recordings.noRecordings')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{recordings.map(rec => (
|
||||
<div key={rec.recordID} className="card p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="text-sm font-medium text-th-text truncate">
|
||||
{rec.name}
|
||||
</h4>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-[10px] font-medium ${
|
||||
rec.published
|
||||
? 'bg-th-success/15 text-th-success'
|
||||
: 'bg-th-warning/15 text-th-warning'
|
||||
}`}
|
||||
>
|
||||
{rec.published ? t('recordings.published') : t('recordings.unpublished')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-th-text-s">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
{formatDate(rec.startTime)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Play size={12} />
|
||||
{formatDuration(rec.startTime, rec.endTime)}
|
||||
</span>
|
||||
{rec.participants && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={12} />
|
||||
{rec.participants}
|
||||
</span>
|
||||
)}
|
||||
{rec.size && (
|
||||
<span>{formatSize(rec.size)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Playback formats */}
|
||||
{rec.formats && rec.formats.length > 0 && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
{rec.formats.map((format, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={format.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg bg-th-accent/10 text-th-accent text-xs font-medium hover:bg-th-accent/20 transition-colors"
|
||||
>
|
||||
<Play size={12} />
|
||||
{format.type === 'presentation' ? t('recordings.presentation') : format.type}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handlePublish(rec.recordID, !rec.published)}
|
||||
disabled={loading[rec.recordID] === 'publishing'}
|
||||
className="btn-ghost text-xs py-1.5 px-2"
|
||||
title={rec.published ? t('recordings.hide') : t('recordings.publish')}
|
||||
>
|
||||
{rec.published ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(rec.recordID)}
|
||||
disabled={loading[rec.recordID] === 'deleting'}
|
||||
className="btn-ghost text-xs py-1.5 px-2 text-th-error"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/components/RoomCard.jsx
Normal file
108
src/components/RoomCard.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Users, Play, Trash2, Radio, Loader2 } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function RoomCard({ room, onDelete }) {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useLanguage();
|
||||
const [status, setStatus] = useState({ running: false, participantCount: 0 });
|
||||
const [starting, setStarting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const res = await api.get(`/rooms/${room.uid}/status`);
|
||||
setStatus(res.data);
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
};
|
||||
checkStatus();
|
||||
const interval = setInterval(checkStatus, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, [room.uid]);
|
||||
|
||||
return (
|
||||
<div className="card-hover group p-5 cursor-pointer" onClick={() => navigate(`/rooms/${room.uid}`)}>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-th-text truncate group-hover:text-th-accent transition-colors">
|
||||
{room.name}
|
||||
</h3>
|
||||
{status.running && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 bg-th-success/15 text-th-success rounded-full text-xs font-medium">
|
||||
<Radio size={10} className="animate-pulse" />
|
||||
{t('common.live')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s mt-0.5">
|
||||
{room.uid.substring(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room info */}
|
||||
<div className="flex items-center gap-4 mb-4 text-xs text-th-text-s">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={14} />
|
||||
{status.running ? t('room.participants', { count: status.participantCount }) : t('common.offline')}
|
||||
</span>
|
||||
{room.max_participants > 0 && (
|
||||
<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">
|
||||
{t('common.protected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-3 border-t border-th-border">
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
setStarting(true);
|
||||
try {
|
||||
if (status.running) {
|
||||
const data = room.access_code ? { 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`);
|
||||
if (res.data.joinUrl) window.open(res.data.joinUrl, '_blank');
|
||||
toast.success(t('room.meetingStarted'));
|
||||
setTimeout(() => {
|
||||
api.get(`/rooms/${room.uid}/status`).then(r => setStatus(r.data)).catch(() => {});
|
||||
}, 2000);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('room.meetingStartFailed'));
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}}
|
||||
disabled={starting}
|
||||
className="btn-primary text-xs py-1.5 px-3 flex-1"
|
||||
>
|
||||
{starting ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
|
||||
{status.running ? t('room.join') : t('room.startMeeting')}
|
||||
</button>
|
||||
{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"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
src/components/Sidebar.jsx
Normal file
108
src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { LayoutDashboard, Settings, Shield, Video, X, Palette } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import ThemeSelector from './ThemeSelector';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Sidebar({ open, onClose }) {
|
||||
const { user } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const [themeOpen, setThemeOpen] = useState(false);
|
||||
|
||||
const navItems = [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: t('nav.dashboard') },
|
||||
{ to: '/settings', icon: Settings, label: t('nav.settings') },
|
||||
];
|
||||
|
||||
if (user?.role === 'admin') {
|
||||
navItems.push({ to: '/admin', icon: Shield, label: t('nav.admin') });
|
||||
}
|
||||
|
||||
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'
|
||||
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
className={`fixed top-0 left-0 z-40 h-full w-64 bg-th-side border-r border-th-border
|
||||
transition-transform duration-300 ease-in-out
|
||||
${open ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0`}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-between h-16 px-4 border-b border-th-border">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 gradient-bg rounded-lg flex items-center justify-center">
|
||||
<Video size={18} className="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-bold gradient-text">Redlight</h1>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||
<p className="px-3 mb-2 text-xs font-semibold text-th-text-s uppercase tracking-wider">
|
||||
{t('nav.navigation')}
|
||||
</p>
|
||||
{navItems.map(item => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={linkClasses}
|
||||
onClick={onClose}
|
||||
>
|
||||
<item.icon size={18} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
<div className="pt-4">
|
||||
<p className="px-3 mb-2 text-xs font-semibold text-th-text-s uppercase tracking-wider">
|
||||
{t('nav.appearance')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setThemeOpen(!themeOpen)}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium text-th-text-s hover:text-th-text hover:bg-th-hover transition-all duration-200"
|
||||
>
|
||||
<Palette size={18} />
|
||||
{t('nav.changeTheme')}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* User info */}
|
||||
<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"
|
||||
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
|
||||
>
|
||||
{user?.name?.[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-th-text truncate">{user?.name}</p>
|
||||
<p className="text-xs text-th-text-s truncate">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Theme Selector Modal */}
|
||||
{themeOpen && <ThemeSelector onClose={() => setThemeOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
99
src/components/ThemeSelector.jsx
Normal file
99
src/components/ThemeSelector.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { X, Check, Sun, Moon } from 'lucide-react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { getThemeGroups } from '../themes';
|
||||
|
||||
export default function ThemeSelector({ onClose }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useLanguage();
|
||||
const groups = getThemeGroups();
|
||||
|
||||
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 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>
|
||||
<p className="text-sm text-th-text-s mt-0.5">{t('themes.selectThemeSubtitle')}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Theme Grid */}
|
||||
<div className="p-6 overflow-y-auto max-h-[60vh] space-y-6">
|
||||
{Object.entries(groups).map(([groupName, groupThemes]) => (
|
||||
<div key={groupName}>
|
||||
<h3 className="text-sm font-semibold text-th-text-s uppercase tracking-wider mb-3">
|
||||
{groupName}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{groupThemes.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTheme(t.id)}
|
||||
className={`relative group rounded-xl p-3 border-2 transition-all duration-200 text-left ${
|
||||
theme === t.id
|
||||
? 'border-th-accent shadow-lg scale-[1.02]'
|
||||
: 'border-transparent hover:border-th-border hover:shadow-md'
|
||||
}`}
|
||||
style={{ backgroundColor: t.colors.bg }}
|
||||
>
|
||||
{/* Color preview */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div
|
||||
className="w-full h-8 rounded-lg flex items-center px-3 gap-2"
|
||||
style={{ backgroundColor: t.colors.bg }}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: t.colors.accent }}
|
||||
/>
|
||||
<div
|
||||
className="h-1.5 rounded-full flex-1"
|
||||
style={{ backgroundColor: t.colors.text, opacity: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme info */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{t.type === 'light' ? (
|
||||
<Sun size={12} style={{ color: t.colors.text }} />
|
||||
) : (
|
||||
<Moon size={12} style={{ color: t.colors.text }} />
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: t.colors.text }}
|
||||
>
|
||||
{t.name}
|
||||
</span>
|
||||
</div>
|
||||
{theme === t.id && (
|
||||
<div
|
||||
className="w-5 h-5 rounded-full flex items-center justify-center"
|
||||
style={{ backgroundColor: t.colors.accent }}
|
||||
>
|
||||
<Check size={12} className="text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/contexts/AuthContext.jsx
Normal file
60
src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import api from '../services/api';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
api.get('/auth/me')
|
||||
.then(res => setUser(res.data.user))
|
||||
.catch(() => {
|
||||
localStorage.removeItem('token');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email, password) => {
|
||||
const res = await api.post('/auth/login', { email, password });
|
||||
localStorage.setItem('token', res.data.token);
|
||||
setUser(res.data.user);
|
||||
return res.data.user;
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (name, email, password) => {
|
||||
const res = await api.post('/auth/register', { name, email, password });
|
||||
localStorage.setItem('token', res.data.token);
|
||||
setUser(res.data.user);
|
||||
return res.data.user;
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const updateUser = useCallback((updatedUser) => {
|
||||
setUser(updatedUser);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, updateUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
46
src/contexts/LanguageContext.jsx
Normal file
46
src/contexts/LanguageContext.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { getTranslation, interpolate, pluralize, defaultLanguage } from '../i18n';
|
||||
|
||||
const LanguageContext = createContext(null);
|
||||
|
||||
export function LanguageProvider({ children }) {
|
||||
const [language, setLanguageState] = useState(() => {
|
||||
return localStorage.getItem('language') || defaultLanguage;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('language', language);
|
||||
document.documentElement.setAttribute('lang', language);
|
||||
}, [language]);
|
||||
|
||||
const setLanguage = useCallback((lang) => {
|
||||
setLanguageState(lang);
|
||||
}, []);
|
||||
|
||||
// t('auth.login') -> translated string
|
||||
// t('dashboard.roomCount', { count: 5 }) -> with interpolation
|
||||
const t = useCallback((key, params) => {
|
||||
const raw = getTranslation(language, key);
|
||||
if (params && params.count !== undefined) {
|
||||
return pluralize(raw, params.count);
|
||||
}
|
||||
if (params) {
|
||||
return interpolate(raw, params);
|
||||
}
|
||||
return raw;
|
||||
}, [language]);
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLanguage, t }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLanguage() {
|
||||
const context = useContext(LanguageContext);
|
||||
if (!context) {
|
||||
throw new Error('useLanguage must be used within a LanguageProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
32
src/contexts/ThemeContext.jsx
Normal file
32
src/contexts/ThemeContext.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const ThemeContext = createContext(null);
|
||||
|
||||
export function ThemeProvider({ children }) {
|
||||
const [theme, setThemeState] = useState(() => {
|
||||
return localStorage.getItem('theme') || 'dark';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = useCallback((newTheme) => {
|
||||
setThemeState(newTheme);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
244
src/i18n/de.json
Normal file
244
src/i18n/de.json
Normal file
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "Redlight",
|
||||
"loading": "Laden...",
|
||||
"save": "Speichern",
|
||||
"cancel": "Abbrechen",
|
||||
"delete": "Löschen",
|
||||
"edit": "Bearbeiten",
|
||||
"create": "Erstellen",
|
||||
"search": "Suchen...",
|
||||
"close": "Schließen",
|
||||
"confirm": "Bestätigen",
|
||||
"back": "Zurück",
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
"or": "oder",
|
||||
"optional": "Optional",
|
||||
"unlimited": "Unbegrenzt",
|
||||
"none": "Keiner",
|
||||
"offline": "Offline",
|
||||
"active": "Aktiv",
|
||||
"inactive": "Inaktiv",
|
||||
"protected": "Geschützt",
|
||||
"live": "Live",
|
||||
"error": "Fehler",
|
||||
"success": "Erfolg"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"settings": "Einstellungen",
|
||||
"admin": "Administration",
|
||||
"appearance": "Darstellung",
|
||||
"changeTheme": "Theme ändern",
|
||||
"navigation": "Navigation"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
"register": "Registrieren",
|
||||
"logout": "Abmelden",
|
||||
"email": "E-Mail",
|
||||
"password": "Passwort",
|
||||
"name": "Name",
|
||||
"welcomeBack": "Willkommen zurück",
|
||||
"loginSubtitle": "Melden Sie sich an, um auf Ihre Räume zuzugreifen.",
|
||||
"createAccount": "Konto erstellen",
|
||||
"registerSubtitle": "Registrieren Sie sich, um Räume zu erstellen und Meetings zu starten.",
|
||||
"noAccount": "Noch kein Konto?",
|
||||
"hasAccount": "Bereits ein Konto?",
|
||||
"signUpNow": "Jetzt registrieren",
|
||||
"signInNow": "Jetzt anmelden",
|
||||
"backToHome": "← Zurück zur Startseite",
|
||||
"emailPlaceholder": "name@beispiel.de",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"namePlaceholder": "Max Mustermann",
|
||||
"minPassword": "Mindestens 6 Zeichen",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"repeatPassword": "Passwort wiederholen",
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||
"passwordTooShort": "Passwort muss mindestens 6 Zeichen lang sein",
|
||||
"loginSuccess": "Willkommen zurück!",
|
||||
"registerSuccess": "Registrierung erfolgreich!",
|
||||
"loginFailed": "Anmeldung fehlgeschlagen",
|
||||
"registerFailed": "Registrierung fehlgeschlagen",
|
||||
"allFieldsRequired": "Alle Felder sind erforderlich"
|
||||
},
|
||||
"home": {
|
||||
"poweredBy": "Powered by BigBlueButton",
|
||||
"heroTitle": "Meetings neu ",
|
||||
"heroTitleHighlight": "definiert",
|
||||
"heroSubtitle": "Das moderne, selbst gehostete BigBlueButton-Frontend. Erstellen Sie Räume, verwalten Sie Aufnahmen 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.",
|
||||
"featureRoomsTitle": "Raumverwaltung",
|
||||
"featureRoomsDesc": "Unbegrenzte Räume mit individuellen Einstellungen und Zugangscodes.",
|
||||
"featureUsersTitle": "Benutzerverwaltung",
|
||||
"featureUsersDesc": "Registrierung, Login und Rollenverwaltung für Ihre Organisation.",
|
||||
"featureThemesTitle": "15+ Themes",
|
||||
"featureThemesDesc": "Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox und viele mehr.",
|
||||
"featureRecordingsTitle": "Aufnahmen",
|
||||
"featureRecordingsDesc": "Alle Aufnahmen pro Raum einsehen, veröffentlichen oder löschen.",
|
||||
"featureOpenSourceTitle": "Open Source",
|
||||
"featureOpenSourceDesc": "Vollständig quelloffen und selbst gehostet. Ihre Daten bleiben bei Ihnen.",
|
||||
"statThemes": "Themes",
|
||||
"statRooms": "Räume",
|
||||
"statOpenSource": "Open Source",
|
||||
"footer": "© {year} Redlight. Ein Open-Source BigBlueButton Frontend."
|
||||
},
|
||||
"dashboard": {
|
||||
"myRooms": "Meine Räume",
|
||||
"roomCount": "{count} Raum erstellt | {count} Räume erstellt",
|
||||
"newRoom": "Neuer Raum",
|
||||
"noRooms": "Noch keine Räume",
|
||||
"noRoomsSubtitle": "Erstellen Sie Ihren ersten Raum, um Meetings zu starten.",
|
||||
"createFirst": "Ersten Raum erstellen",
|
||||
"createRoom": "Neuen Raum erstellen",
|
||||
"roomName": "Raumname",
|
||||
"roomNamePlaceholder": "z.B. Team Meeting",
|
||||
"roomNameRequired": "Raumname ist erforderlich",
|
||||
"welcomeMessage": "Willkommensnachricht",
|
||||
"welcomeMessageDefault": "Willkommen im Meeting!",
|
||||
"maxParticipants": "Max. Teilnehmer",
|
||||
"maxParticipantsHint": "0 = unbegrenzt",
|
||||
"accessCode": "Zugangscode",
|
||||
"muteOnJoin": "Teilnehmer beim Beitritt stummschalten",
|
||||
"allowRecording": "Aufnahme erlauben",
|
||||
"roomCreated": "Raum erstellt!",
|
||||
"roomCreateFailed": "Raum konnte nicht erstellt werden",
|
||||
"roomDeleted": "Raum gelöscht",
|
||||
"roomDeleteFailed": "Raum konnte nicht gelöscht werden",
|
||||
"roomDeleteConfirm": "Raum \"{name}\" wirklich löschen?",
|
||||
"loadFailed": "Räume konnten nicht geladen werden"
|
||||
},
|
||||
"room": {
|
||||
"backToDashboard": "Zurück zum Dashboard",
|
||||
"start": "Starten",
|
||||
"startMeeting": "Meeting starten",
|
||||
"join": "Beitreten",
|
||||
"end": "Beenden",
|
||||
"openDetails": "Details öffnen",
|
||||
"overview": "Übersicht",
|
||||
"recordings": "Aufnahmen",
|
||||
"settings": "Einstellungen",
|
||||
"participants": "{count} Teilnehmer",
|
||||
"copyLink": "Link kopieren",
|
||||
"linkCopied": "Link kopiert!",
|
||||
"meetingDetails": "Meeting-Details",
|
||||
"meetingId": "Meeting ID",
|
||||
"status": "Status",
|
||||
"maxParticipants": "Max. Teilnehmer",
|
||||
"accessCode": "Zugangscode",
|
||||
"roomSettings": "Raumeinstellungen",
|
||||
"mutedOnJoin": "Beim Beitritt stummgeschaltet",
|
||||
"micActiveOnJoin": "Mikrofon aktiv beim Beitritt",
|
||||
"approvalRequired": "Genehmigung erforderlich",
|
||||
"freeJoin": "Freier Beitritt",
|
||||
"allModerators": "Alle als Moderator",
|
||||
"rolesAssigned": "Rollen werden zugewiesen",
|
||||
"recordingAllowed": "Aufnahme erlaubt",
|
||||
"recordingDisabled": "Aufnahme deaktiviert",
|
||||
"welcomeMsg": "Willkommensnachricht",
|
||||
"muteOnJoin": "Beim Beitritt stummschalten",
|
||||
"requireApproval": "Moderator-Genehmigung erforderlich",
|
||||
"anyoneCanStart": "Jeder kann das Meeting starten",
|
||||
"allJoinModerator": "Alle Teilnehmer als Moderator",
|
||||
"allowRecording": "Aufnahme erlauben",
|
||||
"noAccessCode": "Kein Zugangscode",
|
||||
"emptyNoCode": "Leer = kein Code",
|
||||
"settingsSaved": "Einstellungen gespeichert",
|
||||
"settingsSaveFailed": "Einstellungen konnten nicht gespeichert werden",
|
||||
"meetingStarted": "Meeting gestartet!",
|
||||
"meetingStartFailed": "Meeting konnte nicht gestartet werden",
|
||||
"meetingEnded": "Meeting beendet",
|
||||
"meetingEndFailed": "Meeting konnte nicht beendet werden",
|
||||
"joinFailed": "Beitritt fehlgeschlagen",
|
||||
"endConfirm": "Meeting wirklich beenden?",
|
||||
"enterAccessCode": "Zugangscode eingeben:",
|
||||
"notFound": "Raum nicht gefunden"
|
||||
},
|
||||
"recordings": {
|
||||
"title": "Aufnahmen",
|
||||
"noRecordings": "Keine Aufnahmen vorhanden",
|
||||
"published": "Veröffentlicht",
|
||||
"unpublished": "Nicht veröffentlicht",
|
||||
"presentation": "Präsentation",
|
||||
"deleted": "Aufnahme gelöscht",
|
||||
"deleteFailed": "Fehler beim Löschen",
|
||||
"deleteConfirm": "Aufnahme wirklich löschen?",
|
||||
"publishSuccess": "Aufnahme veröffentlicht",
|
||||
"unpublishSuccess": "Aufnahme versteckt",
|
||||
"publishFailed": "Fehler beim Aktualisieren",
|
||||
"hide": "Verstecken",
|
||||
"publish": "Veröffentlichen",
|
||||
"loadFailed": "Aufnahmen konnten nicht geladen werden"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
"subtitle": "Verwalten Sie Ihr Profil und Ihre Einstellungen",
|
||||
"profile": "Profil",
|
||||
"password": "Passwort",
|
||||
"themes": "Themes",
|
||||
"language": "Sprache",
|
||||
"editProfile": "Profil bearbeiten",
|
||||
"avatar": "Profilbild",
|
||||
"avatarColor": "Avatar-Farbe",
|
||||
"avatarColorHint": "Wird als Fallback verwendet, wenn kein Bild hochgeladen ist.",
|
||||
"uploadImage": "Bild hochladen",
|
||||
"removeImage": "Bild entfernen",
|
||||
"avatarHint": "JPG, PNG, GIF oder WebP. Max. 2 MB.",
|
||||
"avatarUploaded": "Profilbild aktualisiert",
|
||||
"avatarUploadFailed": "Fehler beim Hochladen",
|
||||
"avatarRemoved": "Profilbild entfernt",
|
||||
"avatarRemoveFailed": "Fehler beim Entfernen",
|
||||
"avatarInvalidType": "Nur Bilddateien sind erlaubt",
|
||||
"avatarTooLarge": "Bild darf maximal 2 MB groß sein",
|
||||
"changePassword": "Passwort ändern",
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"newPassword": "Neues Passwort",
|
||||
"confirmNewPassword": "Neues Passwort bestätigen",
|
||||
"profileSaved": "Profil gespeichert",
|
||||
"profileSaveFailed": "Fehler beim Speichern",
|
||||
"passwordChanged": "Passwort geändert",
|
||||
"passwordChangeFailed": "Fehler beim Ändern",
|
||||
"passwordMismatch": "Passwörter stimmen nicht überein",
|
||||
"selectLanguage": "Sprache auswählen"
|
||||
},
|
||||
"themes": {
|
||||
"selectTheme": "Theme auswählen",
|
||||
"selectThemeSubtitle": "Wähle dein bevorzugtes Farbschema",
|
||||
"light": "Hell",
|
||||
"dark": "Dunkel"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
"userCount": "{count} Benutzer registriert | {count} Benutzer registriert",
|
||||
"searchUsers": "Benutzer suchen...",
|
||||
"user": "Benutzer",
|
||||
"role": "Rolle",
|
||||
"rooms": "Räume",
|
||||
"registered": "Registriert",
|
||||
"actions": "Aktionen",
|
||||
"admin": "Admin",
|
||||
"makeAdmin": "Zum Admin machen",
|
||||
"makeUser": "Zum Benutzer machen",
|
||||
"resetPassword": "Passwort zurücksetzen",
|
||||
"deleteUser": "Löschen",
|
||||
"createUser": "Benutzer erstellen",
|
||||
"createUserTitle": "Neuen Benutzer erstellen",
|
||||
"userCreated": "Benutzer erstellt",
|
||||
"userCreateFailed": "Benutzer konnte nicht erstellt werden",
|
||||
"newPasswordLabel": "Neues Passwort",
|
||||
"resetPasswordTitle": "Passwort zurücksetzen",
|
||||
"noUsersFound": "Keine Benutzer gefunden",
|
||||
"roleUpdated": "Rolle aktualisiert",
|
||||
"roleUpdateFailed": "Fehler beim Aktualisieren",
|
||||
"userDeleted": "Benutzer gelöscht",
|
||||
"userDeleteFailed": "Fehler beim Löschen",
|
||||
"passwordReset": "Passwort zurückgesetzt",
|
||||
"passwordResetFailed": "Fehler beim Zurücksetzen",
|
||||
"deleteUserConfirm": "Benutzer \"{name}\" wirklich löschen? Alle Räume werden ebenfalls gelöscht."
|
||||
}
|
||||
}
|
||||
244
src/i18n/en.json
Normal file
244
src/i18n/en.json
Normal file
@@ -0,0 +1,244 @@
|
||||
{
|
||||
"common": {
|
||||
"appName": "Redlight",
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"create": "Create",
|
||||
"search": "Search...",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"back": "Back",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"or": "or",
|
||||
"optional": "Optional",
|
||||
"unlimited": "Unlimited",
|
||||
"none": "None",
|
||||
"offline": "Offline",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"protected": "Protected",
|
||||
"live": "Live",
|
||||
"error": "Error",
|
||||
"success": "Success"
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Dashboard",
|
||||
"settings": "Settings",
|
||||
"admin": "Administration",
|
||||
"appearance": "Appearance",
|
||||
"changeTheme": "Change theme",
|
||||
"navigation": "Navigation"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Sign in",
|
||||
"register": "Sign up",
|
||||
"logout": "Sign out",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"name": "Name",
|
||||
"welcomeBack": "Welcome back",
|
||||
"loginSubtitle": "Sign in to access your rooms.",
|
||||
"createAccount": "Create account",
|
||||
"registerSubtitle": "Sign up to create rooms and start meetings.",
|
||||
"noAccount": "Don't have an account?",
|
||||
"hasAccount": "Already have an account?",
|
||||
"signUpNow": "Sign up now",
|
||||
"signInNow": "Sign in now",
|
||||
"backToHome": "← Back to homepage",
|
||||
"emailPlaceholder": "name@example.com",
|
||||
"passwordPlaceholder": "••••••••",
|
||||
"namePlaceholder": "John Doe",
|
||||
"minPassword": "At least 6 characters",
|
||||
"confirmPassword": "Confirm password",
|
||||
"repeatPassword": "Repeat password",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"passwordTooShort": "Password must be at least 6 characters",
|
||||
"loginSuccess": "Welcome back!",
|
||||
"registerSuccess": "Registration successful!",
|
||||
"loginFailed": "Login failed",
|
||||
"registerFailed": "Registration failed",
|
||||
"allFieldsRequired": "All fields are required"
|
||||
},
|
||||
"home": {
|
||||
"poweredBy": "Powered by BigBlueButton",
|
||||
"heroTitle": "Meetings re",
|
||||
"heroTitleHighlight": "defined",
|
||||
"heroSubtitle": "The modern, self-hosted BigBlueButton frontend. Create rooms, manage recordings 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.",
|
||||
"featureUsersTitle": "User Management",
|
||||
"featureUsersDesc": "Registration, login and role management for your organization.",
|
||||
"featureThemesTitle": "15+ Themes",
|
||||
"featureThemesDesc": "Dracula, Nord, Catppuccin, Rosé Pine, Gruvbox and many more.",
|
||||
"featureRecordingsTitle": "Recordings",
|
||||
"featureRecordingsDesc": "View, publish or delete all recordings per room.",
|
||||
"featureOpenSourceTitle": "Open Source",
|
||||
"featureOpenSourceDesc": "Fully open source and self-hosted. Your data stays with you.",
|
||||
"statThemes": "Themes",
|
||||
"statRooms": "Rooms",
|
||||
"statOpenSource": "Open Source",
|
||||
"footer": "© {year} Redlight. An open source BigBlueButton frontend."
|
||||
},
|
||||
"dashboard": {
|
||||
"myRooms": "My Rooms",
|
||||
"roomCount": "{count} room created | {count} rooms created",
|
||||
"newRoom": "New Room",
|
||||
"noRooms": "No rooms yet",
|
||||
"noRoomsSubtitle": "Create your first room to start meetings.",
|
||||
"createFirst": "Create first room",
|
||||
"createRoom": "Create new room",
|
||||
"roomName": "Room name",
|
||||
"roomNamePlaceholder": "e.g. Team Meeting",
|
||||
"roomNameRequired": "Room name is required",
|
||||
"welcomeMessage": "Welcome message",
|
||||
"welcomeMessageDefault": "Welcome to the meeting!",
|
||||
"maxParticipants": "Max. participants",
|
||||
"maxParticipantsHint": "0 = unlimited",
|
||||
"accessCode": "Access code",
|
||||
"muteOnJoin": "Mute participants on join",
|
||||
"allowRecording": "Allow recording",
|
||||
"roomCreated": "Room created!",
|
||||
"roomCreateFailed": "Room could not be created",
|
||||
"roomDeleted": "Room deleted",
|
||||
"roomDeleteFailed": "Room could not be deleted",
|
||||
"roomDeleteConfirm": "Really delete room \"{name}\"?",
|
||||
"loadFailed": "Rooms could not be loaded"
|
||||
},
|
||||
"room": {
|
||||
"backToDashboard": "Back to Dashboard",
|
||||
"start": "Start",
|
||||
"startMeeting": "Start meeting",
|
||||
"join": "Join",
|
||||
"end": "End",
|
||||
"openDetails": "Open details",
|
||||
"overview": "Overview",
|
||||
"recordings": "Recordings",
|
||||
"settings": "Settings",
|
||||
"participants": "{count} participants",
|
||||
"copyLink": "Copy link",
|
||||
"linkCopied": "Link copied!",
|
||||
"meetingDetails": "Meeting details",
|
||||
"meetingId": "Meeting ID",
|
||||
"status": "Status",
|
||||
"maxParticipants": "Max. participants",
|
||||
"accessCode": "Access code",
|
||||
"roomSettings": "Room settings",
|
||||
"mutedOnJoin": "Muted on join",
|
||||
"micActiveOnJoin": "Microphone active on join",
|
||||
"approvalRequired": "Approval required",
|
||||
"freeJoin": "Free join",
|
||||
"allModerators": "All as moderator",
|
||||
"rolesAssigned": "Roles are assigned",
|
||||
"recordingAllowed": "Recording allowed",
|
||||
"recordingDisabled": "Recording disabled",
|
||||
"welcomeMsg": "Welcome message",
|
||||
"muteOnJoin": "Mute on join",
|
||||
"requireApproval": "Moderator approval required",
|
||||
"anyoneCanStart": "Anyone can start the meeting",
|
||||
"allJoinModerator": "All participants as moderator",
|
||||
"allowRecording": "Allow recording",
|
||||
"noAccessCode": "No access code",
|
||||
"emptyNoCode": "Empty = no code",
|
||||
"settingsSaved": "Settings saved",
|
||||
"settingsSaveFailed": "Settings could not be saved",
|
||||
"meetingStarted": "Meeting started!",
|
||||
"meetingStartFailed": "Meeting could not be started",
|
||||
"meetingEnded": "Meeting ended",
|
||||
"meetingEndFailed": "Meeting could not be ended",
|
||||
"joinFailed": "Join failed",
|
||||
"endConfirm": "Really end meeting?",
|
||||
"enterAccessCode": "Enter access code:",
|
||||
"notFound": "Room not found"
|
||||
},
|
||||
"recordings": {
|
||||
"title": "Recordings",
|
||||
"noRecordings": "No recordings available",
|
||||
"published": "Published",
|
||||
"unpublished": "Unpublished",
|
||||
"presentation": "Presentation",
|
||||
"deleted": "Recording deleted",
|
||||
"deleteFailed": "Error deleting recording",
|
||||
"deleteConfirm": "Really delete recording?",
|
||||
"publishSuccess": "Recording published",
|
||||
"unpublishSuccess": "Recording unpublished",
|
||||
"publishFailed": "Error updating recording",
|
||||
"hide": "Hide",
|
||||
"publish": "Publish",
|
||||
"loadFailed": "Recordings could not be loaded"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Manage your profile and settings",
|
||||
"profile": "Profile",
|
||||
"password": "Password",
|
||||
"themes": "Themes",
|
||||
"language": "Language",
|
||||
"editProfile": "Edit profile",
|
||||
"avatar": "Profile picture",
|
||||
"avatarColor": "Avatar color",
|
||||
"avatarColorHint": "Used as fallback when no image is uploaded.",
|
||||
"uploadImage": "Upload image",
|
||||
"removeImage": "Remove image",
|
||||
"avatarHint": "JPG, PNG, GIF or WebP. Max. 2 MB.",
|
||||
"avatarUploaded": "Profile picture updated",
|
||||
"avatarUploadFailed": "Error uploading image",
|
||||
"avatarRemoved": "Profile picture removed",
|
||||
"avatarRemoveFailed": "Error removing image",
|
||||
"avatarInvalidType": "Only image files are allowed",
|
||||
"avatarTooLarge": "Image must be less than 2 MB",
|
||||
"changePassword": "Change password",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"confirmNewPassword": "Confirm new password",
|
||||
"profileSaved": "Profile saved",
|
||||
"profileSaveFailed": "Error saving profile",
|
||||
"passwordChanged": "Password changed",
|
||||
"passwordChangeFailed": "Error changing password",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"selectLanguage": "Select language"
|
||||
},
|
||||
"themes": {
|
||||
"selectTheme": "Select theme",
|
||||
"selectThemeSubtitle": "Choose your preferred color scheme",
|
||||
"light": "Light",
|
||||
"dark": "Dark"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Administration",
|
||||
"userCount": "{count} user registered | {count} users registered",
|
||||
"searchUsers": "Search users...",
|
||||
"user": "User",
|
||||
"role": "Role",
|
||||
"rooms": "Rooms",
|
||||
"registered": "Registered",
|
||||
"actions": "Actions",
|
||||
"admin": "Admin",
|
||||
"makeAdmin": "Make admin",
|
||||
"makeUser": "Make user",
|
||||
"resetPassword": "Reset password",
|
||||
"deleteUser": "Delete",
|
||||
"createUser": "Create user",
|
||||
"createUserTitle": "Create new user",
|
||||
"userCreated": "User created",
|
||||
"userCreateFailed": "User could not be created",
|
||||
"newPasswordLabel": "New password",
|
||||
"resetPasswordTitle": "Reset password",
|
||||
"noUsersFound": "No users found",
|
||||
"roleUpdated": "Role updated",
|
||||
"roleUpdateFailed": "Error updating role",
|
||||
"userDeleted": "User deleted",
|
||||
"userDeleteFailed": "Error deleting user",
|
||||
"passwordReset": "Password reset",
|
||||
"passwordResetFailed": "Error resetting password",
|
||||
"deleteUserConfirm": "Really delete user \"{name}\"? All rooms will also be deleted."
|
||||
}
|
||||
}
|
||||
42
src/i18n/index.js
Normal file
42
src/i18n/index.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import en from './en.json';
|
||||
import de from './de.json';
|
||||
|
||||
export const languages = {
|
||||
en: { name: 'English', flag: '🇬🇧', translations: en },
|
||||
de: { name: 'Deutsch', flag: '🇩🇪', translations: de },
|
||||
};
|
||||
|
||||
export const defaultLanguage = 'en';
|
||||
|
||||
export function getTranslation(lang, key) {
|
||||
const keys = key.split('.');
|
||||
const translations = languages[lang]?.translations || languages[defaultLanguage].translations;
|
||||
let value = translations;
|
||||
for (const k of keys) {
|
||||
value = value?.[k];
|
||||
if (value === undefined) {
|
||||
// Fallback to default language
|
||||
let fallback = languages[defaultLanguage].translations;
|
||||
for (const fk of keys) {
|
||||
fallback = fallback?.[fk];
|
||||
}
|
||||
return fallback || key;
|
||||
}
|
||||
}
|
||||
return value || key;
|
||||
}
|
||||
|
||||
export function interpolate(template, params = {}) {
|
||||
if (!template) return '';
|
||||
return template.replace(/\{(\w+)\}/g, (_, key) => {
|
||||
return params[key] !== undefined ? params[key] : `{${key}}`;
|
||||
});
|
||||
}
|
||||
|
||||
// Handle plural: "singular | plural" format
|
||||
export function pluralize(template, count) {
|
||||
if (!template) return '';
|
||||
const parts = template.split(' | ');
|
||||
if (parts.length === 1) return interpolate(template, { count });
|
||||
return interpolate(count === 1 ? parts[0] : parts[1], { count });
|
||||
}
|
||||
486
src/index.css
Normal file
486
src/index.css
Normal file
@@ -0,0 +1,486 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
/* ===== DEFAULT LIGHT ===== */
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #64748b;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #2563eb;
|
||||
--accent-text: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
--card-bg: #ffffff;
|
||||
--input-bg: #ffffff;
|
||||
--input-border: #cbd5e1;
|
||||
--nav-bg: #ffffff;
|
||||
--sidebar-bg: #f8fafc;
|
||||
--hover-bg: #f1f5f9;
|
||||
--success: #22c55e;
|
||||
--warning: #eab308;
|
||||
--error: #ef4444;
|
||||
--ring: #3b82f6;
|
||||
--shadow-color: rgba(0, 0, 0, 0.08);
|
||||
--gradient-start: #3b82f6;
|
||||
--gradient-end: #8b5cf6;
|
||||
}
|
||||
|
||||
/* ===== DEFAULT DARK ===== */
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-tertiary: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--accent: #3b82f6;
|
||||
--accent-hover: #60a5fa;
|
||||
--accent-text: #ffffff;
|
||||
--border: #334155;
|
||||
--card-bg: #1e293b;
|
||||
--input-bg: #1e293b;
|
||||
--input-border: #475569;
|
||||
--nav-bg: #1e293b;
|
||||
--sidebar-bg: #0f172a;
|
||||
--hover-bg: #334155;
|
||||
--success: #22c55e;
|
||||
--warning: #eab308;
|
||||
--error: #ef4444;
|
||||
--ring: #3b82f6;
|
||||
--shadow-color: rgba(0, 0, 0, 0.3);
|
||||
--gradient-start: #3b82f6;
|
||||
--gradient-end: #8b5cf6;
|
||||
}
|
||||
|
||||
/* ===== DRACULA ===== */
|
||||
[data-theme="dracula"] {
|
||||
--bg-primary: #282a36;
|
||||
--bg-secondary: #44475a;
|
||||
--bg-tertiary: #383a4c;
|
||||
--text-primary: #f8f8f2;
|
||||
--text-secondary: #6272a4;
|
||||
--accent: #bd93f9;
|
||||
--accent-hover: #caa8fc;
|
||||
--accent-text: #282a36;
|
||||
--border: #44475a;
|
||||
--card-bg: #383a4c;
|
||||
--input-bg: #44475a;
|
||||
--input-border: #6272a4;
|
||||
--nav-bg: #21222c;
|
||||
--sidebar-bg: #21222c;
|
||||
--hover-bg: #44475a;
|
||||
--success: #50fa7b;
|
||||
--warning: #f1fa8c;
|
||||
--error: #ff5555;
|
||||
--ring: #bd93f9;
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--gradient-start: #bd93f9;
|
||||
--gradient-end: #ff79c6;
|
||||
}
|
||||
|
||||
/* ===== CATPPUCCIN MOCHA ===== */
|
||||
[data-theme="mocha"] {
|
||||
--bg-primary: #1e1e2e;
|
||||
--bg-secondary: #313244;
|
||||
--bg-tertiary: #45475a;
|
||||
--text-primary: #cdd6f4;
|
||||
--text-secondary: #a6adc8;
|
||||
--accent: #89b4fa;
|
||||
--accent-hover: #b4befe;
|
||||
--accent-text: #1e1e2e;
|
||||
--border: #45475a;
|
||||
--card-bg: #313244;
|
||||
--input-bg: #313244;
|
||||
--input-border: #585b70;
|
||||
--nav-bg: #181825;
|
||||
--sidebar-bg: #181825;
|
||||
--hover-bg: #45475a;
|
||||
--success: #a6e3a1;
|
||||
--warning: #f9e2af;
|
||||
--error: #f38ba8;
|
||||
--ring: #89b4fa;
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--gradient-start: #89b4fa;
|
||||
--gradient-end: #cba6f7;
|
||||
}
|
||||
|
||||
/* ===== CATPPUCCIN LATTE (Light) ===== */
|
||||
[data-theme="latte"] {
|
||||
--bg-primary: #eff1f5;
|
||||
--bg-secondary: #e6e9ef;
|
||||
--bg-tertiary: #dce0e8;
|
||||
--text-primary: #4c4f69;
|
||||
--text-secondary: #6c6f85;
|
||||
--accent: #1e66f5;
|
||||
--accent-hover: #2a6ef5;
|
||||
--accent-text: #ffffff;
|
||||
--border: #ccd0da;
|
||||
--card-bg: #e6e9ef;
|
||||
--input-bg: #eff1f5;
|
||||
--input-border: #bcc0cc;
|
||||
--nav-bg: #e6e9ef;
|
||||
--sidebar-bg: #dce0e8;
|
||||
--hover-bg: #dce0e8;
|
||||
--success: #40a02b;
|
||||
--warning: #df8e1d;
|
||||
--error: #d20f39;
|
||||
--ring: #1e66f5;
|
||||
--shadow-color: rgba(76, 79, 105, 0.1);
|
||||
--gradient-start: #1e66f5;
|
||||
--gradient-end: #8839ef;
|
||||
}
|
||||
|
||||
/* ===== NORD ===== */
|
||||
[data-theme="nord"] {
|
||||
--bg-primary: #2e3440;
|
||||
--bg-secondary: #3b4252;
|
||||
--bg-tertiary: #434c5e;
|
||||
--text-primary: #eceff4;
|
||||
--text-secondary: #d8dee9;
|
||||
--accent: #88c0d0;
|
||||
--accent-hover: #8fbcbb;
|
||||
--accent-text: #2e3440;
|
||||
--border: #4c566a;
|
||||
--card-bg: #3b4252;
|
||||
--input-bg: #3b4252;
|
||||
--input-border: #4c566a;
|
||||
--nav-bg: #2e3440;
|
||||
--sidebar-bg: #2e3440;
|
||||
--hover-bg: #434c5e;
|
||||
--success: #a3be8c;
|
||||
--warning: #ebcb8b;
|
||||
--error: #bf616a;
|
||||
--ring: #88c0d0;
|
||||
--shadow-color: rgba(0, 0, 0, 0.3);
|
||||
--gradient-start: #88c0d0;
|
||||
--gradient-end: #b48ead;
|
||||
}
|
||||
|
||||
/* ===== TOKYO NIGHT ===== */
|
||||
[data-theme="tokyo-night"] {
|
||||
--bg-primary: #1a1b26;
|
||||
--bg-secondary: #24283b;
|
||||
--bg-tertiary: #2f3349;
|
||||
--text-primary: #a9b1d6;
|
||||
--text-secondary: #565f89;
|
||||
--accent: #7aa2f7;
|
||||
--accent-hover: #89b4fa;
|
||||
--accent-text: #1a1b26;
|
||||
--border: #3b4261;
|
||||
--card-bg: #24283b;
|
||||
--input-bg: #24283b;
|
||||
--input-border: #3b4261;
|
||||
--nav-bg: #16161e;
|
||||
--sidebar-bg: #16161e;
|
||||
--hover-bg: #2f3349;
|
||||
--success: #9ece6a;
|
||||
--warning: #e0af68;
|
||||
--error: #f7768e;
|
||||
--ring: #7aa2f7;
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--gradient-start: #7aa2f7;
|
||||
--gradient-end: #bb9af7;
|
||||
}
|
||||
|
||||
/* ===== GRUVBOX DARK ===== */
|
||||
[data-theme="gruvbox-dark"] {
|
||||
--bg-primary: #282828;
|
||||
--bg-secondary: #3c3836;
|
||||
--bg-tertiary: #504945;
|
||||
--text-primary: #ebdbb2;
|
||||
--text-secondary: #a89984;
|
||||
--accent: #fe8019;
|
||||
--accent-hover: #fabd2f;
|
||||
--accent-text: #282828;
|
||||
--border: #504945;
|
||||
--card-bg: #3c3836;
|
||||
--input-bg: #3c3836;
|
||||
--input-border: #665c54;
|
||||
--nav-bg: #1d2021;
|
||||
--sidebar-bg: #1d2021;
|
||||
--hover-bg: #504945;
|
||||
--success: #b8bb26;
|
||||
--warning: #fabd2f;
|
||||
--error: #fb4934;
|
||||
--ring: #fe8019;
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--gradient-start: #fe8019;
|
||||
--gradient-end: #fabd2f;
|
||||
}
|
||||
|
||||
/* ===== GRUVBOX LIGHT ===== */
|
||||
[data-theme="gruvbox-light"] {
|
||||
--bg-primary: #fbf1c7;
|
||||
--bg-secondary: #ebdbb2;
|
||||
--bg-tertiary: #d5c4a1;
|
||||
--text-primary: #3c3836;
|
||||
--text-secondary: #665c54;
|
||||
--accent: #d65d0e;
|
||||
--accent-hover: #af3a03;
|
||||
--accent-text: #fbf1c7;
|
||||
--border: #d5c4a1;
|
||||
--card-bg: #ebdbb2;
|
||||
--input-bg: #fbf1c7;
|
||||
--input-border: #bdae93;
|
||||
--nav-bg: #ebdbb2;
|
||||
--sidebar-bg: #ebdbb2;
|
||||
--hover-bg: #d5c4a1;
|
||||
--success: #79740e;
|
||||
--warning: #b57614;
|
||||
--error: #cc241d;
|
||||
--ring: #d65d0e;
|
||||
--shadow-color: rgba(60, 56, 54, 0.1);
|
||||
--gradient-start: #d65d0e;
|
||||
--gradient-end: #b57614;
|
||||
}
|
||||
|
||||
/* ===== ROSE PINE ===== */
|
||||
[data-theme="rose-pine"] {
|
||||
--bg-primary: #191724;
|
||||
--bg-secondary: #1f1d2e;
|
||||
--bg-tertiary: #26233a;
|
||||
--text-primary: #e0def4;
|
||||
--text-secondary: #908caa;
|
||||
--accent: #c4a7e7;
|
||||
--accent-hover: #ebbcba;
|
||||
--accent-text: #191724;
|
||||
--border: #393552;
|
||||
--card-bg: #1f1d2e;
|
||||
--input-bg: #1f1d2e;
|
||||
--input-border: #393552;
|
||||
--nav-bg: #191724;
|
||||
--sidebar-bg: #191724;
|
||||
--hover-bg: #26233a;
|
||||
--success: #9ccfd8;
|
||||
--warning: #f6c177;
|
||||
--error: #eb6f92;
|
||||
--ring: #c4a7e7;
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--gradient-start: #c4a7e7;
|
||||
--gradient-end: #eb6f92;
|
||||
}
|
||||
|
||||
/* ===== ROSE PINE DAWN (Light) ===== */
|
||||
[data-theme="rose-pine-dawn"] {
|
||||
--bg-primary: #faf4ed;
|
||||
--bg-secondary: #fffaf3;
|
||||
--bg-tertiary: #f2e9e1;
|
||||
--text-primary: #575279;
|
||||
--text-secondary: #797593;
|
||||
--accent: #907aa9;
|
||||
--accent-hover: #b4637a;
|
||||
--accent-text: #faf4ed;
|
||||
--border: #dfdad9;
|
||||
--card-bg: #fffaf3;
|
||||
--input-bg: #faf4ed;
|
||||
--input-border: #dfdad9;
|
||||
--nav-bg: #fffaf3;
|
||||
--sidebar-bg: #f2e9e1;
|
||||
--hover-bg: #f2e9e1;
|
||||
--success: #56949f;
|
||||
--warning: #ea9d34;
|
||||
--error: #b4637a;
|
||||
--ring: #907aa9;
|
||||
--shadow-color: rgba(87, 82, 121, 0.08);
|
||||
--gradient-start: #907aa9;
|
||||
--gradient-end: #b4637a;
|
||||
}
|
||||
|
||||
/* ===== SOLARIZED DARK ===== */
|
||||
[data-theme="solarized-dark"] {
|
||||
--bg-primary: #002b36;
|
||||
--bg-secondary: #073642;
|
||||
--bg-tertiary: #0a4050;
|
||||
--text-primary: #839496;
|
||||
--text-secondary: #657b83;
|
||||
--accent: #268bd2;
|
||||
--accent-hover: #2aa198;
|
||||
--accent-text: #fdf6e3;
|
||||
--border: #073642;
|
||||
--card-bg: #073642;
|
||||
--input-bg: #073642;
|
||||
--input-border: #586e75;
|
||||
--nav-bg: #002b36;
|
||||
--sidebar-bg: #002b36;
|
||||
--hover-bg: #0a4050;
|
||||
--success: #859900;
|
||||
--warning: #b58900;
|
||||
--error: #dc322f;
|
||||
--ring: #268bd2;
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--gradient-start: #268bd2;
|
||||
--gradient-end: #2aa198;
|
||||
}
|
||||
|
||||
/* ===== SOLARIZED LIGHT ===== */
|
||||
[data-theme="solarized-light"] {
|
||||
--bg-primary: #fdf6e3;
|
||||
--bg-secondary: #eee8d5;
|
||||
--bg-tertiary: #e4ddc8;
|
||||
--text-primary: #657b83;
|
||||
--text-secondary: #93a1a1;
|
||||
--accent: #268bd2;
|
||||
--accent-hover: #2aa198;
|
||||
--accent-text: #fdf6e3;
|
||||
--border: #eee8d5;
|
||||
--card-bg: #eee8d5;
|
||||
--input-bg: #fdf6e3;
|
||||
--input-border: #d3cbb7;
|
||||
--nav-bg: #eee8d5;
|
||||
--sidebar-bg: #eee8d5;
|
||||
--hover-bg: #e4ddc8;
|
||||
--success: #859900;
|
||||
--warning: #b58900;
|
||||
--error: #dc322f;
|
||||
--ring: #268bd2;
|
||||
--shadow-color: rgba(101, 123, 131, 0.1);
|
||||
--gradient-start: #268bd2;
|
||||
--gradient-end: #2aa198;
|
||||
}
|
||||
|
||||
/* ===== ONE DARK ===== */
|
||||
[data-theme="one-dark"] {
|
||||
--bg-primary: #282c34;
|
||||
--bg-secondary: #2c313a;
|
||||
--bg-tertiary: #353b45;
|
||||
--text-primary: #abb2bf;
|
||||
--text-secondary: #636d83;
|
||||
--accent: #61afef;
|
||||
--accent-hover: #79bcf5;
|
||||
--accent-text: #282c34;
|
||||
--border: #3e4452;
|
||||
--card-bg: #2c313a;
|
||||
--input-bg: #2c313a;
|
||||
--input-border: #3e4452;
|
||||
--nav-bg: #21252b;
|
||||
--sidebar-bg: #21252b;
|
||||
--hover-bg: #353b45;
|
||||
--success: #98c379;
|
||||
--warning: #e5c07b;
|
||||
--error: #e06c75;
|
||||
--ring: #61afef;
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--gradient-start: #61afef;
|
||||
--gradient-end: #c678dd;
|
||||
}
|
||||
|
||||
/* ===== GITHUB DARK ===== */
|
||||
[data-theme="github-dark"] {
|
||||
--bg-primary: #0d1117;
|
||||
--bg-secondary: #161b22;
|
||||
--bg-tertiary: #21262d;
|
||||
--text-primary: #c9d1d9;
|
||||
--text-secondary: #8b949e;
|
||||
--accent: #58a6ff;
|
||||
--accent-hover: #79c0ff;
|
||||
--accent-text: #0d1117;
|
||||
--border: #30363d;
|
||||
--card-bg: #161b22;
|
||||
--input-bg: #0d1117;
|
||||
--input-border: #30363d;
|
||||
--nav-bg: #161b22;
|
||||
--sidebar-bg: #0d1117;
|
||||
--hover-bg: #21262d;
|
||||
--success: #3fb950;
|
||||
--warning: #d29922;
|
||||
--error: #f85149;
|
||||
--ring: #58a6ff;
|
||||
--shadow-color: rgba(0, 0, 0, 0.4);
|
||||
--gradient-start: #58a6ff;
|
||||
--gradient-end: #bc8cff;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@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));
|
||||
}
|
||||
}
|
||||
34
src/main.jsx
Normal file
34
src/main.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import App from './App';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<LanguageProvider>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: 'var(--card-bg)',
|
||||
color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
</LanguageProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
368
src/pages/Admin.jsx
Normal file
368
src/pages/Admin.jsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Admin() {
|
||||
const { user } = useAuth();
|
||||
const { t, language } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [openMenu, setOpenMenu] = useState(null);
|
||||
const [resetPwModal, setResetPwModal] = useState(null);
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [showCreateUser, setShowCreateUser] = useState(false);
|
||||
const [creatingUser, setCreatingUser] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'user' });
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== 'admin') {
|
||||
navigate('/dashboard');
|
||||
return;
|
||||
}
|
||||
fetchUsers();
|
||||
}, [user]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
const res = await api.get('/admin/users');
|
||||
setUsers(res.data.users);
|
||||
} catch {
|
||||
toast.error(t('admin.roleUpdateFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId, newRole) => {
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/role`, { role: newRole });
|
||||
toast.success(t('admin.roleUpdated'));
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.roleUpdateFailed'));
|
||||
}
|
||||
setOpenMenu(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (userId, userName) => {
|
||||
if (!confirm(t('admin.deleteUserConfirm', { name: userName }))) return;
|
||||
try {
|
||||
await api.delete(`/admin/users/${userId}`);
|
||||
toast.success(t('admin.userDeleted'));
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.userDeleteFailed'));
|
||||
}
|
||||
setOpenMenu(null);
|
||||
};
|
||||
|
||||
const handleResetPassword = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.put(`/admin/users/${resetPwModal}/password`, { newPassword });
|
||||
toast.success(t('admin.passwordReset'));
|
||||
setResetPwModal(null);
|
||||
setNewPassword('');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.passwordResetFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateUser = async (e) => {
|
||||
e.preventDefault();
|
||||
setCreatingUser(true);
|
||||
try {
|
||||
await api.post('/admin/users', newUser);
|
||||
toast.success(t('admin.userCreated'));
|
||||
setShowCreateUser(false);
|
||||
setNewUser({ name: '', email: '', password: '', role: 'user' });
|
||||
fetchUsers();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.userCreateFailed'));
|
||||
} finally {
|
||||
setCreatingUser(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(u =>
|
||||
u.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Shield size={24} className="text-th-accent" />
|
||||
<h1 className="text-2xl font-bold text-th-text">{t('admin.title')}</h1>
|
||||
</div>
|
||||
<p className="text-sm text-th-text-s">
|
||||
{t('admin.userCount', { count: users.length })}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreateUser(true)} className="btn-primary">
|
||||
<UserPlus size={18} />
|
||||
<span className="hidden sm:inline">{t('admin.createUser')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('admin.searchUsers')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-th-border">
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3">
|
||||
{t('admin.user')}
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3 hidden sm:table-cell">
|
||||
{t('admin.role')}
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3 hidden md:table-cell">
|
||||
{t('admin.rooms')}
|
||||
</th>
|
||||
<th className="text-left text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3 hidden lg:table-cell">
|
||||
{t('admin.registered')}
|
||||
</th>
|
||||
<th className="text-right text-xs font-semibold text-th-text-s uppercase tracking-wider px-5 py-3">
|
||||
{t('admin.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map(u => (
|
||||
<tr key={u.id} className="border-b border-th-border last:border-0 hover:bg-th-hover transition-colors">
|
||||
<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"
|
||||
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
|
||||
>
|
||||
{u.avatar_image ? (
|
||||
<img
|
||||
src={`${api.defaults.baseURL}/auth/avatar/${u.avatar_image}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
u.name[0]?.toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-th-text">{u.name}</p>
|
||||
<p className="text-xs text-th-text-s">{u.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 hidden sm:table-cell">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
u.role === 'admin'
|
||||
? 'bg-th-accent/15 text-th-accent'
|
||||
: 'bg-th-bg-t text-th-text-s'
|
||||
}`}>
|
||||
{u.role === 'admin' ? <Shield size={10} /> : <Users size={10} />}
|
||||
{u.role === 'admin' ? t('admin.admin') : t('admin.user')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-th-text hidden md:table-cell">
|
||||
{u.room_count}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-sm text-th-text-s hidden lg:table-cell">
|
||||
{new Date(u.created_at).toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US')}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center justify-end relative">
|
||||
<button
|
||||
onClick={() => setOpenMenu(openMenu === u.id ? null : u.id)}
|
||||
className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||
disabled={u.id === user.id}
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
|
||||
{openMenu === u.id && u.id !== user.id && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpenMenu(null)} />
|
||||
<div className="absolute right-0 top-8 z-20 w-48 bg-th-card rounded-xl border border-th-border shadow-th-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleRoleChange(u.id, u.role === 'admin' ? 'user' : 'admin')}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
{u.role === 'admin' ? <UserX size={14} /> : <UserCheck size={14} />}
|
||||
{u.role === 'admin' ? t('admin.makeUser') : t('admin.makeAdmin')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setResetPwModal(u.id); setOpenMenu(null); }}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-text hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<Key size={14} />
|
||||
{t('admin.resetPassword')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(u.id, u.name)}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-th-error hover:bg-th-hover transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('admin.deleteUser')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredUsers.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Users size={48} className="mx-auto text-th-text-s/40 mb-3" />
|
||||
<p className="text-th-text-s text-sm">{t('admin.noUsersFound')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<form onSubmit={handleResetPassword}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('admin.newPasswordLabel')}</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
className="input-field"
|
||||
placeholder={t('auth.minPassword')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button type="button" onClick={() => setResetPwModal(null)} className="btn-secondary flex-1">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" className="btn-primary flex-1">
|
||||
{t('admin.resetPassword')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<form onSubmit={handleCreateUser} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={newUser.name}
|
||||
onChange={e => setNewUser({ ...newUser, name: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label 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" />
|
||||
<input
|
||||
type="email"
|
||||
value={newUser.email}
|
||||
onChange={e => setNewUser({ ...newUser, email: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label 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" />
|
||||
<input
|
||||
type="password"
|
||||
value={newUser.password}
|
||||
onChange={e => setNewUser({ ...newUser, password: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.minPassword')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('admin.role')}</label>
|
||||
<select
|
||||
value={newUser.role}
|
||||
onChange={e => setNewUser({ ...newUser, role: e.target.value })}
|
||||
className="input-field"
|
||||
>
|
||||
<option value="user">{t('admin.user')}</option>
|
||||
<option value="admin">{t('admin.admin')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button type="button" onClick={() => setShowCreateUser(false)} className="btn-secondary flex-1">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={creatingUser} className="btn-primary flex-1">
|
||||
{creatingUser ? <Loader2 size={18} className="animate-spin" /> : t('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
230
src/pages/Dashboard.jsx
Normal file
230
src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Video, Loader2, LayoutGrid, List } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import RoomCard from '../components/RoomCard';
|
||||
import Modal from '../components/Modal';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { t } = useLanguage();
|
||||
const [rooms, setRooms] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [viewMode, setViewMode] = useState('grid');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [newRoom, setNewRoom] = useState({
|
||||
name: '',
|
||||
welcome_message: '',
|
||||
max_participants: 0,
|
||||
access_code: '',
|
||||
mute_on_join: true,
|
||||
record_meeting: true,
|
||||
});
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
const res = await api.get('/rooms');
|
||||
setRooms(res.data.rooms);
|
||||
} catch (err) {
|
||||
toast.error(t('dashboard.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms();
|
||||
}, []);
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.post('/rooms', newRoom);
|
||||
toast.success(t('dashboard.roomCreated'));
|
||||
setShowCreate(false);
|
||||
setNewRoom({
|
||||
name: '',
|
||||
welcome_message: t('dashboard.welcomeMessageDefault'),
|
||||
max_participants: 0,
|
||||
access_code: '',
|
||||
mute_on_join: true,
|
||||
record_meeting: true,
|
||||
});
|
||||
fetchRooms();
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('dashboard.roomCreateFailed'));
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (room) => {
|
||||
if (!confirm(t('dashboard.roomDeleteConfirm', { name: room.name }))) return;
|
||||
try {
|
||||
await api.delete(`/rooms/${room.uid}`);
|
||||
toast.success(t('dashboard.roomDeleted'));
|
||||
fetchRooms();
|
||||
} catch (err) {
|
||||
toast.error(t('dashboard.roomDeleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-th-text">{t('dashboard.myRooms')}</h1>
|
||||
<p className="text-sm text-th-text-s mt-1">
|
||||
{t('dashboard.roomCount', { count: rooms.length })}
|
||||
</p>
|
||||
</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">
|
||||
<button
|
||||
onClick={() => setViewMode('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'
|
||||
}`}
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('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'
|
||||
}`}
|
||||
>
|
||||
<List size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onClick={() => setShowCreate(true)} className="btn-primary">
|
||||
<Plus size={18} />
|
||||
<span className="hidden sm:inline">{t('dashboard.newRoom')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room grid/list */}
|
||||
{rooms.length === 0 ? (
|
||||
<div className="card p-12 text-center">
|
||||
<Video size={48} className="mx-auto text-th-text-s/40 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-th-text mb-2">{t('dashboard.noRooms')}</h3>
|
||||
<p className="text-sm text-th-text-s mb-6">
|
||||
{t('dashboard.noRoomsSubtitle')}
|
||||
</p>
|
||||
<button onClick={() => setShowCreate(true)} className="btn-primary">
|
||||
<Plus size={18} />
|
||||
{t('dashboard.createFirst')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={
|
||||
viewMode === 'grid'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
|
||||
: 'space-y-3'
|
||||
}>
|
||||
{rooms.map(room => (
|
||||
<RoomCard key={room.id} room={room} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Room Modal */}
|
||||
{showCreate && (
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoom.name}
|
||||
onChange={e => setNewRoom({ ...newRoom, name: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder={t('dashboard.roomNamePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.welcomeMessage')}</label>
|
||||
<textarea
|
||||
value={newRoom.welcome_message}
|
||||
onChange={e => setNewRoom({ ...newRoom, welcome_message: e.target.value })}
|
||||
className="input-field resize-none"
|
||||
rows={2}
|
||||
placeholder={t('dashboard.welcomeMessageDefault')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="number"
|
||||
value={newRoom.max_participants}
|
||||
onChange={e => setNewRoom({ ...newRoom, max_participants: parseInt(e.target.value) || 0 })}
|
||||
className="input-field"
|
||||
min="0"
|
||||
placeholder={t('dashboard.maxParticipantsHint')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('dashboard.accessCode')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRoom.access_code}
|
||||
onChange={e => setNewRoom({ ...newRoom, access_code: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder={t('common.optional')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm text-th-text">{t('dashboard.muteOnJoin')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm text-th-text">{t('dashboard.allowRecording')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-4 border-t border-th-border">
|
||||
<button type="button" onClick={() => setShowCreate(false)} className="btn-secondary flex-1">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={creating} className="btn-primary flex-1">
|
||||
{creating ? <Loader2 size={18} className="animate-spin" /> : t('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
src/pages/GuestJoin.jsx
Normal file
222
src/pages/GuestJoin.jsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio } from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function GuestJoin() {
|
||||
const { uid } = useParams();
|
||||
const [roomInfo, setRoomInfo] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [accessCode, setAccessCode] = useState('');
|
||||
const [moderatorCode, setModeratorCode] = useState('');
|
||||
const [status, setStatus] = useState({ running: false });
|
||||
|
||||
useEffect(() => {
|
||||
const fetchRoom = async () => {
|
||||
try {
|
||||
const res = await api.get(`/rooms/${uid}/public`);
|
||||
setRoomInfo(res.data.room);
|
||||
setStatus({ running: res.data.running });
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Raum nicht gefunden');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchRoom();
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await api.get(`/rooms/${uid}/status`);
|
||||
setStatus(res.data);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [uid]);
|
||||
|
||||
const handleJoin = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) {
|
||||
toast.error('Name ist erforderlich');
|
||||
return;
|
||||
}
|
||||
|
||||
setJoining(true);
|
||||
try {
|
||||
const res = await api.post(`/rooms/${uid}/guest-join`, {
|
||||
name: name.trim(),
|
||||
access_code: accessCode || undefined,
|
||||
moderator_code: moderatorCode || undefined,
|
||||
});
|
||||
if (res.data.joinUrl) {
|
||||
window.location.href = res.data.joinUrl;
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || 'Beitritt fehlgeschlagen');
|
||||
} finally {
|
||||
setJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-th-bg">
|
||||
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-th-bg">
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl text-center">
|
||||
<div className="w-16 h-16 bg-th-error/15 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Video size={28} className="text-th-error" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-th-text mb-2">Zugang nicht möglich</h2>
|
||||
<p className="text-sm text-th-text-s mb-6">{error}</p>
|
||||
<Link to="/login" className="btn-primary inline-flex">
|
||||
Zum Login
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||
{/* Animated background */}
|
||||
<div className="absolute inset-0 bg-th-bg">
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Join card */}
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center gap-2.5 mb-6">
|
||||
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
|
||||
<Video size={22} className="text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold gradient-text">Redlight</span>
|
||||
</div>
|
||||
|
||||
{/* Room info */}
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-xl font-bold text-th-text mb-1">{roomInfo.name}</h2>
|
||||
<p className="text-sm text-th-text-s">
|
||||
Erstellt von <span className="font-medium text-th-text">{roomInfo.owner_name}</span>
|
||||
</p>
|
||||
<div className="mt-3 inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: status.running ? 'rgba(34, 197, 94, 0.15)' : 'rgba(100, 116, 139, 0.15)',
|
||||
color: status.running ? '#22c55e' : '#94a3b8',
|
||||
}}
|
||||
>
|
||||
{status.running ? <Radio size={10} className="animate-pulse" /> : <Users size={12} />}
|
||||
{status.running ? 'Meeting läuft' : 'Noch nicht gestartet'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Join form */}
|
||||
<form onSubmit={handleJoin} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">Ihr Name *</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder="Max Mustermann"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{roomInfo.has_access_code && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">Zugangscode</label>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={accessCode}
|
||||
onChange={e => setAccessCode(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder="Code eingeben"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">
|
||||
Moderator-Code
|
||||
<span className="text-th-text-s font-normal ml-1">(optional)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Shield size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={moderatorCode}
|
||||
onChange={e => setModeratorCode(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder="Nur wenn Sie Moderator sind"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={joining || (!status.running && !roomInfo.anyone_can_start)}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{joining ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
Meeting beitreten
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!status.running && (
|
||||
<p className="text-xs text-th-text-s text-center">
|
||||
Das Meeting wurde noch nicht gestartet. Bitte warten Sie, bis der Moderator es startet.
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-th-border text-center">
|
||||
<Link to="/login" className="text-sm text-th-text-s hover:text-th-accent transition-colors">
|
||||
Haben Sie ein Konto? <span className="text-th-accent font-medium">Anmelden</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
src/pages/Home.jsx
Normal file
146
src/pages/Home.jsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe } from 'lucide-react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
|
||||
export default function Home() {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Video,
|
||||
title: t('home.featureVideoTitle'),
|
||||
desc: t('home.featureVideoDesc'),
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: t('home.featureRoomsTitle'),
|
||||
desc: t('home.featureRoomsDesc'),
|
||||
},
|
||||
{
|
||||
icon: Shield,
|
||||
title: t('home.featureUsersTitle'),
|
||||
desc: t('home.featureUsersDesc'),
|
||||
},
|
||||
{
|
||||
icon: Palette,
|
||||
title: t('home.featureThemesTitle'),
|
||||
desc: t('home.featureThemesDesc'),
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: t('home.featureRecordingsTitle'),
|
||||
desc: t('home.featureRecordingsDesc'),
|
||||
},
|
||||
{
|
||||
icon: Globe,
|
||||
title: t('home.featureOpenSourceTitle'),
|
||||
desc: t('home.featureOpenSourceDesc'),
|
||||
},
|
||||
];
|
||||
|
||||
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">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-9 h-9 gradient-bg rounded-lg flex items-center justify-center">
|
||||
<Video size={20} className="text-white" />
|
||||
</div>
|
||||
<span className="text-xl font-bold gradient-text">Redlight</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/login" className="btn-ghost text-sm">
|
||||
{t('auth.login')}
|
||||
</Link>
|
||||
<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.poweredBy')}
|
||||
</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>
|
||||
</h1>
|
||||
|
||||
<p className="text-lg md:text-xl text-th-text-s max-w-2xl mx-auto mb-10 leading-relaxed">
|
||||
{t('home.heroSubtitle')}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 justify-center">
|
||||
<Link to="/register" className="btn-primary text-base px-8 py-3">
|
||||
{t('home.getStarted')}
|
||||
<ArrowRight size={18} />
|
||||
</Link>
|
||||
<Link to="/login" className="btn-secondary text-base px-8 py-3">
|
||||
{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">
|
||||
{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>
|
||||
<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>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-th-border py-8 text-center">
|
||||
<p className="text-sm text-th-text-s">
|
||||
{t('home.footer', { year: new Date().getFullYear() })}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/pages/Login.jsx
Normal file
120
src/pages/Login.jsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Video, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Login() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
toast.success(t('auth.loginSuccess'));
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('auth.loginFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||
{/* Animated background */}
|
||||
<div className="absolute inset-0 bg-th-bg">
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login card */}
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center gap-2.5 mb-8">
|
||||
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
|
||||
<Video size={22} className="text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold gradient-text">Redlight</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.welcomeBack')}</h2>
|
||||
<p className="text-th-text-s">
|
||||
{t('auth.loginSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<div className="relative">
|
||||
<Mail size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label 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" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.passwordPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{t('auth.login')}
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-th-text-s">
|
||||
{t('auth.noAccount')}{' '}
|
||||
<Link to="/register" className="text-th-accent hover:underline font-medium">
|
||||
{t('auth.signUpNow')}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
||||
{t('auth.backToHome')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
165
src/pages/Register.jsx
Normal file
165
src/pages/Register.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Video, Mail, Lock, User, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Register() {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { register } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error(t('auth.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
toast.error(t('auth.passwordTooShort'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await register(name, email, password);
|
||||
toast.success(t('auth.registerSuccess'));
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('auth.registerFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-6 relative overflow-hidden">
|
||||
{/* Animated background */}
|
||||
<div className="absolute inset-0 bg-th-bg">
|
||||
<div className="absolute inset-0 opacity-30">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-th-accent rounded-full blur-[128px] animate-pulse" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-purple-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '2s' }} />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-pink-500 rounded-full blur-[128px] animate-pulse" style={{ animationDelay: '4s' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register card */}
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center justify-center gap-2.5 mb-8">
|
||||
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
|
||||
<Video size={22} className="text-white" />
|
||||
</div>
|
||||
<span className="text-2xl font-bold gradient-text">Redlight</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-th-text mb-2">{t('auth.createAccount')}</h2>
|
||||
<p className="text-th-text-s">
|
||||
{t('auth.registerSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.namePlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label 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" />
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.emailPlaceholder')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label 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" />
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.minPassword')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label 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" />
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.repeatPassword')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full py-3"
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{t('auth.register')}
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-th-text-s">
|
||||
{t('auth.hasAccount')}{' '}
|
||||
<Link to="/login" className="text-th-accent hover:underline font-medium">
|
||||
{t('auth.signInNow')}
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<Link to="/" className="block mt-4 text-center text-sm text-th-text-s hover:text-th-text transition-colors">
|
||||
{t('auth.backToHome')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
477
src/pages/RoomDetail.jsx
Normal file
477
src/pages/RoomDetail.jsx
Normal file
@@ -0,0 +1,477 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ArrowLeft, Play, Square, Users, Settings, FileVideo, Radio,
|
||||
Loader2, Copy, ExternalLink, Lock, Mic, MicOff, UserCheck,
|
||||
Shield, Save,
|
||||
} from 'lucide-react';
|
||||
import api from '../services/api';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import RecordingList from '../components/RecordingList';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function RoomDetail() {
|
||||
const { uid } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { t } = useLanguage();
|
||||
|
||||
const [room, setRoom] = useState(null);
|
||||
const [status, setStatus] = useState({ running: false, participantCount: 0, moderatorCount: 0 });
|
||||
const [recordings, setRecordings] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(null);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [editRoom, setEditRoom] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const isOwner = room && user && room.user_id === user.id;
|
||||
|
||||
const fetchRoom = async () => {
|
||||
try {
|
||||
const res = await api.get(`/rooms/${uid}`);
|
||||
setRoom(res.data.room);
|
||||
setEditRoom(res.data.room);
|
||||
} catch {
|
||||
toast.error(t('room.notFound'));
|
||||
navigate('/dashboard');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await api.get(`/rooms/${uid}/status`);
|
||||
setStatus(res.data);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRecordings = async () => {
|
||||
try {
|
||||
const res = await api.get(`/recordings/room/${uid}`);
|
||||
setRecordings(res.data.recordings || []);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoom();
|
||||
fetchStatus();
|
||||
fetchRecordings();
|
||||
const interval = setInterval(fetchStatus, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, [uid]);
|
||||
|
||||
const handleStart = async () => {
|
||||
setActionLoading('start');
|
||||
try {
|
||||
const res = await api.post(`/rooms/${uid}/start`);
|
||||
if (res.data.joinUrl) {
|
||||
window.open(res.data.joinUrl, '_blank');
|
||||
}
|
||||
setTimeout(fetchStatus, 2000);
|
||||
toast.success(t('room.meetingStarted'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('room.meetingStartFailed'));
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoin = async () => {
|
||||
setActionLoading('join');
|
||||
try {
|
||||
const data = room.access_code ? { 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');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('room.joinFailed'));
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnd = async () => {
|
||||
if (!confirm(t('room.endConfirm'))) return;
|
||||
setActionLoading('end');
|
||||
try {
|
||||
await api.post(`/rooms/${uid}/end`);
|
||||
toast.success(t('room.meetingEnded'));
|
||||
setTimeout(fetchStatus, 2000);
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('room.meetingEndFailed'));
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSettings = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await api.put(`/rooms/${uid}`, {
|
||||
name: editRoom.name,
|
||||
welcome_message: editRoom.welcome_message,
|
||||
max_participants: editRoom.max_participants,
|
||||
access_code: editRoom.access_code,
|
||||
mute_on_join: !!editRoom.mute_on_join,
|
||||
require_approval: !!editRoom.require_approval,
|
||||
anyone_can_start: !!editRoom.anyone_can_start,
|
||||
all_join_moderator: !!editRoom.all_join_moderator,
|
||||
record_meeting: !!editRoom.record_meeting,
|
||||
guest_access: !!editRoom.guest_access,
|
||||
moderator_code: editRoom.moderator_code,
|
||||
});
|
||||
setRoom(res.data.room);
|
||||
setEditRoom(res.data.room);
|
||||
toast.success(t('room.settingsSaved'));
|
||||
} catch (err) {
|
||||
toast.error(t('room.settingsSaveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyLink = () => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/rooms/${uid}`);
|
||||
toast.success(t('room.linkCopied'));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 size={32} className="animate-spin text-th-accent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!room) return null;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: t('room.overview'), icon: Play },
|
||||
{ id: 'recordings', label: t('room.recordings'), icon: FileVideo, count: recordings.length },
|
||||
...(isOwner ? [{ id: 'settings', label: t('room.settings'), icon: Settings }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="btn-ghost text-sm mb-4"
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
{t('room.backToDashboard')}
|
||||
</button>
|
||||
|
||||
{/* Room header */}
|
||||
<div className="card p-6 mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<h1 className="text-2xl font-bold text-th-text">{room.name}</h1>
|
||||
{status.running && (
|
||||
<span className="flex items-center gap-1.5 px-2.5 py-1 bg-th-success/15 text-th-success rounded-full text-xs font-semibold">
|
||||
<Radio size={12} className="animate-pulse" />
|
||||
{t('common.live')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-th-text-s">
|
||||
<span className="flex items-center gap-1">
|
||||
<Users size={14} />
|
||||
{status.running ? t('room.participants', { count: status.participantCount }) : t('common.offline')}
|
||||
</span>
|
||||
{room.access_code && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Lock size={14} />
|
||||
{t('common.protected')}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={copyLink} className="flex items-center gap-1 hover:text-th-accent transition-colors">
|
||||
<Copy size={14} />
|
||||
{t('room.copyLink')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isOwner && !status.running && (
|
||||
<button onClick={handleStart} disabled={actionLoading === 'start'} className="btn-primary">
|
||||
{actionLoading === 'start' ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
|
||||
{t('room.start')}
|
||||
</button>
|
||||
)}
|
||||
{status.running && (
|
||||
<button onClick={handleJoin} disabled={actionLoading === 'join'} className="btn-primary">
|
||||
{actionLoading === 'join' ? <Loader2 size={16} className="animate-spin" /> : <ExternalLink size={16} />}
|
||||
{t('room.join')}
|
||||
</button>
|
||||
)}
|
||||
{isOwner && status.running && (
|
||||
<button onClick={handleEnd} disabled={actionLoading === 'end'} className="btn-danger">
|
||||
{actionLoading === 'end' ? <Loader2 size={16} className="animate-spin" /> : <Square size={16} />}
|
||||
{t('room.end')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 mb-6 border-b border-th-border">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === tab.id
|
||||
? 'border-th-accent text-th-accent'
|
||||
: 'border-transparent text-th-text-s hover:text-th-text hover:border-th-border'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={16} />
|
||||
{tab.label}
|
||||
{tab.count !== undefined && tab.count > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full bg-th-accent/15 text-th-accent text-xs">
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Meeting info */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-th-text mb-4">{t('room.meetingDetails')}</h3>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-th-text-s">{t('room.status')}</span>
|
||||
<span className={status.running ? 'text-th-success font-medium' : 'text-th-text-s'}>
|
||||
{status.running ? t('common.active') : t('common.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-th-text-s">{t('room.maxParticipants')}</span>
|
||||
<span className="text-th-text">{room.max_participants || t('common.unlimited')}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-th-text-s">{t('room.accessCode')}</span>
|
||||
<span className="text-th-text">{room.access_code || t('common.none')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Room settings overview */}
|
||||
<div className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-th-text mb-4">{t('room.roomSettings')}</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
{room.mute_on_join ? <MicOff size={16} className="text-th-warning" /> : <Mic size={16} className="text-th-success" />}
|
||||
<span className="text-th-text">
|
||||
{room.mute_on_join ? t('room.mutedOnJoin') : t('room.micActiveOnJoin')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<UserCheck size={16} className={room.require_approval ? 'text-th-warning' : 'text-th-text-s'} />
|
||||
<span className="text-th-text">
|
||||
{room.require_approval ? t('room.approvalRequired') : t('room.freeJoin')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<Shield size={16} className={room.all_join_moderator ? 'text-th-accent' : 'text-th-text-s'} />
|
||||
<span className="text-th-text">
|
||||
{room.all_join_moderator ? t('room.allModerators') : t('room.rolesAssigned')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<FileVideo size={16} className={room.record_meeting ? 'text-th-success' : 'text-th-text-s'} />
|
||||
<span className="text-th-text">
|
||||
{room.record_meeting ? t('room.recordingAllowed') : t('room.recordingDisabled')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Welcome message */}
|
||||
<div className="card p-5 md:col-span-2">
|
||||
<h3 className="text-sm font-semibold text-th-text mb-2">{t('room.welcomeMsg')}</h3>
|
||||
<p className="text-sm text-th-text-s">{room.welcome_message || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'recordings' && (
|
||||
<RecordingList recordings={recordings} onRefresh={fetchRecordings} />
|
||||
)}
|
||||
|
||||
{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>
|
||||
<input
|
||||
type="text"
|
||||
value={editRoom.name}
|
||||
onChange={e => setEditRoom({ ...editRoom, name: e.target.value })}
|
||||
className="input-field"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.welcomeMsg')}</label>
|
||||
<textarea
|
||||
value={editRoom.welcome_message || ''}
|
||||
onChange={e => setEditRoom({ ...editRoom, welcome_message: e.target.value })}
|
||||
className="input-field resize-none"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input
|
||||
type="number"
|
||||
value={editRoom.max_participants}
|
||||
onChange={e => setEditRoom({ ...editRoom, max_participants: parseInt(e.target.value) || 0 })}
|
||||
className="input-field"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.accessCode')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editRoom.access_code || ''}
|
||||
onChange={e => setEditRoom({ ...editRoom, access_code: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder={t('room.emptyNoCode')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm text-th-text">{t('room.muteOnJoin')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm text-th-text">{t('room.requireApproval')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm text-th-text">{t('room.anyoneCanStart')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm text-th-text">{t('room.allJoinModerator')}</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
<span className="text-sm text-th-text">{t('room.allowRecording')}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Guest access section */}
|
||||
<div className="pt-4 border-t border-th-border space-y-4">
|
||||
<h3 className="text-sm font-semibold text-th-text">{t('room.guestAccessTitle')}</h3>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!editRoom.guest_access}
|
||||
onChange={e => setEditRoom({ ...editRoom, guest_access: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-th-border text-th-accent focus:ring-th-ring"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm text-th-text">{t('room.guestAccess')}</span>
|
||||
<p className="text-xs text-th-text-s">{t('room.guestAccessHint')}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{editRoom.guest_access && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.moderatorCode')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editRoom.moderator_code || ''}
|
||||
onChange={e => setEditRoom({ ...editRoom, moderator_code: e.target.value })}
|
||||
className="input-field"
|
||||
placeholder={t('room.moderatorCodeHint')}
|
||||
/>
|
||||
<p className="text-xs text-th-text-s mt-1">{t('room.moderatorCodeDesc')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('room.guestLink')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-th-bg-s px-3 py-2 rounded-lg text-xs text-th-text font-mono truncate border border-th-border">
|
||||
{window.location.origin}/join/{room.uid}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`${window.location.origin}/join/${room.uid}`);
|
||||
toast.success(t('room.linkCopied'));
|
||||
}}
|
||||
className="btn-ghost text-xs py-2 px-3"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-th-border">
|
||||
<button type="submit" disabled={saving} className="btn-primary">
|
||||
{saving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
406
src/pages/Settings.jsx
Normal file
406
src/pages/Settings.jsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { User, Mail, Lock, Palette, Save, Loader2, Globe, Camera, X } from 'lucide-react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { themes, getThemeGroups } from '../themes';
|
||||
import api from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Settings() {
|
||||
const { user, updateUser } = useAuth();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t, language, setLanguage } = useLanguage();
|
||||
|
||||
const [profile, setProfile] = useState({
|
||||
name: user?.name || '',
|
||||
email: user?.email || '',
|
||||
});
|
||||
const [passwords, setPasswords] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [savingProfile, setSavingProfile] = useState(false);
|
||||
const [savingPassword, setSavingPassword] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState('profile');
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
const groups = getThemeGroups();
|
||||
|
||||
const avatarColors = [
|
||||
'#6366f1', '#8b5cf6', '#a855f7', '#d946ef',
|
||||
'#ec4899', '#f43f5e', '#ef4444', '#f97316',
|
||||
'#eab308', '#22c55e', '#14b8a6', '#06b6d4',
|
||||
'#3b82f6', '#2563eb', '#7c3aed', '#64748b',
|
||||
];
|
||||
|
||||
const handleProfileSave = async (e) => {
|
||||
e.preventDefault();
|
||||
setSavingProfile(true);
|
||||
try {
|
||||
const res = await api.put('/auth/profile', {
|
||||
name: profile.name,
|
||||
email: profile.email,
|
||||
theme,
|
||||
avatar_color: user?.avatar_color,
|
||||
});
|
||||
updateUser(res.data.user);
|
||||
toast.success(t('settings.profileSaved'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('settings.profileSaveFailed'));
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSave = async (e) => {
|
||||
e.preventDefault();
|
||||
if (passwords.newPassword !== passwords.confirmPassword) {
|
||||
toast.error(t('settings.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
setSavingPassword(true);
|
||||
try {
|
||||
await api.put('/auth/password', {
|
||||
currentPassword: passwords.currentPassword,
|
||||
newPassword: passwords.newPassword,
|
||||
});
|
||||
setPasswords({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||
toast.success(t('settings.passwordChanged'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('settings.passwordChangeFailed'));
|
||||
} finally {
|
||||
setSavingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarColor = async (color) => {
|
||||
try {
|
||||
const res = await api.put('/auth/profile', { avatar_color: color });
|
||||
updateUser(res.data.user);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error(t('settings.avatarInvalidType'));
|
||||
return;
|
||||
}
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error(t('settings.avatarTooLarge'));
|
||||
return;
|
||||
}
|
||||
setUploadingAvatar(true);
|
||||
try {
|
||||
const res = await api.post('/auth/avatar', file, {
|
||||
headers: { 'Content-Type': file.type },
|
||||
});
|
||||
updateUser(res.data.user);
|
||||
toast.success(t('settings.avatarUploaded'));
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('settings.avatarUploadFailed'));
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
try {
|
||||
const res = await api.delete('/auth/avatar');
|
||||
updateUser(res.data.user);
|
||||
toast.success(t('settings.avatarRemoved'));
|
||||
} catch {
|
||||
toast.error(t('settings.avatarRemoveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const sections = [
|
||||
{ id: 'profile', label: t('settings.profile'), icon: User },
|
||||
{ id: 'password', label: t('settings.password'), icon: Lock },
|
||||
{ id: 'language', label: t('settings.language'), icon: Globe },
|
||||
{ id: 'themes', label: t('settings.themes'), icon: Palette },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-th-text">{t('settings.title')}</h1>
|
||||
<p className="text-sm text-th-text-s mt-1">{t('settings.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{/* Section nav */}
|
||||
<div className="md:w-56 flex-shrink-0">
|
||||
<nav className="flex md:flex-col gap-1">
|
||||
{sections.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setActiveSection(s.id)}
|
||||
className={`flex items-center gap-2.5 px-3 py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
activeSection === s.id
|
||||
? 'bg-th-accent text-th-accent-t'
|
||||
: 'text-th-text-s hover:text-th-text hover:bg-th-hover'
|
||||
}`}
|
||||
>
|
||||
<s.icon size={16} />
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 max-w-2xl">
|
||||
{/* Profile section */}
|
||||
{activeSection === 'profile' && (
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-th-text mb-6">{t('settings.editProfile')}</h2>
|
||||
|
||||
{/* Avatar */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-th-text mb-3">{t('settings.avatar')}</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative group">
|
||||
{user?.avatar_image ? (
|
||||
<img
|
||||
src={`${api.defaults.baseURL}/auth/avatar/${user.avatar_image}`}
|
||||
alt="Avatar"
|
||||
className="w-16 h-16 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center text-white text-xl font-bold"
|
||||
style={{ backgroundColor: user?.avatar_color || '#6366f1' }}
|
||||
>
|
||||
{user?.name?.[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadingAvatar}
|
||||
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 ? (
|
||||
<Loader2 size={20} className="text-white animate-spin" />
|
||||
) : (
|
||||
<Camera size={20} className="text-white" />
|
||||
)}
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="btn-ghost text-xs py-1.5 px-3"
|
||||
>
|
||||
<Camera size={14} />
|
||||
{t('settings.uploadImage')}
|
||||
</button>
|
||||
{user?.avatar_image && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAvatarRemove}
|
||||
className="btn-ghost text-xs py-1.5 px-3 text-th-error hover:text-th-error"
|
||||
>
|
||||
<X size={14} />
|
||||
{t('settings.removeImage')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-th-text-s">{t('settings.avatarHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{avatarColors.map(color => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => handleAvatarColor(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'
|
||||
}`}
|
||||
style={{ backgroundColor: color, '--tw-ring-offset-color': 'var(--bg-primary)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-th-text-s mt-2">{t('settings.avatarColorHint')}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleProfileSave} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-th-text mb-1.5">{t('auth.name')}</label>
|
||||
<div className="relative">
|
||||
<User size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="text"
|
||||
value={profile.name}
|
||||
onChange={e => setProfile({ ...profile, name: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label 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" />
|
||||
<input
|
||||
type="email"
|
||||
value={profile.email}
|
||||
onChange={e => setProfile({ ...profile, email: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={savingProfile} className="btn-primary">
|
||||
{savingProfile ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password section */}
|
||||
{activeSection === 'password' && (
|
||||
<div className="card p-6">
|
||||
<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>
|
||||
<div className="relative">
|
||||
<Lock size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||
<input
|
||||
type="password"
|
||||
value={passwords.currentPassword}
|
||||
onChange={e => setPasswords({ ...passwords, currentPassword: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label 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" />
|
||||
<input
|
||||
type="password"
|
||||
value={passwords.newPassword}
|
||||
onChange={e => setPasswords({ ...passwords, newPassword: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.minPassword')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label 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" />
|
||||
<input
|
||||
type="password"
|
||||
value={passwords.confirmPassword}
|
||||
onChange={e => setPasswords({ ...passwords, confirmPassword: e.target.value })}
|
||||
className="input-field pl-11"
|
||||
placeholder={t('auth.repeatPassword')}
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" disabled={savingPassword} className="btn-primary">
|
||||
{savingPassword ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
|
||||
{t('settings.changePassword')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Language section */}
|
||||
{activeSection === 'language' && (
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold text-th-text mb-2">{t('settings.selectLanguage')}</h2>
|
||||
<p className="text-sm text-th-text-s mb-6">{t('settings.subtitle')}</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{[
|
||||
{ code: 'en', label: 'English', flag: '🇬🇧' },
|
||||
{ code: 'de', label: 'Deutsch', flag: '🇩🇪' },
|
||||
].map(lang => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => setLanguage(lang.code)}
|
||||
className={`flex items-center gap-3 p-4 rounded-xl border-2 transition-all ${
|
||||
language === lang.code
|
||||
? 'border-th-accent shadow-md bg-th-accent/5'
|
||||
: 'border-transparent hover:border-th-border'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{lang.flag}</span>
|
||||
<span className="text-sm font-medium text-th-text">{lang.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Themes section */}
|
||||
{activeSection === 'themes' && (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groups).map(([groupName, groupThemes]) => (
|
||||
<div key={groupName} className="card p-5">
|
||||
<h3 className="text-sm font-semibold text-th-text-s uppercase tracking-wider mb-4">{groupName}</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{groupThemes.map(th => (
|
||||
<button
|
||||
key={th.id}
|
||||
onClick={() => setTheme(th.id)}
|
||||
className={`flex items-center gap-3 p-3 rounded-xl border-2 transition-all ${
|
||||
theme === th.id
|
||||
? 'border-th-accent shadow-md'
|
||||
: 'border-transparent hover:border-th-border'
|
||||
}`}
|
||||
>
|
||||
{/* Color preview */}
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center flex-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 }} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-th-text">{th.name}</p>
|
||||
<p className="text-xs text-th-text-s capitalize">{th.type === 'light' ? t('themes.light') : t('themes.dark')}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
36
src/services/api.js
Normal file
36
src/services/api.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor - add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Response interceptor - handle auth errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token');
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
122
src/themes/index.js
Normal file
122
src/themes/index.js
Normal file
@@ -0,0 +1,122 @@
|
||||
export const themes = [
|
||||
{
|
||||
id: 'light',
|
||||
name: 'Hell',
|
||||
type: 'light',
|
||||
group: 'Standard',
|
||||
colors: { bg: '#ffffff', accent: '#3b82f6', text: '#0f172a' },
|
||||
},
|
||||
{
|
||||
id: 'dark',
|
||||
name: 'Dunkel',
|
||||
type: 'dark',
|
||||
group: 'Standard',
|
||||
colors: { bg: '#0f172a', accent: '#3b82f6', text: '#f1f5f9' },
|
||||
},
|
||||
{
|
||||
id: 'dracula',
|
||||
name: 'Dracula',
|
||||
type: 'dark',
|
||||
group: 'Community',
|
||||
colors: { bg: '#282a36', accent: '#bd93f9', text: '#f8f8f2' },
|
||||
},
|
||||
{
|
||||
id: 'mocha',
|
||||
name: 'Catppuccin Mocha',
|
||||
type: 'dark',
|
||||
group: 'Catppuccin',
|
||||
colors: { bg: '#1e1e2e', accent: '#89b4fa', text: '#cdd6f4' },
|
||||
},
|
||||
{
|
||||
id: 'latte',
|
||||
name: 'Catppuccin Latte',
|
||||
type: 'light',
|
||||
group: 'Catppuccin',
|
||||
colors: { bg: '#eff1f5', accent: '#1e66f5', text: '#4c4f69' },
|
||||
},
|
||||
{
|
||||
id: 'nord',
|
||||
name: 'Nord',
|
||||
type: 'dark',
|
||||
group: 'Community',
|
||||
colors: { bg: '#2e3440', accent: '#88c0d0', text: '#eceff4' },
|
||||
},
|
||||
{
|
||||
id: 'tokyo-night',
|
||||
name: 'Tokyo Night',
|
||||
type: 'dark',
|
||||
group: 'Community',
|
||||
colors: { bg: '#1a1b26', accent: '#7aa2f7', text: '#a9b1d6' },
|
||||
},
|
||||
{
|
||||
id: 'gruvbox-dark',
|
||||
name: 'Gruvbox Dark',
|
||||
type: 'dark',
|
||||
group: 'Gruvbox',
|
||||
colors: { bg: '#282828', accent: '#fe8019', text: '#ebdbb2' },
|
||||
},
|
||||
{
|
||||
id: 'gruvbox-light',
|
||||
name: 'Gruvbox Light',
|
||||
type: 'light',
|
||||
group: 'Gruvbox',
|
||||
colors: { bg: '#fbf1c7', accent: '#d65d0e', text: '#3c3836' },
|
||||
},
|
||||
{
|
||||
id: 'rose-pine',
|
||||
name: 'Rosé Pine',
|
||||
type: 'dark',
|
||||
group: 'Rosé Pine',
|
||||
colors: { bg: '#191724', accent: '#c4a7e7', text: '#e0def4' },
|
||||
},
|
||||
{
|
||||
id: 'rose-pine-dawn',
|
||||
name: 'Rosé Pine Dawn',
|
||||
type: 'light',
|
||||
group: 'Rosé Pine',
|
||||
colors: { bg: '#faf4ed', accent: '#907aa9', text: '#575279' },
|
||||
},
|
||||
{
|
||||
id: 'solarized-dark',
|
||||
name: 'Solarized Dark',
|
||||
type: 'dark',
|
||||
group: 'Solarized',
|
||||
colors: { bg: '#002b36', accent: '#268bd2', text: '#839496' },
|
||||
},
|
||||
{
|
||||
id: 'solarized-light',
|
||||
name: 'Solarized Light',
|
||||
type: 'light',
|
||||
group: 'Solarized',
|
||||
colors: { bg: '#fdf6e3', accent: '#268bd2', text: '#657b83' },
|
||||
},
|
||||
{
|
||||
id: 'one-dark',
|
||||
name: 'One Dark',
|
||||
type: 'dark',
|
||||
group: 'Community',
|
||||
colors: { bg: '#282c34', accent: '#61afef', text: '#abb2bf' },
|
||||
},
|
||||
{
|
||||
id: 'github-dark',
|
||||
name: 'GitHub Dark',
|
||||
type: 'dark',
|
||||
group: 'Community',
|
||||
colors: { bg: '#0d1117', accent: '#58a6ff', text: '#c9d1d9' },
|
||||
},
|
||||
];
|
||||
|
||||
export function getThemeById(id) {
|
||||
return themes.find(t => t.id === id) || themes[1]; // default to dark
|
||||
}
|
||||
|
||||
export function getThemeGroups() {
|
||||
const groups = {};
|
||||
themes.forEach(theme => {
|
||||
if (!groups[theme.group]) {
|
||||
groups[theme.group] = [];
|
||||
}
|
||||
groups[theme.group].push(theme);
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
39
tailwind.config.js
Normal file
39
tailwind.config.js
Normal file
@@ -0,0 +1,39 @@
|
||||
/** @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: [],
|
||||
};
|
||||
15
vite.config.js
Normal file
15
vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user