add branding option
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m10s
All checks were successful
Build & Push Docker Image / build (push) Successful in 1m10s
This commit is contained in:
@@ -30,7 +30,7 @@ COPY server/ ./server/
|
|||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
# Create uploads directory
|
# Create uploads directory
|
||||||
RUN mkdir -p uploads/avatars
|
RUN mkdir -p uploads/avatars uploads/branding
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3001
|
ENV PORT=3001
|
||||||
|
|||||||
87
package-lock.json
generated
87
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "redlight",
|
"name": "redlight",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "redlight",
|
"name": "redlight",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.0",
|
"axios": "^1.7.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
@@ -1333,6 +1334,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/append-field": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/arg": {
|
"node_modules/arg": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
|
||||||
@@ -1599,6 +1606,23 @@
|
|||||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-from": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/busboy": {
|
||||||
|
"version": "1.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
|
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||||
|
"dependencies": {
|
||||||
|
"streamsearch": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -1794,6 +1818,21 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/concat-stream": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 6.0"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-from": "^1.0.0",
|
||||||
|
"inherits": "^2.0.3",
|
||||||
|
"readable-stream": "^3.0.2",
|
||||||
|
"typedarray": "^0.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/concurrently": {
|
"node_modules/concurrently": {
|
||||||
"version": "9.2.1",
|
"version": "9.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz",
|
||||||
@@ -3022,6 +3061,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mkdirp": {
|
||||||
|
"version": "0.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
|
||||||
|
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"minimist": "^1.2.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mkdirp": "bin/cmd.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mkdirp-classic": {
|
"node_modules/mkdirp-classic": {
|
||||||
"version": "0.5.3",
|
"version": "0.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||||
@@ -3034,6 +3085,24 @@
|
|||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/multer": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"append-field": "^1.0.0",
|
||||||
|
"busboy": "^1.6.0",
|
||||||
|
"concat-stream": "^2.0.0",
|
||||||
|
"mkdirp": "^0.5.6",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"type-is": "^1.6.18",
|
||||||
|
"xtend": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mz": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -4168,6 +4237,14 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/streamsearch": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
@@ -4469,6 +4546,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/typedarray": {
|
||||||
|
"version": "0.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"lucide-react": "^0.460.0",
|
"lucide-react": "^0.460.0",
|
||||||
|
"multer": "^2.0.2",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"react": "^18.3.0",
|
"react": "^18.3.0",
|
||||||
"react-dom": "^18.3.0",
|
"react-dom": "^18.3.0",
|
||||||
|
|||||||
@@ -170,6 +170,12 @@ export async function initDatabase() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id);
|
CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id);
|
CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
} else {
|
} else {
|
||||||
await db.exec(`
|
await db.exec(`
|
||||||
@@ -222,6 +228,12 @@ export async function initDatabase() {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||||
CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id);
|
CREATE INDEX IF NOT EXISTS idx_room_shares_room_id ON room_shares(room_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id);
|
CREATE INDEX IF NOT EXISTS idx_room_shares_user_id ON room_shares(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import authRoutes from './routes/auth.js';
|
|||||||
import roomRoutes from './routes/rooms.js';
|
import roomRoutes from './routes/rooms.js';
|
||||||
import recordingRoutes from './routes/recordings.js';
|
import recordingRoutes from './routes/recordings.js';
|
||||||
import adminRoutes from './routes/admin.js';
|
import adminRoutes from './routes/admin.js';
|
||||||
|
import brandingRoutes from './routes/branding.js';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -31,6 +32,7 @@ async function start() {
|
|||||||
app.use('/api/rooms', roomRoutes);
|
app.use('/api/rooms', roomRoutes);
|
||||||
app.use('/api/recordings', recordingRoutes);
|
app.use('/api/recordings', recordingRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
|
app.use('/api/branding', brandingRoutes);
|
||||||
|
|
||||||
// Serve static files in production
|
// Serve static files in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
|||||||
160
server/routes/branding.js
Normal file
160
server/routes/branding.js
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { getDb } from '../config/database.js';
|
||||||
|
import { authenticateToken, requireAdmin } from '../middleware/auth.js';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Ensure uploads/branding directory exists
|
||||||
|
const brandingDir = path.join(__dirname, '..', '..', 'uploads', 'branding');
|
||||||
|
if (!fs.existsSync(brandingDir)) {
|
||||||
|
fs.mkdirSync(brandingDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multer config for logo upload
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => cb(null, brandingDir),
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname).toLowerCase() || '.png';
|
||||||
|
cb(null, `logo${ext}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const allowed = /\.(jpg|jpeg|png|gif|svg|webp|ico)$/i;
|
||||||
|
const mimeAllowed = /^image\/(jpeg|png|gif|svg\+xml|webp|x-icon|vnd\.microsoft\.icon)$/;
|
||||||
|
if (allowed.test(path.extname(file.originalname)) && mimeAllowed.test(file.mimetype)) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only image files are allowed'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: get setting from DB
|
||||||
|
async function getSetting(key) {
|
||||||
|
const db = getDb();
|
||||||
|
const row = await db.get('SELECT value FROM settings WHERE key = ?', [key]);
|
||||||
|
return row?.value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: set setting in DB
|
||||||
|
async function setSetting(key, value) {
|
||||||
|
const db = getDb();
|
||||||
|
// Upsert
|
||||||
|
const existing = await db.get('SELECT key FROM settings WHERE key = ?', [key]);
|
||||||
|
if (existing) {
|
||||||
|
await db.run('UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = ?', [value, key]);
|
||||||
|
} else {
|
||||||
|
await db.run('INSERT INTO settings (key, value) VALUES (?, ?)', [key, value]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: delete setting from DB
|
||||||
|
async function deleteSetting(key) {
|
||||||
|
const db = getDb();
|
||||||
|
await db.run('DELETE FROM settings WHERE key = ?', [key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: find current logo file on disk
|
||||||
|
function findLogoFile() {
|
||||||
|
if (!fs.existsSync(brandingDir)) return null;
|
||||||
|
const files = fs.readdirSync(brandingDir);
|
||||||
|
const logo = files.find(f => f.startsWith('logo.'));
|
||||||
|
return logo ? path.join(brandingDir, logo) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/branding - Get branding settings (public)
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const appName = await getSetting('app_name');
|
||||||
|
const logoFile = findLogoFile();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
appName: appName || 'Redlight',
|
||||||
|
hasLogo: !!logoFile,
|
||||||
|
logoUrl: logoFile ? '/api/branding/logo' : null,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Get branding error:', err);
|
||||||
|
res.status(500).json({ error: 'Could not load branding' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/branding/logo - Serve logo file (public)
|
||||||
|
router.get('/logo', (req, res) => {
|
||||||
|
const logoFile = findLogoFile();
|
||||||
|
if (!logoFile) {
|
||||||
|
return res.status(404).json({ error: 'No logo found' });
|
||||||
|
}
|
||||||
|
res.sendFile(logoFile);
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/branding/logo - Upload logo (admin only)
|
||||||
|
router.post('/logo', authenticateToken, requireAdmin, (req, res) => {
|
||||||
|
upload.single('logo')(req, res, async (err) => {
|
||||||
|
if (err) {
|
||||||
|
if (err instanceof multer.MulterError) {
|
||||||
|
return res.status(400).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'File too large (max 2MB)' : err.message });
|
||||||
|
}
|
||||||
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No file uploaded' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old logo files that don't match the new extension
|
||||||
|
const files = fs.readdirSync(brandingDir);
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.startsWith('logo.') && f !== req.file.filename) {
|
||||||
|
fs.unlinkSync(path.join(brandingDir, f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
logoUrl: '/api/branding/logo',
|
||||||
|
message: 'Logo uploaded',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/branding/logo - Remove logo (admin only)
|
||||||
|
router.delete('/logo', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const logoFile = findLogoFile();
|
||||||
|
if (logoFile) {
|
||||||
|
fs.unlinkSync(logoFile);
|
||||||
|
}
|
||||||
|
res.json({ message: 'Logo removed' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete logo error:', err);
|
||||||
|
res.status(500).json({ error: 'Could not remove logo' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/branding/name - Update app name (admin only)
|
||||||
|
router.put('/name', authenticateToken, requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { appName } = req.body;
|
||||||
|
if (!appName || !appName.trim()) {
|
||||||
|
return res.status(400).json({ error: 'App name is required' });
|
||||||
|
}
|
||||||
|
await setSetting('app_name', appName.trim());
|
||||||
|
res.json({ appName: appName.trim() });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update app name error:', err);
|
||||||
|
res.status(500).json({ error: 'Could not update app name' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { useAuth } from './contexts/AuthContext';
|
import { useAuth } from './contexts/AuthContext';
|
||||||
import { useLanguage } from './contexts/LanguageContext';
|
import { useLanguage } from './contexts/LanguageContext';
|
||||||
|
import { useBranding } from './contexts/BrandingContext';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import ProtectedRoute from './components/ProtectedRoute';
|
import ProtectedRoute from './components/ProtectedRoute';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
@@ -16,6 +17,7 @@ import GuestJoin from './pages/GuestJoin';
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
const { setLanguage } = useLanguage();
|
const { setLanguage } = useLanguage();
|
||||||
|
const { appName } = useBranding();
|
||||||
|
|
||||||
// Sync language from server when user loads
|
// Sync language from server when user loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -24,6 +26,11 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [user?.language, setLanguage]);
|
}, [user?.language, setLanguage]);
|
||||||
|
|
||||||
|
// Update document title with branding
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = `${appName} - BigBlueButton Frontend`;
|
||||||
|
}, [appName]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-th-bg flex items-center justify-center">
|
<div className="min-h-screen bg-th-bg flex items-center justify-center">
|
||||||
|
|||||||
35
src/components/BrandLogo.jsx
Normal file
35
src/components/BrandLogo.jsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { Video } from 'lucide-react';
|
||||||
|
import { useBranding } from '../contexts/BrandingContext';
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: { box: 'w-8 h-8', rounded: 'rounded-lg', icon: 16, text: 'text-lg' },
|
||||||
|
md: { box: 'w-9 h-9', rounded: 'rounded-lg', icon: 20, text: 'text-xl' },
|
||||||
|
lg: { box: 'w-10 h-10', rounded: 'rounded-xl', icon: 22, text: 'text-2xl' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BrandLogo({ size = 'md', className = '' }) {
|
||||||
|
const { appName, hasLogo, logoUrl } = useBranding();
|
||||||
|
const s = sizes[size] || sizes.md;
|
||||||
|
|
||||||
|
if (hasLogo && logoUrl) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2.5 ${className}`}>
|
||||||
|
<img
|
||||||
|
src={logoUrl}
|
||||||
|
alt={appName}
|
||||||
|
className={`${s.box} ${s.rounded} object-contain`}
|
||||||
|
/>
|
||||||
|
<span className={`${s.text} font-bold gradient-text`}>{appName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2.5 ${className}`}>
|
||||||
|
<div className={`${s.box} gradient-bg ${s.rounded} flex items-center justify-center`}>
|
||||||
|
<Video size={s.icon} className="text-white" />
|
||||||
|
</div>
|
||||||
|
<span className={`${s.text} font-bold gradient-text`}>{appName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { LayoutDashboard, Settings, Shield, Video, X, Palette } from 'lucide-react';
|
import { LayoutDashboard, Settings, Shield, X, Palette } from 'lucide-react';
|
||||||
|
import BrandLogo from './BrandLogo';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import ThemeSelector from './ThemeSelector';
|
import ThemeSelector from './ThemeSelector';
|
||||||
@@ -36,14 +37,7 @@ export default function Sidebar({ open, onClose }) {
|
|||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center justify-between h-16 px-4 border-b border-th-border">
|
<div className="flex items-center justify-between h-16 px-4 border-b border-th-border">
|
||||||
<div className="flex items-center gap-2.5">
|
<BrandLogo size="sm" />
|
||||||
<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
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
className="lg:hidden p-1.5 rounded-lg hover:bg-th-hover text-th-text-s transition-colors"
|
||||||
|
|||||||
37
src/contexts/BrandingContext.jsx
Normal file
37
src/contexts/BrandingContext.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
|
const BrandingContext = createContext();
|
||||||
|
|
||||||
|
export function BrandingProvider({ children }) {
|
||||||
|
const [branding, setBranding] = useState({
|
||||||
|
appName: 'Redlight',
|
||||||
|
hasLogo: false,
|
||||||
|
logoUrl: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchBranding = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/branding');
|
||||||
|
setBranding(res.data);
|
||||||
|
} catch {
|
||||||
|
// keep defaults
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBranding();
|
||||||
|
}, [fetchBranding]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BrandingContext.Provider value={{ ...branding, refreshBranding: fetchBranding }}>
|
||||||
|
{children}
|
||||||
|
</BrandingContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBranding() {
|
||||||
|
const ctx = useContext(BrandingContext);
|
||||||
|
if (!ctx) throw new Error('useBranding must be used within BrandingProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -277,6 +277,19 @@
|
|||||||
"userDeleteFailed": "Fehler beim Löschen",
|
"userDeleteFailed": "Fehler beim Löschen",
|
||||||
"passwordReset": "Passwort zurückgesetzt",
|
"passwordReset": "Passwort zurückgesetzt",
|
||||||
"passwordResetFailed": "Fehler beim Zurücksetzen",
|
"passwordResetFailed": "Fehler beim Zurücksetzen",
|
||||||
"deleteUserConfirm": "Benutzer \"{name}\" wirklich löschen? Alle Räume werden ebenfalls gelöscht."
|
"deleteUserConfirm": "Benutzer \"{name}\" wirklich löschen? Alle Räume werden ebenfalls gelöscht.",
|
||||||
|
"brandingTitle": "Branding",
|
||||||
|
"brandingDescription": "Logo und App-Name anpassen, die in der Anwendung angezeigt werden.",
|
||||||
|
"logoLabel": "Logo",
|
||||||
|
"logoUpload": "Logo hochladen",
|
||||||
|
"logoChange": "Logo ändern",
|
||||||
|
"logoHint": "PNG, JPG, SVG oder WebP. Max. 2 MB.",
|
||||||
|
"logoUploaded": "Logo hochgeladen",
|
||||||
|
"logoUploadFailed": "Logo konnte nicht hochgeladen werden",
|
||||||
|
"logoRemoved": "Logo entfernt",
|
||||||
|
"logoRemoveFailed": "Logo konnte nicht entfernt werden",
|
||||||
|
"appNameLabel": "App-Name",
|
||||||
|
"appNameUpdated": "App-Name aktualisiert",
|
||||||
|
"appNameUpdateFailed": "App-Name konnte nicht aktualisiert werden"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -277,6 +277,19 @@
|
|||||||
"userDeleteFailed": "Error deleting user",
|
"userDeleteFailed": "Error deleting user",
|
||||||
"passwordReset": "Password reset",
|
"passwordReset": "Password reset",
|
||||||
"passwordResetFailed": "Error resetting password",
|
"passwordResetFailed": "Error resetting password",
|
||||||
"deleteUserConfirm": "Really delete user \"{name}\"? All rooms will also be deleted."
|
"deleteUserConfirm": "Really delete user \"{name}\"? All rooms will also be deleted.",
|
||||||
|
"brandingTitle": "Branding",
|
||||||
|
"brandingDescription": "Customize the logo and app name shown across the application.",
|
||||||
|
"logoLabel": "Logo",
|
||||||
|
"logoUpload": "Upload logo",
|
||||||
|
"logoChange": "Change logo",
|
||||||
|
"logoHint": "PNG, JPG, SVG or WebP. Max 2 MB.",
|
||||||
|
"logoUploaded": "Logo uploaded",
|
||||||
|
"logoUploadFailed": "Logo upload failed",
|
||||||
|
"logoRemoved": "Logo removed",
|
||||||
|
"logoRemoveFailed": "Could not remove logo",
|
||||||
|
"appNameLabel": "App name",
|
||||||
|
"appNameUpdated": "App name updated",
|
||||||
|
"appNameUpdateFailed": "Could not update app name"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
33
src/main.jsx
33
src/main.jsx
@@ -6,6 +6,7 @@ import App from './App';
|
|||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { ThemeProvider } from './contexts/ThemeContext';
|
import { ThemeProvider } from './contexts/ThemeContext';
|
||||||
import { LanguageProvider } from './contexts/LanguageContext';
|
import { LanguageProvider } from './contexts/LanguageContext';
|
||||||
|
import { BrandingProvider } from './contexts/BrandingContext';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
@@ -13,21 +14,23 @@ ReactDOM.createRoot(document.getElementById('root')).render(
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<LanguageProvider>
|
<LanguageProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AuthProvider>
|
<BrandingProvider>
|
||||||
<App />
|
<AuthProvider>
|
||||||
<Toaster
|
<App />
|
||||||
position="top-right"
|
<Toaster
|
||||||
toastOptions={{
|
position="top-right"
|
||||||
duration: 4000,
|
toastOptions={{
|
||||||
style: {
|
duration: 4000,
|
||||||
background: 'var(--card-bg)',
|
style: {
|
||||||
color: 'var(--text-primary)',
|
background: 'var(--card-bg)',
|
||||||
border: '1px solid var(--border)',
|
color: 'var(--text-primary)',
|
||||||
},
|
border: '1px solid var(--border)',
|
||||||
}}
|
},
|
||||||
/>
|
}}
|
||||||
</AuthProvider>
|
/>
|
||||||
</ThemeProvider>
|
</AuthProvider>
|
||||||
|
</BrandingProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</LanguageProvider>
|
</LanguageProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
Users, Shield, Search, Trash2, ChevronDown, Loader2,
|
||||||
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
MoreVertical, Key, UserCheck, UserX, UserPlus, Mail, Lock, User,
|
||||||
|
Upload, X as XIcon, Image, Type,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { useBranding } from '../contexts/BrandingContext';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function Admin() {
|
export default function Admin() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { t, language } = useLanguage();
|
const { t, language } = useLanguage();
|
||||||
|
const { appName, hasLogo, logoUrl, refreshBranding } = useBranding();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -23,6 +26,12 @@ export default function Admin() {
|
|||||||
const [creatingUser, setCreatingUser] = useState(false);
|
const [creatingUser, setCreatingUser] = useState(false);
|
||||||
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'user' });
|
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'user' });
|
||||||
|
|
||||||
|
// Branding state
|
||||||
|
const [editAppName, setEditAppName] = useState('');
|
||||||
|
const [savingName, setSavingName] = useState(false);
|
||||||
|
const [uploadingLogo, setUploadingLogo] = useState(false);
|
||||||
|
const logoInputRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.role !== 'admin') {
|
if (user?.role !== 'admin') {
|
||||||
navigate('/dashboard');
|
navigate('/dashboard');
|
||||||
@@ -31,6 +40,10 @@ export default function Admin() {
|
|||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEditAppName(appName || 'Redlight');
|
||||||
|
}, [appName]);
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/admin/users');
|
const res = await api.get('/admin/users');
|
||||||
@@ -77,6 +90,51 @@ export default function Admin() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Branding handlers ──────────────────────────────────────────────────
|
||||||
|
const handleLogoUpload = async (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
setUploadingLogo(true);
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('logo', file);
|
||||||
|
await api.post('/branding/logo', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
toast.success(t('admin.logoUploaded'));
|
||||||
|
refreshBranding();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.response?.data?.error || t('admin.logoUploadFailed'));
|
||||||
|
} finally {
|
||||||
|
setUploadingLogo(false);
|
||||||
|
if (logoInputRef.current) logoInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogoRemove = async () => {
|
||||||
|
try {
|
||||||
|
await api.delete('/branding/logo');
|
||||||
|
toast.success(t('admin.logoRemoved'));
|
||||||
|
refreshBranding();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('admin.logoRemoveFailed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAppNameSave = async () => {
|
||||||
|
if (!editAppName.trim()) return;
|
||||||
|
setSavingName(true);
|
||||||
|
try {
|
||||||
|
await api.put('/branding/name', { appName: editAppName.trim() });
|
||||||
|
toast.success(t('admin.appNameUpdated'));
|
||||||
|
refreshBranding();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('admin.appNameUpdateFailed'));
|
||||||
|
} finally {
|
||||||
|
setSavingName(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateUser = async (e) => {
|
const handleCreateUser = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setCreatingUser(true);
|
setCreatingUser(true);
|
||||||
@@ -126,6 +184,90 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Branding */}
|
||||||
|
<div className="card p-6 mb-8">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Image size={20} className="text-th-accent" />
|
||||||
|
<h2 className="text-lg font-semibold text-th-text">{t('admin.brandingTitle')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-th-text-s mb-5">{t('admin.brandingDescription')}</p>
|
||||||
|
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
|
{/* Logo upload */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.logoLabel')}</label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{hasLogo && logoUrl ? (
|
||||||
|
<div className="relative group">
|
||||||
|
<img
|
||||||
|
src={`${logoUrl}?t=${Date.now()}`}
|
||||||
|
alt="Logo"
|
||||||
|
className="w-14 h-14 rounded-xl object-contain border border-th-border bg-th-bg p-1"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleLogoRemove}
|
||||||
|
className="absolute -top-2 -right-2 w-5 h-5 bg-th-error text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<XIcon size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-14 h-14 rounded-xl border-2 border-dashed border-th-border flex items-center justify-center text-th-text-s">
|
||||||
|
<Image size={24} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
ref={logoInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleLogoUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => logoInputRef.current?.click()}
|
||||||
|
disabled={uploadingLogo}
|
||||||
|
className="btn-secondary text-sm"
|
||||||
|
>
|
||||||
|
{uploadingLogo ? (
|
||||||
|
<Loader2 size={14} className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Upload size={14} />
|
||||||
|
)}
|
||||||
|
{hasLogo ? t('admin.logoChange') : t('admin.logoUpload')}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-th-text-s mt-1">{t('admin.logoHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* App name */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-th-text mb-2">{t('admin.appNameLabel')}</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Type size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editAppName}
|
||||||
|
onChange={e => setEditAppName(e.target.value)}
|
||||||
|
className="input-field pl-9 text-sm"
|
||||||
|
placeholder="Redlight"
|
||||||
|
maxLength={30}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAppNameSave}
|
||||||
|
disabled={savingName || editAppName.trim() === appName}
|
||||||
|
className="btn-primary text-sm px-4"
|
||||||
|
>
|
||||||
|
{savingName ? <Loader2 size={14} className="animate-spin" /> : t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="card p-4 mb-6">
|
<div className="card p-4 mb-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio } from 'lucide-react';
|
import { Video, User, Lock, Shield, ArrowRight, Loader2, Users, Radio } from 'lucide-react';
|
||||||
|
import BrandLogo from '../components/BrandLogo';
|
||||||
import api from '../services/api';
|
import api from '../services/api';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
@@ -132,11 +133,8 @@ export default function GuestJoin() {
|
|||||||
<div className="relative w-full max-w-md">
|
<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">
|
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center justify-center gap-2.5 mb-6">
|
<div className="flex justify-center mb-6">
|
||||||
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
|
<BrandLogo size="lg" />
|
||||||
<Video size={22} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold gradient-text">Redlight</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Room info */}
|
{/* Room info */}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe } from 'lucide-react';
|
import { Video, Shield, Users, Palette, ArrowRight, Zap, Globe } from 'lucide-react';
|
||||||
|
import BrandLogo from '../components/BrandLogo';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -48,12 +49,7 @@ export default function Home() {
|
|||||||
|
|
||||||
{/* Navbar */}
|
{/* Navbar */}
|
||||||
<nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-4">
|
<nav className="relative z-10 flex items-center justify-between px-6 md:px-12 py-4">
|
||||||
<div className="flex items-center gap-2.5">
|
<BrandLogo size="md" />
|
||||||
<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">
|
<div className="flex items-center gap-3">
|
||||||
<Link to="/login" className="btn-ghost text-sm">
|
<Link to="/login" className="btn-ghost text-sm">
|
||||||
{t('auth.login')}
|
{t('auth.login')}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useState } from 'react';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { Video, Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
import { Mail, Lock, ArrowRight, Loader2 } from 'lucide-react';
|
||||||
|
import BrandLogo from '../components/BrandLogo';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
@@ -42,11 +43,8 @@ export default function Login() {
|
|||||||
<div className="relative w-full max-w-md">
|
<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">
|
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center justify-center gap-2.5 mb-8">
|
<div className="flex justify-center mb-8">
|
||||||
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
|
<BrandLogo size="lg" />
|
||||||
<Video size={22} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold gradient-text">Redlight</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useState } from 'react';
|
|||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { Video, Mail, Lock, User, ArrowRight, Loader2 } from 'lucide-react';
|
import { Mail, Lock, User, ArrowRight, Loader2 } from 'lucide-react';
|
||||||
|
import BrandLogo from '../components/BrandLogo';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register() {
|
||||||
@@ -55,11 +56,8 @@ export default function Register() {
|
|||||||
<div className="relative w-full max-w-md">
|
<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">
|
<div className="card p-8 backdrop-blur-xl bg-th-card/80 border border-th-border shadow-2xl rounded-2xl">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center justify-center gap-2.5 mb-8">
|
<div className="flex justify-center mb-8">
|
||||||
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
|
<BrandLogo size="lg" />
|
||||||
<Video size={22} className="text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="text-2xl font-bold gradient-text">Redlight</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
Reference in New Issue
Block a user