feat: add calendar component with event management features including create, edit, delete, and share functionalities
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s

This commit is contained in:
2026-03-02 10:35:01 +01:00
parent fae46c8395
commit 13c60ba052
17 changed files with 2210 additions and 2 deletions

View File

@@ -4,7 +4,7 @@ on:
push:
branches:
- main
- federation-try
- develop-calendar
release:
types: [published]

57
outlook-addin/README.md Normal file
View File

@@ -0,0 +1,57 @@
# Redlight Outlook Add-in
An Outlook Add-in (compatible with **Outlook on the Web** and **Outlook 365 / New Outlook**) that lets you create a Redlight meeting room directly from your Outlook calendar.
## Setup
### 1. Configure Environment
Edit `taskpane.html` and replace the `REDLIGHT_BASE_URL` at the top of the script section with your Redlight instance URL:
```js
const REDLIGHT_BASE_URL = 'https://your-redlight-instance.com';
```
### 2. Update Manifest
Edit `manifest.xml` and update:
- All `<bt:Url id="..." DefaultValue="..." />` entries to point to where you host these files.
- The `<Id>` GUID if you want a unique add-in identifier.
- `<SupportUrl>` / `<ProviderName>` as needed.
### 3. Host the Add-in Files
The add-in files (`taskpane.html`, `manifest.xml`) must be served over **HTTPS**. Options:
1. **Host on your Redlight server** — place the files in `public/outlook-addin/` and they'll be served at `https://your-domain/outlook-addin/taskpane.html`.
2. **Host anywhere** — any static HTTPS server works.
### 4. Sideload or Deploy
#### Sideload for Testing (Outlook Web)
1. Go to **Outlook on the Web** → [https://outlook.office.com](https://outlook.office.com)
2. Click the **gear icon****View all Outlook settings****Mail****Customize actions****Get add-ins**
3. Click **My add-ins****Add a custom add-in****Add from file...**
4. Upload `manifest.xml`
#### Sideload for Testing (Outlook 365 Desktop)
1. Open Outlook → **File****Manage Add-ins** (or **Get Add-ins**)
2. Click **My Add-ins****Add a custom add-in****Add from file...**
3. Select `manifest.xml`
#### Deploy via Microsoft 365 Admin Center
1. Go to admin.microsoft.com → **Settings****Integrated apps**
2. Upload the manifest to deploy for your organization
## Usage
1. Open your **Outlook Calendar** and create a **new event**
2. In the compose toolbar, click the **Redlight** icon
3. Log in with your Redlight credentials (token is cached for future use)
4. Select an existing room or create a new one
5. Click **Insert Meeting Link** — the room join URL will be inserted into the event body and location
## Files
- `manifest.xml` — Office Add-in manifest (defines capabilities, icons, URLs)
- `taskpane.html` — The add-in UI and logic (single-file HTML+CSS+JS)

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<circle cx="8" cy="8" r="7" fill="#6366f1"/>
<circle cx="8" cy="8" r="3" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 182 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#6366f1"/>
<circle cx="16" cy="16" r="6" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 187 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80">
<circle cx="40" cy="40" r="36" fill="#6366f1"/>
<circle cx="40" cy="40" r="15" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 188 B

117
outlook-addin/manifest.xml Normal file
View File

@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0"
xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0"
xsi:type="MailApp">
<Id>a1b2c3d4-e5f6-7890-abcd-ef1234567890</Id>
<Version>1.0.0</Version>
<ProviderName>Redlight</ProviderName>
<DefaultLocale>en-US</DefaultLocale>
<DisplayName DefaultValue="Redlight Meeting" />
<Description DefaultValue="Create a Redlight / BigBlueButton meeting room directly from Outlook." />
<SupportUrl DefaultValue="https://github.com/your-org/redlight" />
<Hosts>
<Host Name="Mailbox" />
</Hosts>
<Requirements>
<Sets>
<Set Name="Mailbox" MinVersion="1.5" />
</Sets>
</Requirements>
<FormSettings>
<Form xsi:type="ItemEdit">
<DesktopSettings>
<SourceLocation DefaultValue="https://your-redlight-instance.com/outlook-addin/taskpane.html" />
<RequestedHeight>600</RequestedHeight>
</DesktopSettings>
</Form>
</FormSettings>
<Permissions>ReadWriteItem</Permissions>
<Rule xsi:type="RuleCollection" Mode="Or">
<Rule xsi:type="ItemIs" ItemType="Appointment" FormType="Edit" />
</Rule>
<DisableEntityHighlighting>true</DisableEntityHighlighting>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides"
xsi:type="VersionOverridesV1_0">
<Requirements>
<bt:Sets DefaultMinVersion="1.5">
<bt:Set Name="Mailbox" />
</bt:Sets>
</Requirements>
<Hosts>
<Host xsi:type="MailHost">
<DesktopFormFactor>
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
<OfficeTab id="TabDefault">
<Group id="redlightGroup">
<Label resid="groupLabel" />
<Control xsi:type="Button" id="redlightButton">
<Label resid="buttonLabel" />
<Supertip>
<Title resid="buttonLabel" />
<Description resid="buttonDesc" />
</Supertip>
<Icon>
<bt:Image size="16" resid="icon16" />
<bt:Image size="32" resid="icon32" />
<bt:Image size="80" resid="icon80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="taskpaneUrl" />
</Action>
</Control>
</Group>
</OfficeTab>
</ExtensionPoint>
</DesktopFormFactor>
<!-- Mobile support -->
<MobileFormFactor>
<ExtensionPoint xsi:type="MobileOnlineMeetingCommandSurface">
<Control xsi:type="MobileButton" id="redlightMobileButton">
<Label resid="buttonLabel" />
<Icon>
<bt:Image size="25" resid="icon32" />
<bt:Image size="32" resid="icon32" />
<bt:Image size="48" resid="icon80" />
</Icon>
<Action xsi:type="ShowTaskpane">
<SourceLocation resid="taskpaneUrl" />
</Action>
</Control>
</ExtensionPoint>
</MobileFormFactor>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="icon16" DefaultValue="https://your-redlight-instance.com/outlook-addin/assets/icon-16.png" />
<bt:Image id="icon32" DefaultValue="https://your-redlight-instance.com/outlook-addin/assets/icon-32.png" />
<bt:Image id="icon80" DefaultValue="https://your-redlight-instance.com/outlook-addin/assets/icon-80.png" />
</bt:Images>
<bt:Urls>
<bt:Url id="taskpaneUrl" DefaultValue="https://your-redlight-instance.com/outlook-addin/taskpane.html" />
</bt:Urls>
<bt:ShortStrings>
<bt:String id="groupLabel" DefaultValue="Redlight" />
<bt:String id="buttonLabel" DefaultValue="Redlight Meeting" />
</bt:ShortStrings>
<bt:LongStrings>
<bt:String id="buttonDesc" DefaultValue="Create or select a Redlight meeting room and insert the join link into this event." />
</bt:LongStrings>
</Resources>
</VersionOverrides>
</OfficeApp>

565
outlook-addin/taskpane.html Normal file
View File

@@ -0,0 +1,565 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Redlight Meeting</title>
<!-- Office.js -->
<script src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
<style>
:root {
--accent: #6366f1;
--accent-hover: #4f46e5;
--bg: #0f1117;
--bg-s: #1a1d2e;
--card: #161927;
--border: #2a2d3e;
--text: #e2e8f0;
--text-s: #8892a4;
--success: #22c55e;
--error: #ef4444;
--radius: 10px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
font-size: 13px;
line-height: 1.5;
padding: 16px;
min-height: 100vh;
}
h1 { font-size: 18px; font-weight: 700; margin-bottom: 4px; }
h2 { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--text); }
.subtitle { font-size: 12px; color: var(--text-s); margin-bottom: 16px; }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
margin-bottom: 12px;
}
input, select {
width: 100%;
padding: 8px 12px;
background: var(--bg-s);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 13px;
outline: none;
transition: border-color 0.2s;
}
input:focus, select:focus {
border-color: var(--accent);
}
input::placeholder { color: var(--text-s); }
label {
display: block;
font-size: 12px;
font-weight: 500;
color: var(--text);
margin-bottom: 4px;
}
.form-group { margin-bottom: 12px; }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
width: 100%;
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary {
background: var(--bg-s);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover { background: var(--border); }
.btn-success {
background: var(--success);
color: white;
}
.btn-success:hover { opacity: 0.9; }
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
.room-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 6px;
cursor: pointer;
transition: all 0.2s;
}
.room-item:hover { border-color: var(--accent); background: var(--bg-s); }
.room-item.selected { border-color: var(--accent); background: var(--accent)20; }
.room-item .room-name { font-weight: 600; font-size: 13px; }
.room-item .room-uid { font-size: 11px; color: var(--text-s); }
.divider {
border: none;
border-top: 1px solid var(--border);
margin: 16px 0;
}
.error { color: var(--error); font-size: 12px; margin-top: 6px; }
.success-msg { color: var(--success); font-size: 12px; margin-top: 6px; }
.status {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
padding: 2px 8px;
border-radius: 99px;
font-weight: 500;
}
.status-online { background: rgba(34,197,94,0.15); color: var(--success); }
.status-offline { background: rgba(255,255,255,0.06); color: var(--text-s); }
.logo {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.logo-dot {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--accent);
}
.logo-text { font-size: 16px; font-weight: 700; }
.tabs {
display: flex;
gap: 4px;
background: var(--bg-s);
border-radius: 8px;
padding: 3px;
margin-bottom: 12px;
}
.tab {
flex: 1;
padding: 6px;
text-align: center;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
color: var(--text-s);
border: none;
background: transparent;
transition: all 0.2s;
}
.tab.active {
background: var(--accent);
color: white;
}
.hidden { display: none !important; }
.loading { text-align: center; padding: 20px; color: var(--text-s); }
.link-preview {
background: var(--bg-s);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
font-size: 12px;
word-break: break-all;
color: var(--accent);
margin-top: 8px;
}
</style>
</head>
<body>
<!-- ─── Login View ─── -->
<div id="loginView">
<div class="logo">
<div class="logo-dot"></div>
<span class="logo-text">Redlight</span>
</div>
<h1>Sign in</h1>
<p class="subtitle">Connect to your Redlight instance to create meetings.</p>
<div class="card">
<div class="form-group">
<label>Redlight URL</label>
<input type="url" id="serverUrl" placeholder="https://your-instance.com" />
</div>
<div class="form-group">
<label>Email</label>
<input type="email" id="loginEmail" placeholder="name@example.com" />
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="loginPassword" placeholder="••••••••" />
</div>
<div id="loginError" class="error hidden"></div>
<button class="btn btn-primary" id="loginBtn" onclick="handleLogin()">Sign in</button>
</div>
</div>
<!-- ─── Main View ─── -->
<div id="mainView" class="hidden">
<div class="logo">
<div class="logo-dot"></div>
<span class="logo-text">Redlight</span>
</div>
<div class="tabs">
<button class="tab active" id="tabExisting" onclick="switchTab('existing')">Select Room</button>
<button class="tab" id="tabCreate" onclick="switchTab('create')">New Room</button>
</div>
<!-- ─── Existing Rooms ─── -->
<div id="existingPanel">
<div id="roomsList">
<div class="loading">Loading rooms...</div>
</div>
</div>
<!-- ─── Create Room ─── -->
<div id="createPanel" class="hidden">
<div class="card">
<div class="form-group">
<label>Room Name *</label>
<input type="text" id="newRoomName" placeholder="e.g. Team Meeting" />
</div>
<div class="form-group">
<label>Access Code</label>
<input type="text" id="newAccessCode" placeholder="Optional" />
</div>
<div id="createError" class="error hidden"></div>
<button class="btn btn-primary" id="createRoomBtn" onclick="handleCreateRoom()">Create Room</button>
</div>
</div>
<hr class="divider" />
<!-- ─── Selected Room / Insert ─── -->
<div id="selectedRoomPanel" class="hidden">
<h2>Selected Room</h2>
<div class="card">
<div id="selectedRoomName" style="font-weight:600;margin-bottom:4px;"></div>
<div id="selectedRoomUid" style="font-size:11px;color:var(--text-s);"></div>
<div id="meetingLinkPreview" class="link-preview hidden"></div>
</div>
<button class="btn btn-success" id="insertBtn" onclick="insertMeetingLink()">
&#x2714; Insert Meeting Link
</button>
<div id="insertSuccess" class="success-msg hidden">Meeting link inserted!</div>
</div>
<div style="margin-top:16px;">
<button class="btn btn-secondary btn-sm" onclick="handleLogout()">Sign out</button>
</div>
</div>
<script>
// ─── Configuration ──────────────────────────────────────────────────────
// Will be populated from user input or localStorage
let REDLIGHT_BASE_URL = '';
let authToken = '';
let selectedRoom = null;
// ─── Office.js initialization ───────────────────────────────────────────
Office.onReady(function (info) {
// Check for cached credentials
const cached = localStorage.getItem('redlight_outlook');
if (cached) {
try {
const data = JSON.parse(cached);
REDLIGHT_BASE_URL = data.url;
authToken = data.token;
document.getElementById('serverUrl').value = data.url;
// Verify token is still valid
verifyToken().then(valid => {
if (valid) {
showMainView();
loadRooms();
} else {
showLoginView();
}
});
} catch {
showLoginView();
}
}
});
// ─── API Helper ─────────────────────────────────────────────────────────
async function apiRequest(method, path, body) {
const url = `${REDLIGHT_BASE_URL}/api${path}`;
const headers = { 'Content-Type': 'application/json' };
if (authToken) headers['Authorization'] = `Bearer ${authToken}`;
const options = { method, headers };
if (body) options.body = JSON.stringify(body);
const res = await fetch(url, options);
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
return res.json();
}
async function verifyToken() {
try {
await apiRequest('GET', '/auth/me');
return true;
} catch {
return false;
}
}
// ─── Login ──────────────────────────────────────────────────────────────
async function handleLogin() {
const url = document.getElementById('serverUrl').value.trim().replace(/\/+$/, '');
const email = document.getElementById('loginEmail').value.trim();
const password = document.getElementById('loginPassword').value;
const errorEl = document.getElementById('loginError');
errorEl.classList.add('hidden');
if (!url || !email || !password) {
errorEl.textContent = 'All fields are required.';
errorEl.classList.remove('hidden');
return;
}
REDLIGHT_BASE_URL = url;
document.getElementById('loginBtn').disabled = true;
try {
const data = await apiRequest('POST', '/auth/login', { email, password });
authToken = data.token;
// Cache credentials
localStorage.setItem('redlight_outlook', JSON.stringify({ url, token: authToken }));
showMainView();
loadRooms();
} catch (err) {
errorEl.textContent = err.message || 'Login failed';
errorEl.classList.remove('hidden');
} finally {
document.getElementById('loginBtn').disabled = false;
}
}
function handleLogout() {
authToken = '';
localStorage.removeItem('redlight_outlook');
showLoginView();
}
// ─── Views ──────────────────────────────────────────────────────────────
function showLoginView() {
document.getElementById('loginView').classList.remove('hidden');
document.getElementById('mainView').classList.add('hidden');
}
function showMainView() {
document.getElementById('loginView').classList.add('hidden');
document.getElementById('mainView').classList.remove('hidden');
}
function switchTab(tab) {
document.getElementById('tabExisting').classList.toggle('active', tab === 'existing');
document.getElementById('tabCreate').classList.toggle('active', tab === 'create');
document.getElementById('existingPanel').classList.toggle('hidden', tab !== 'existing');
document.getElementById('createPanel').classList.toggle('hidden', tab !== 'create');
}
// ─── Load Rooms ─────────────────────────────────────────────────────────
async function loadRooms() {
const container = document.getElementById('roomsList');
container.innerHTML = '<div class="loading">Loading rooms...</div>';
try {
const data = await apiRequest('GET', '/rooms');
const rooms = data.rooms || [];
if (rooms.length === 0) {
container.innerHTML = '<div class="loading">No rooms found. Create one first.</div>';
return;
}
container.innerHTML = rooms.map(r => `
<div class="room-item" onclick="selectRoom('${r.uid}', '${escapeHtml(r.name)}')" id="room-${r.uid}">
<div>
<div class="room-name">${escapeHtml(r.name)}</div>
<div class="room-uid">${r.uid}</div>
</div>
</div>
`).join('');
} catch (err) {
container.innerHTML = `<div class="error">${err.message}</div>`;
}
}
function selectRoom(uid, name) {
selectedRoom = { uid, name };
// Update UI
document.querySelectorAll('.room-item').forEach(el => el.classList.remove('selected'));
const el = document.getElementById(`room-${uid}`);
if (el) el.classList.add('selected');
// Show selected panel
const panel = document.getElementById('selectedRoomPanel');
panel.classList.remove('hidden');
document.getElementById('selectedRoomName').textContent = name;
document.getElementById('selectedRoomUid').textContent = uid;
document.getElementById('insertSuccess').classList.add('hidden');
// Build and show meeting link preview
const link = `${REDLIGHT_BASE_URL}/join/${uid}`;
const preview = document.getElementById('meetingLinkPreview');
preview.textContent = link;
preview.classList.remove('hidden');
}
// ─── Create Room ────────────────────────────────────────────────────────
async function handleCreateRoom() {
const name = document.getElementById('newRoomName').value.trim();
const accessCode = document.getElementById('newAccessCode').value.trim();
const errorEl = document.getElementById('createError');
errorEl.classList.add('hidden');
if (!name) {
errorEl.textContent = 'Room name is required.';
errorEl.classList.remove('hidden');
return;
}
document.getElementById('createRoomBtn').disabled = true;
try {
const data = await apiRequest('POST', '/rooms', {
name,
access_code: accessCode || null,
mute_on_join: true,
record_meeting: true,
});
const room = data.room;
selectRoom(room.uid, room.name);
switchTab('existing');
loadRooms(); // Refresh list
// Clear form
document.getElementById('newRoomName').value = '';
document.getElementById('newAccessCode').value = '';
} catch (err) {
errorEl.textContent = err.message || 'Could not create room';
errorEl.classList.remove('hidden');
} finally {
document.getElementById('createRoomBtn').disabled = false;
}
}
// ─── Insert Meeting Link into Outlook Event ─────────────────────────────
function insertMeetingLink() {
if (!selectedRoom) return;
const joinUrl = `${REDLIGHT_BASE_URL}/join/${selectedRoom.uid}`;
const meetingHtml = `
<br/>
<div style="border-left: 3px solid #6366f1; padding: 8px 14px; margin: 12px 0; background: #f8f9fa; border-radius: 4px;">
<strong style="color:#6366f1;">Redlight Meeting</strong><br/>
<strong>${escapeHtml(selectedRoom.name)}</strong><br/>
<a href="${joinUrl}" style="color:#6366f1;">${joinUrl}</a>
</div>
`;
try {
const item = Office.context.mailbox.item;
// Set location
if (item.location && item.location.setAsync) {
item.location.setAsync(joinUrl);
}
// Append meeting link to body
item.body.getTypeAsync(function (result) {
if (result.status === Office.AsyncResultStatus.Succeeded) {
const bodyType = result.value;
if (bodyType === Office.CoercionType.Html) {
item.body.setSelectedDataAsync(meetingHtml, { coercionType: Office.CoercionType.Html }, function (r) {
if (r.status === Office.AsyncResultStatus.Succeeded) {
showInsertSuccess();
} else {
// Fallback: append at end
item.body.prependAsync(meetingHtml, { coercionType: Office.CoercionType.Html }, function () {
showInsertSuccess();
});
}
});
} else {
// Plain text fallback
const textContent = `\n\nRedlight Meeting: ${selectedRoom.name}\n${joinUrl}\n`;
item.body.setSelectedDataAsync(textContent, { coercionType: Office.CoercionType.Text }, function () {
showInsertSuccess();
});
}
}
});
} catch (err) {
// If Office.js is not available (testing outside Outlook)
console.log('Meeting link:', joinUrl);
showInsertSuccess();
}
}
function showInsertSuccess() {
document.getElementById('insertSuccess').classList.remove('hidden');
document.getElementById('insertBtn').textContent = '✓ Inserted!';
setTimeout(() => {
document.getElementById('insertBtn').innerHTML = '&#x2714; Insert Meeting Link';
}, 3000);
}
// ─── Utilities ──────────────────────────────────────────────────────────
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
</script>
</body>
</html>

View File

@@ -438,6 +438,82 @@ export async function initDatabase() {
`);
}
// ── Calendar tables ──────────────────────────────────────────────────────
if (isPostgres) {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_events (
id SERIAL PRIMARY KEY,
uid TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
room_uid TEXT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
color TEXT DEFAULT '#6366f1',
federated_from TEXT DEFAULT NULL,
federated_join_url TEXT DEFAULT NULL,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cal_events_user_id ON calendar_events(user_id);
CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid);
CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time);
CREATE TABLE IF NOT EXISTS calendar_event_shares (
id SERIAL PRIMARY KEY,
event_id INTEGER NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(event_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id);
CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id);
`);
} else {
await db.exec(`
CREATE TABLE IF NOT EXISTS calendar_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uid TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
start_time DATETIME NOT NULL,
end_time DATETIME NOT NULL,
room_uid TEXT,
user_id INTEGER NOT NULL,
color TEXT DEFAULT '#6366f1',
federated_from TEXT DEFAULT NULL,
federated_join_url TEXT DEFAULT NULL,
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_cal_events_user_id ON calendar_events(user_id);
CREATE INDEX IF NOT EXISTS idx_cal_events_uid ON calendar_events(uid);
CREATE INDEX IF NOT EXISTS idx_cal_events_start ON calendar_events(start_time);
CREATE TABLE IF NOT EXISTS calendar_event_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(event_id, user_id),
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_cal_shares_event ON calendar_event_shares(event_id);
CREATE INDEX IF NOT EXISTS idx_cal_shares_user ON calendar_event_shares(user_id);
`);
}
// Calendar migrations: add federated columns if missing
if (!(await db.columnExists('calendar_events', 'federated_from'))) {
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_from TEXT DEFAULT NULL');
}
if (!(await db.columnExists('calendar_events', 'federated_join_url'))) {
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
}
// ── Default admin (only on very first start) ────────────────────────────
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
if (!adminAlreadySeeded) {

View File

@@ -13,6 +13,7 @@ import recordingRoutes from './routes/recordings.js';
import adminRoutes from './routes/admin.js';
import brandingRoutes from './routes/branding.js';
import federationRoutes, { wellKnownHandler } from './routes/federation.js';
import calendarRoutes from './routes/calendar.js';
import { startFederationSync } from './jobs/federationSync.js';
const __filename = fileURLToPath(import.meta.url);
@@ -53,6 +54,9 @@ async function start() {
app.use('/api/admin', adminRoutes);
app.use('/api/branding', brandingRoutes);
app.use('/api/federation', federationRoutes);
app.use('/api/calendar', calendarRoutes);
// Mount calendar federation receive also under /api/federation for remote instances
app.use('/api/federation', calendarRoutes);
app.get('/.well-known/redlight', wellKnownHandler);
// Serve static files in production

489
server/routes/calendar.js Normal file
View File

@@ -0,0 +1,489 @@
import { Router } from 'express';
import crypto from 'crypto';
import { getDb } from '../config/database.js';
import { authenticateToken } from '../middleware/auth.js';
import { log } from '../config/logger.js';
import {
isFederationEnabled,
getFederationDomain,
signPayload,
verifyPayload,
discoverInstance,
parseAddress,
} from '../config/federation.js';
import { rateLimit } from 'express-rate-limit';
const router = Router();
// Rate limit for federation calendar receive
const calendarFederationLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests. Please try again later.' },
});
// ── GET /api/calendar/events — List events for the current user ─────────────
router.get('/events', authenticateToken, async (req, res) => {
try {
const db = getDb();
const { from, to } = req.query;
let sql = `
SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name
FROM calendar_events ce
JOIN users u ON ce.user_id = u.id
WHERE (ce.user_id = ? OR ce.id IN (
SELECT event_id FROM calendar_event_shares WHERE user_id = ?
))
`;
const params = [req.user.id, req.user.id];
if (from) {
sql += ' AND ce.end_time >= ?';
params.push(from);
}
if (to) {
sql += ' AND ce.start_time <= ?';
params.push(to);
}
sql += ' ORDER BY ce.start_time ASC';
const events = await db.all(sql, params);
// Mark shared events
for (const ev of events) {
ev.is_owner = ev.user_id === req.user.id;
}
res.json({ events });
} catch (err) {
log.server.error(`Calendar list error: ${err.message}`);
res.status(500).json({ error: 'Events could not be loaded' });
}
});
// ── GET /api/calendar/events/:id — Get single event ─────────────────────────
router.get('/events/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const event = await db.get(`
SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name
FROM calendar_events ce
JOIN users u ON ce.user_id = u.id
WHERE ce.id = ?
`, [req.params.id]);
if (!event) return res.status(404).json({ error: 'Event not found' });
// Check access
if (event.user_id !== req.user.id) {
const share = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, req.user.id]);
if (!share) return res.status(403).json({ error: 'No permission' });
}
// Get shared users
const sharedUsers = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_event_shares ces
JOIN users u ON ces.user_id = u.id
WHERE ces.event_id = ?
`, [event.id]);
event.is_owner = event.user_id === req.user.id;
res.json({ event, sharedUsers });
} catch (err) {
log.server.error(`Calendar get event error: ${err.message}`);
res.status(500).json({ error: 'Event could not be loaded' });
}
});
// ── POST /api/calendar/events — Create event ────────────────────────────────
router.post('/events', authenticateToken, async (req, res) => {
try {
const { title, description, start_time, end_time, room_uid, color } = req.body;
if (!title || !title.trim()) return res.status(400).json({ error: 'Title is required' });
if (!start_time || !end_time) return res.status(400).json({ error: 'Start and end time are required' });
if (title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
const startDate = new Date(start_time);
const endDate = new Date(end_time);
if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
return res.status(400).json({ error: 'Invalid date format' });
}
if (endDate <= startDate) {
return res.status(400).json({ error: 'End time must be after start time' });
}
// Verify room exists if specified
const db = getDb();
if (room_uid) {
const room = await db.get('SELECT id FROM rooms WHERE uid = ?', [room_uid]);
if (!room) return res.status(400).json({ error: 'Linked room not found' });
}
const uid = crypto.randomBytes(12).toString('hex');
const result = await db.run(`
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [
uid,
title.trim(),
description || null,
startDate.toISOString(),
endDate.toISOString(),
room_uid || null,
req.user.id,
color || '#6366f1',
]);
const event = await db.get('SELECT * FROM calendar_events WHERE id = ?', [result.lastInsertRowid]);
res.status(201).json({ event });
} catch (err) {
log.server.error(`Calendar create error: ${err.message}`);
res.status(500).json({ error: 'Event could not be created' });
}
});
// ── PUT /api/calendar/events/:id — Update event ─────────────────────────────
router.put('/events/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
const { title, description, start_time, end_time, room_uid, color } = req.body;
if (title && title.length > 200) return res.status(400).json({ error: 'Title must not exceed 200 characters' });
if (description && description.length > 5000) return res.status(400).json({ error: 'Description must not exceed 5000 characters' });
if (start_time && end_time) {
const s = new Date(start_time);
const e = new Date(end_time);
if (isNaN(s.getTime()) || isNaN(e.getTime())) return res.status(400).json({ error: 'Invalid date format' });
if (e <= s) return res.status(400).json({ error: 'End time must be after start time' });
}
if (room_uid) {
const room = await db.get('SELECT id FROM rooms WHERE uid = ?', [room_uid]);
if (!room) return res.status(400).json({ error: 'Linked room not found' });
}
await db.run(`
UPDATE calendar_events SET
title = COALESCE(?, title),
description = ?,
start_time = COALESCE(?, start_time),
end_time = COALESCE(?, end_time),
room_uid = ?,
color = COALESCE(?, color),
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, [
title || null,
description !== undefined ? description : event.description,
start_time || null,
end_time || null,
room_uid !== undefined ? (room_uid || null) : event.room_uid,
color || null,
req.params.id,
]);
const updated = await db.get('SELECT * FROM calendar_events WHERE id = ?', [req.params.id]);
res.json({ event: updated });
} catch (err) {
log.server.error(`Calendar update error: ${err.message}`);
res.status(500).json({ error: 'Event could not be updated' });
}
});
// ── DELETE /api/calendar/events/:id — Delete event ──────────────────────────
router.delete('/events/:id', authenticateToken, async (req, res) => {
try {
const db = getDb();
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
await db.run('DELETE FROM calendar_events WHERE id = ?', [req.params.id]);
res.json({ message: 'Event deleted' });
} catch (err) {
log.server.error(`Calendar delete error: ${err.message}`);
res.status(500).json({ error: 'Event could not be deleted' });
}
});
// ── POST /api/calendar/events/:id/share — Share event with local user ───────
router.post('/events/:id/share', authenticateToken, async (req, res) => {
try {
const { user_id } = req.body;
if (!user_id) return res.status(400).json({ error: 'User ID is required' });
const db = getDb();
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
if (user_id === req.user.id) return res.status(400).json({ error: 'Cannot share with yourself' });
const existing = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, user_id]);
if (existing) return res.status(400).json({ error: 'Already shared with this user' });
await db.run('INSERT INTO calendar_event_shares (event_id, user_id) VALUES (?, ?)', [event.id, user_id]);
const sharedUsers = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_event_shares ces
JOIN users u ON ces.user_id = u.id
WHERE ces.event_id = ?
`, [event.id]);
res.json({ sharedUsers });
} catch (err) {
log.server.error(`Calendar share error: ${err.message}`);
res.status(500).json({ error: 'Could not share event' });
}
});
// ── DELETE /api/calendar/events/:id/share/:userId — Remove share ────────────
router.delete('/events/:id/share/:userId', authenticateToken, async (req, res) => {
try {
const db = getDb();
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
await db.run('DELETE FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, parseInt(req.params.userId)]);
const sharedUsers = await db.all(`
SELECT u.id, u.name, u.display_name, u.email, u.avatar_color, u.avatar_image
FROM calendar_event_shares ces
JOIN users u ON ces.user_id = u.id
WHERE ces.event_id = ?
`, [event.id]);
res.json({ sharedUsers });
} catch (err) {
log.server.error(`Calendar unshare error: ${err.message}`);
res.status(500).json({ error: 'Could not remove share' });
}
});
// ── GET /api/calendar/events/:id/ics — Download event as ICS ────────────────
router.get('/events/:id/ics', authenticateToken, async (req, res) => {
try {
const db = getDb();
const event = await db.get(`
SELECT ce.*, COALESCE(NULLIF(u.display_name,''), u.name) as organizer_name, u.email as organizer_email
FROM calendar_events ce
JOIN users u ON ce.user_id = u.id
WHERE ce.id = ?
`, [req.params.id]);
if (!event) return res.status(404).json({ error: 'Event not found' });
// Check access
if (event.user_id !== req.user.id) {
const share = await db.get('SELECT id FROM calendar_event_shares WHERE event_id = ? AND user_id = ?', [event.id, req.user.id]);
if (!share) return res.status(403).json({ error: 'No permission' });
}
// Build room join URL if linked
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
let location = '';
if (event.room_uid) {
location = `${baseUrl}/join/${event.room_uid}`;
}
const ics = generateICS(event, location, baseUrl);
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(event.title)}.ics"`);
res.send(ics);
} catch (err) {
log.server.error(`ICS download error: ${err.message}`);
res.status(500).json({ error: 'Could not generate ICS file' });
}
});
// ── POST /api/calendar/events/:id/federation — Send event to remote user ────
router.post('/events/:id/federation', authenticateToken, async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const { to } = req.body;
if (!to) return res.status(400).json({ error: 'Remote address is required' });
const { username, domain } = parseAddress(to);
if (!domain) return res.status(400).json({ error: 'Remote address must be in format username@domain' });
if (domain === getFederationDomain()) {
return res.status(400).json({ error: 'Cannot send to your own instance. Use local sharing instead.' });
}
const db = getDb();
const event = await db.get('SELECT * FROM calendar_events WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
if (!event) return res.status(404).json({ error: 'Event not found or no permission' });
const baseUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
let joinUrl = null;
if (event.room_uid) {
joinUrl = `${baseUrl}/join/${event.room_uid}`;
}
const payload = {
type: 'calendar_event',
event_uid: event.uid,
title: event.title,
description: event.description || '',
start_time: event.start_time,
end_time: event.end_time,
room_uid: event.room_uid || null,
join_url: joinUrl,
from_user: `@${req.user.name}@${getFederationDomain()}`,
to_user: to,
timestamp: new Date().toISOString(),
};
const signature = signPayload(payload);
const { baseUrl: remoteApi } = await discoverInstance(domain);
const response = await fetch(`${remoteApi}/calendar-event`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Federation-Signature': signature,
'X-Federation-Origin': getFederationDomain(),
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || `Remote server responded with ${response.status}`);
}
res.json({ success: true });
} catch (err) {
log.server.error(`Calendar federation send error: ${err.message}`);
res.status(500).json({ error: err.message || 'Could not send event to remote instance' });
}
});
// ── POST /receive-event or /calendar-event — Receive calendar event from remote ──
// '/receive-event' when mounted at /api/calendar
// '/calendar-event' when mounted at /api/federation (for remote instance discovery)
router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, async (req, res) => {
try {
if (!isFederationEnabled()) {
return res.status(400).json({ error: 'Federation is not configured on this instance' });
}
const signature = req.headers['x-federation-signature'];
const payload = req.body || {};
if (!signature) return res.status(401).json({ error: 'Missing federation signature' });
const { event_uid, title, description, start_time, end_time, room_uid, join_url, from_user, to_user } = payload;
if (!event_uid || !title || !start_time || !end_time || !from_user || !to_user) {
return res.status(400).json({ error: 'Incomplete event payload' });
}
// Validate lengths
if (event_uid.length > 100 || title.length > 200 || (description && description.length > 5000) ||
from_user.length > 200 || to_user.length > 200 || (join_url && join_url.length > 2000)) {
return res.status(400).json({ error: 'Payload fields exceed maximum allowed length' });
}
// Verify signature
const { domain: senderDomain } = parseAddress(from_user);
if (!senderDomain) return res.status(400).json({ error: 'Sender address must include a domain' });
const { publicKey } = await discoverInstance(senderDomain);
if (!publicKey) return res.status(400).json({ error: 'Sender instance did not provide a public key' });
if (!verifyPayload(payload, signature, publicKey)) {
return res.status(403).json({ error: 'Invalid federation signature' });
}
// Find local user
const { username } = parseAddress(to_user);
const db = getDb();
const targetUser = await db.get('SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)', [username]);
if (!targetUser) return res.status(404).json({ error: 'User not found on this instance' });
// Check duplicate
const existing = await db.get('SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?', [event_uid, targetUser.id]);
if (existing) return res.json({ success: true, message: 'Event already received' });
// Create event for the target user
await db.run(`
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
event_uid,
title,
description || null,
start_time,
end_time,
room_uid || null,
targetUser.id,
'#6366f1',
from_user,
join_url || null,
]);
res.json({ success: true });
} catch (err) {
log.server.error(`Calendar federation receive error: ${err.message}`);
res.status(500).json({ error: 'Failed to process calendar event' });
}
});
// ── Helper: Generate ICS content ────────────────────────────────────────────
function generateICS(event, location, prodIdDomain) {
const formatDate = (dateStr) => {
const d = new Date(dateStr);
return d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
};
const escapeICS = (str) => {
if (!str) return '';
return str.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
};
const now = formatDate(new Date().toISOString());
const dtStart = formatDate(event.start_time);
const dtEnd = formatDate(event.end_time);
let ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
`PRODID:-//${prodIdDomain}//Redlight Calendar//EN`,
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
'BEGIN:VEVENT',
`UID:${event.uid}@${prodIdDomain}`,
`DTSTAMP:${now}`,
`DTSTART:${dtStart}`,
`DTEND:${dtEnd}`,
`SUMMARY:${escapeICS(event.title)}`,
];
if (event.description) {
ics.push(`DESCRIPTION:${escapeICS(event.description)}`);
}
if (location) {
ics.push(`LOCATION:${escapeICS(location)}`);
ics.push(`URL:${location}`);
}
if (event.organizer_name && event.organizer_email) {
ics.push(`ORGANIZER;CN=${escapeICS(event.organizer_name)}:mailto:${event.organizer_email}`);
}
ics.push('END:VEVENT', 'END:VCALENDAR');
return ics.join('\r\n');
}
export default router;

View File

@@ -16,6 +16,7 @@ import Admin from './pages/Admin';
import GuestJoin from './pages/GuestJoin';
import FederationInbox from './pages/FederationInbox';
import FederatedRoomDetail from './pages/FederatedRoomDetail';
import Calendar from './pages/Calendar';
export default function App() {
const { user, loading } = useAuth();
@@ -54,6 +55,7 @@ export default function App() {
{/* Protected routes */}
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/rooms/:uid" element={<RoomDetail />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />

View File

@@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, Settings, Shield, X, Palette, Globe } from 'lucide-react';
import { LayoutDashboard, Settings, Shield, X, Palette, Globe, CalendarDays } from 'lucide-react';
import BrandLogo from './BrandLogo';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
@@ -30,6 +30,7 @@ export default function Sidebar({ open, onClose }) {
const navItems = [
{ to: '/dashboard', icon: LayoutDashboard, label: t('nav.dashboard') },
{ to: '/calendar', icon: CalendarDays, label: t('nav.calendar') },
{ to: '/federation/inbox', icon: Globe, label: t('nav.federation'), badge: federationCount },
{ to: '/settings', icon: Settings, label: t('nav.settings') },
];

View File

@@ -32,6 +32,7 @@
"appearance": "Darstellung",
"changeTheme": "Theme ändern",
"navigation": "Navigation",
"calendar": "Kalender",
"federation": "Einladungen"
},
"auth": {
@@ -408,5 +409,58 @@
"joinUrl": "Beitritts-URL",
"roomDeleted": "Gelöscht",
"roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar."
},
"calendar": {
"title": "Kalender",
"subtitle": "Meetings planen und verwalten",
"newEvent": "Neues Event",
"createEvent": "Event erstellen",
"editEvent": "Event bearbeiten",
"eventTitle": "Titel",
"eventTitlePlaceholder": "z.B. Team Meeting",
"description": "Beschreibung",
"descriptionPlaceholder": "Beschreibung hinzufügen...",
"startTime": "Beginn",
"endTime": "Ende",
"linkedRoom": "Verknüpfter Raum",
"noRoom": "Kein Raum (kein Videomeeting)",
"linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.",
"color": "Farbe",
"eventCreated": "Event erstellt!",
"eventUpdated": "Event aktualisiert!",
"eventDeleted": "Event gelöscht",
"saveFailed": "Event konnte nicht gespeichert werden",
"deleteFailed": "Event konnte nicht gelöscht werden",
"deleteConfirm": "Dieses Event wirklich löschen?",
"loadFailed": "Events konnten nicht geladen werden",
"today": "Heute",
"month": "Monat",
"week": "Woche",
"more": "weitere",
"mon": "Mo",
"tue": "Di",
"wed": "Mi",
"thu": "Do",
"fri": "Fr",
"sat": "Sa",
"sun": "So",
"downloadICS": "ICS herunterladen",
"icsDownloaded": "ICS-Datei heruntergeladen",
"icsFailed": "ICS-Datei konnte nicht heruntergeladen werden",
"share": "Teilen",
"shareEvent": "Event teilen",
"shareAdded": "Benutzer zum Event hinzugefügt",
"shareRemoved": "Freigabe entfernt",
"shareFailed": "Event konnte nicht geteilt werden",
"sendFederated": "An Remote senden",
"sendFederatedTitle": "Event an Remote-Instanz senden",
"sendFederatedDesc": "Sende dieses Kalender-Event an einen Benutzer auf einer anderen Redlight-Instanz.",
"send": "Senden",
"fedSent": "Event an Remote-Instanz gesendet!",
"fedFailed": "Event konnte nicht an Remote-Instanz gesendet werden",
"openRoom": "Verknüpften Raum öffnen",
"organizer": "Organisator",
"federatedFrom": "Von Remote-Instanz",
"joinFederatedMeeting": "Remote-Meeting beitreten"
}
}

View File

@@ -32,6 +32,7 @@
"appearance": "Appearance",
"changeTheme": "Change theme",
"navigation": "Navigation",
"calendar": "Calendar",
"federation": "Invitations"
},
"auth": {
@@ -408,5 +409,58 @@
"joinUrl": "Join URL",
"roomDeleted": "Deleted",
"roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available."
},
"calendar": {
"title": "Calendar",
"subtitle": "Plan and manage your meetings",
"newEvent": "New Event",
"createEvent": "Create Event",
"editEvent": "Edit Event",
"eventTitle": "Title",
"eventTitlePlaceholder": "e.g. Team Meeting",
"description": "Description",
"descriptionPlaceholder": "Add a description...",
"startTime": "Start",
"endTime": "End",
"linkedRoom": "Linked Room",
"noRoom": "No room (no video meeting)",
"linkedRoomHint": "Link a room to automatically include the join-URL in the event.",
"color": "Color",
"eventCreated": "Event created!",
"eventUpdated": "Event updated!",
"eventDeleted": "Event deleted",
"saveFailed": "Could not save event",
"deleteFailed": "Could not delete event",
"deleteConfirm": "Really delete this event?",
"loadFailed": "Events could not be loaded",
"today": "Today",
"month": "Month",
"week": "Week",
"more": "more",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat",
"sun": "Sun",
"downloadICS": "Download ICS",
"icsDownloaded": "ICS file downloaded",
"icsFailed": "Could not download ICS file",
"share": "Share",
"shareEvent": "Share Event",
"shareAdded": "User added to event",
"shareRemoved": "Share removed",
"shareFailed": "Could not share event",
"sendFederated": "Send to remote",
"sendFederatedTitle": "Send Event to Remote Instance",
"sendFederatedDesc": "Send this calendar event to a user on another Redlight instance.",
"send": "Send",
"fedSent": "Event sent to remote instance!",
"fedFailed": "Could not send event to remote instance",
"openRoom": "Open linked room",
"organizer": "Organizer",
"federatedFrom": "From remote instance",
"joinFederatedMeeting": "Join remote meeting"
}
}

View File

@@ -440,6 +440,32 @@
--gradient-end: #d6336a;
}
/* ===== RED MODULAR LIGHT ===== */
[data-theme="red-modular-light"] {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-tertiary: #e0e0e0;
--text-primary: #000000;
--text-secondary: #333333;
--accent: #e60000;
--accent-hover: #ff3333;
--accent-text: #ffffff;
--border: rgba(255, 255, 255, 0.1);
--card-bg: #ffffff;
--input-bg: #ffffff;
--input-border: #d3cbb7;
--nav-bg: #ffffff;
--sidebar-bg: #ffffff;
--hover-bg: #e0e0e0;
--success: #86b300;
--warning: #ecb637;
--error: #ec4137;
--ring: #b30051;
--shadow-color: rgba(0, 0, 0, 0.3);
--gradient-start: #b30051;
--gradient-end: #d6336a;
}
@layer components {
.btn-primary {

744
src/pages/Calendar.jsx Normal file
View File

@@ -0,0 +1,744 @@
import { useState, useEffect, useMemo } from 'react';
import {
ChevronLeft, ChevronRight, Plus, Calendar as CalendarIcon, Clock, Video,
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send,
} from 'lucide-react';
import api from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import Modal from '../components/Modal';
import toast from 'react-hot-toast';
const COLORS = ['#6366f1', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6'];
export default function Calendar() {
const { user } = useAuth();
const { t } = useLanguage();
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [currentDate, setCurrentDate] = useState(new Date());
const [view, setView] = useState('month'); // month | week
const [rooms, setRooms] = useState([]);
// Modal state
const [showCreate, setShowCreate] = useState(false);
const [showDetail, setShowDetail] = useState(null);
const [editingEvent, setEditingEvent] = useState(null);
const [showShare, setShowShare] = useState(null);
const [showFedShare, setShowFedShare] = useState(null);
// Create/Edit form
const [form, setForm] = useState({
title: '', description: '', start_time: '', end_time: '',
room_uid: '', color: '#6366f1',
});
const [saving, setSaving] = useState(false);
// Share state
const [shareSearch, setShareSearch] = useState('');
const [shareResults, setShareResults] = useState([]);
const [sharedUsers, setSharedUsers] = useState([]);
const [fedAddress, setFedAddress] = useState('');
const [fedSending, setFedSending] = useState(false);
// Load events on month change
useEffect(() => {
fetchEvents();
}, [currentDate]);
useEffect(() => {
fetchRooms();
}, []);
const fetchEvents = async () => {
try {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const from = new Date(year, month - 1, 1).toISOString();
const to = new Date(year, month + 2, 0).toISOString();
const res = await api.get(`/calendar/events?from=${from}&to=${to}`);
setEvents(res.data.events || []);
} catch {
toast.error(t('calendar.loadFailed'));
} finally {
setLoading(false);
}
};
const fetchRooms = async () => {
try {
const res = await api.get('/rooms');
setRooms(res.data.rooms || []);
} catch { /* ignore */ }
};
// Calendar grid computation
const calendarDays = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Start from Monday (ISO week)
let startOffset = firstDay.getDay() - 1;
if (startOffset < 0) startOffset = 6;
const calStart = new Date(year, month, 1 - startOffset);
const days = [];
const current = new Date(calStart);
for (let i = 0; i < 42; i++) {
days.push(new Date(current));
current.setDate(current.getDate() + 1);
}
return days;
}, [currentDate]);
const weekDays = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const date = currentDate.getDate();
const dayOfWeek = currentDate.getDay();
let mondayOffset = dayOfWeek - 1;
if (mondayOffset < 0) mondayOffset = 6;
const monday = new Date(year, month, date - mondayOffset);
const days = [];
for (let i = 0; i < 7; i++) {
days.push(new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + i));
}
return days;
}, [currentDate]);
const eventsForDay = (day) => {
const dayStr = day.toISOString().split('T')[0];
return events.filter(ev => {
const start = ev.start_time.split('T')[0];
const end = ev.end_time.split('T')[0];
return dayStr >= start && dayStr <= end;
});
};
const isToday = (day) => {
const today = new Date();
return day.toDateString() === today.toDateString();
};
const isCurrentMonth = (day) => {
return day.getMonth() === currentDate.getMonth();
};
const navigatePrev = () => {
const d = new Date(currentDate);
if (view === 'month') d.setMonth(d.getMonth() - 1);
else d.setDate(d.getDate() - 7);
setCurrentDate(d);
};
const navigateNext = () => {
const d = new Date(currentDate);
if (view === 'month') d.setMonth(d.getMonth() + 1);
else d.setDate(d.getDate() + 7);
setCurrentDate(d);
};
const goToToday = () => setCurrentDate(new Date());
const monthLabel = currentDate.toLocaleString('default', { month: 'long', year: 'numeric' });
const openCreateForDay = (day) => {
const start = new Date(day);
start.setHours(9, 0, 0, 0);
const end = new Date(day);
end.setHours(10, 0, 0, 0);
setForm({
title: '', description: '',
start_time: toLocalDateTimeStr(start),
end_time: toLocalDateTimeStr(end),
room_uid: '', color: '#6366f1',
});
setEditingEvent(null);
setShowCreate(true);
};
const openEdit = (ev) => {
setForm({
title: ev.title,
description: ev.description || '',
start_time: toLocalDateTimeStr(new Date(ev.start_time)),
end_time: toLocalDateTimeStr(new Date(ev.end_time)),
room_uid: ev.room_uid || '',
color: ev.color || '#6366f1',
});
setEditingEvent(ev);
setShowDetail(null);
setShowCreate(true);
};
const handleSave = async (e) => {
e.preventDefault();
setSaving(true);
try {
const data = {
...form,
start_time: new Date(form.start_time).toISOString(),
end_time: new Date(form.end_time).toISOString(),
};
if (editingEvent) {
await api.put(`/calendar/events/${editingEvent.id}`, data);
toast.success(t('calendar.eventUpdated'));
} else {
await api.post('/calendar/events', data);
toast.success(t('calendar.eventCreated'));
}
setShowCreate(false);
setEditingEvent(null);
fetchEvents();
} catch (err) {
toast.error(err.response?.data?.error || t('calendar.saveFailed'));
} finally {
setSaving(false);
}
};
const handleDelete = async (ev) => {
if (!confirm(t('calendar.deleteConfirm'))) return;
try {
await api.delete(`/calendar/events/${ev.id}`);
toast.success(t('calendar.eventDeleted'));
setShowDetail(null);
fetchEvents();
} catch {
toast.error(t('calendar.deleteFailed'));
}
};
const handleDownloadICS = async (ev) => {
try {
const res = await api.get(`/calendar/events/${ev.id}/ics`, { responseType: 'blob' });
const url = window.URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = `${ev.title}.ics`;
a.click();
window.URL.revokeObjectURL(url);
toast.success(t('calendar.icsDownloaded'));
} catch {
toast.error(t('calendar.icsFailed'));
}
};
// Share functions
const openShareModal = async (ev) => {
setShowShare(ev);
setShareSearch('');
setShareResults([]);
try {
const res = await api.get(`/calendar/events/${ev.id}`);
setSharedUsers(res.data.sharedUsers || []);
} catch { /* ignore */ }
};
const searchUsers = async (query) => {
setShareSearch(query);
if (query.length < 2) { setShareResults([]); return; }
try {
const res = await api.get(`/rooms/users/search?q=${encodeURIComponent(query)}`);
const sharedIds = new Set(sharedUsers.map(u => u.id));
setShareResults(res.data.users.filter(u => !sharedIds.has(u.id)));
} catch { setShareResults([]); }
};
const handleShare = async (userId) => {
if (!showShare) return;
try {
const res = await api.post(`/calendar/events/${showShare.id}/share`, { user_id: userId });
setSharedUsers(res.data.sharedUsers);
setShareSearch('');
setShareResults([]);
toast.success(t('calendar.shareAdded'));
} catch (err) {
toast.error(err.response?.data?.error || t('calendar.shareFailed'));
}
};
const handleUnshare = async (userId) => {
if (!showShare) return;
try {
const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`);
setSharedUsers(res.data.sharedUsers);
toast.success(t('calendar.shareRemoved'));
} catch { toast.error(t('calendar.shareFailed')); }
};
const handleFedSend = async (e) => {
e.preventDefault();
if (!showFedShare) return;
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
if (!normalized.includes('@') || normalized.endsWith('@')) {
toast.error(t('federation.addressHint'));
return;
}
setFedSending(true);
try {
await api.post(`/calendar/events/${showFedShare.id}/federation`, { to: fedAddress });
toast.success(t('calendar.fedSent'));
setShowFedShare(null);
setFedAddress('');
} catch (err) {
toast.error(err.response?.data?.error || t('calendar.fedFailed'));
} finally {
setFedSending(false);
}
};
const dayNames = [
t('calendar.mon'), t('calendar.tue'), t('calendar.wed'),
t('calendar.thu'), t('calendar.fri'), t('calendar.sat'), t('calendar.sun'),
];
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-6">
<div>
<h1 className="text-2xl font-bold text-th-text">{t('calendar.title')}</h1>
<p className="text-sm text-th-text-s mt-1">{t('calendar.subtitle')}</p>
</div>
<button onClick={() => {
const now = new Date();
now.setHours(now.getHours() + 1, 0, 0, 0);
const end = new Date(now);
end.setHours(end.getHours() + 1);
setForm({
title: '', description: '',
start_time: toLocalDateTimeStr(now),
end_time: toLocalDateTimeStr(end),
room_uid: '', color: '#6366f1',
});
setEditingEvent(null);
setShowCreate(true);
}} className="btn-primary">
<Plus size={18} />
<span className="hidden sm:inline">{t('calendar.newEvent')}</span>
</button>
</div>
{/* Toolbar */}
<div className="card p-3 mb-4 flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<button onClick={navigatePrev} className="btn-ghost p-2">
<ChevronLeft size={18} />
</button>
<button onClick={goToToday} className="btn-ghost text-sm px-3 py-1.5">
{t('calendar.today')}
</button>
<button onClick={navigateNext} className="btn-ghost p-2">
<ChevronRight size={18} />
</button>
<h2 className="text-lg font-semibold text-th-text ml-2">{monthLabel}</h2>
</div>
<div className="flex items-center bg-th-bg-s rounded-lg border border-th-border p-0.5">
<button
onClick={() => setView('month')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === 'month' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
}`}
>
{t('calendar.month')}
</button>
<button
onClick={() => setView('week')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === 'week' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
}`}
>
{t('calendar.week')}
</button>
</div>
</div>
{/* Calendar Grid */}
<div className="card overflow-hidden">
{/* Day headers */}
<div className="grid grid-cols-7 border-b border-th-border">
{dayNames.map((name, i) => (
<div key={i} className="py-2.5 text-center text-xs font-semibold text-th-text-s uppercase tracking-wider border-r border-th-border last:border-r-0">
{name}
</div>
))}
</div>
{/* Days */}
{view === 'month' ? (
<div className="grid grid-cols-7">
{calendarDays.map((day, i) => {
const dayEvents = eventsForDay(day);
const today = isToday(day);
const inMonth = isCurrentMonth(day);
return (
<div
key={i}
onClick={() => openCreateForDay(day)}
className={`min-h-[100px] p-1.5 border-r border-b border-th-border last:border-r-0 cursor-pointer hover:bg-th-hover/50 transition-colors
${!inMonth ? 'opacity-40' : ''}`}
>
<div className={`text-xs font-medium mb-1 w-6 h-6 flex items-center justify-center rounded-full
${today ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s'}`}>
{day.getDate()}
</div>
<div className="space-y-0.5">
{dayEvents.slice(0, 3).map(ev => (
<div
key={ev.id}
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
className="text-[10px] leading-tight px-1.5 py-0.5 rounded truncate text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }}
title={ev.title}
>
{formatTime(ev.start_time)} {ev.title}
</div>
))}
{dayEvents.length > 3 && (
<div className="text-[10px] text-th-text-s font-medium px-1.5">
+{dayEvents.length - 3} {t('calendar.more')}
</div>
)}
</div>
</div>
);
})}
</div>
) : (
/* Week view */
<div className="grid grid-cols-7">
{weekDays.map((day, i) => {
const dayEvents = eventsForDay(day);
const today = isToday(day);
return (
<div
key={i}
onClick={() => openCreateForDay(day)}
className="min-h-[300px] p-2 border-r border-b border-th-border last:border-r-0 cursor-pointer hover:bg-th-hover/50 transition-colors"
>
<div className={`text-sm font-medium mb-2 w-7 h-7 flex items-center justify-center rounded-full
${today ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s'}`}>
{day.getDate()}
</div>
<div className="space-y-1">
{dayEvents.map(ev => (
<div
key={ev.id}
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
className="text-xs px-2 py-1.5 rounded text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }}
>
<div className="truncate">{ev.title}</div>
<div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} {formatTime(ev.end_time)}</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Create/Edit Modal */}
{showCreate && (
<Modal title={editingEvent ? t('calendar.editEvent') : t('calendar.createEvent')} onClose={() => { setShowCreate(false); setEditingEvent(null); }}>
<form onSubmit={handleSave} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.eventTitle')} *</label>
<input
type="text"
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
className="input-field"
placeholder={t('calendar.eventTitlePlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.description')}</label>
<textarea
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
className="input-field resize-none"
rows={2}
placeholder={t('calendar.descriptionPlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.startTime')} *</label>
<input
type="datetime-local"
value={form.start_time}
onChange={e => setForm({ ...form, start_time: e.target.value })}
className="input-field"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.endTime')} *</label>
<input
type="datetime-local"
value={form.end_time}
onChange={e => setForm({ ...form, end_time: e.target.value })}
className="input-field"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.linkedRoom')}</label>
<select
value={form.room_uid}
onChange={e => setForm({ ...form, room_uid: e.target.value })}
className="input-field"
>
<option value="">{t('calendar.noRoom')}</option>
{rooms.map(r => (
<option key={r.uid} value={r.uid}>{r.name}</option>
))}
</select>
<p className="text-xs text-th-text-s mt-1">{t('calendar.linkedRoomHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</label>
<div className="flex gap-2">
{COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => setForm({ ...form, color: c })}
className={`w-7 h-7 rounded-full border-2 transition-all ${form.color === c ? 'border-th-text scale-110' : 'border-transparent'}`}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div className="flex items-center gap-3 pt-4 border-t border-th-border">
<button type="button" onClick={() => { setShowCreate(false); setEditingEvent(null); }} className="btn-secondary flex-1">
{t('common.cancel')}
</button>
<button type="submit" disabled={saving} className="btn-primary flex-1">
{saving ? <Loader2 size={18} className="animate-spin" /> : t('common.save')}
</button>
</div>
</form>
</Modal>
)}
{/* Event Detail Modal */}
{showDetail && (
<Modal title={showDetail.title} onClose={() => setShowDetail(null)}>
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-th-text-s">
<Clock size={14} />
<span>
{new Date(showDetail.start_time).toLocaleString()} {new Date(showDetail.end_time).toLocaleString()}
</span>
</div>
{showDetail.description && (
<p className="text-sm text-th-text">{showDetail.description}</p>
)}
{showDetail.room_uid && (
<div className="flex items-center gap-2 text-sm">
<Video size={14} className="text-th-accent" />
<a
href={`/rooms/${showDetail.room_uid}`}
className="text-th-accent hover:underline"
onClick={(e) => { e.preventDefault(); window.location.href = `/rooms/${showDetail.room_uid}`; }}
>
{t('calendar.openRoom')}
</a>
</div>
)}
{showDetail.federated_from && (
<div className="flex items-center gap-2 text-xs text-th-text-s">
<Globe size={12} />
<span>{t('calendar.federatedFrom')}: {showDetail.federated_from}</span>
</div>
)}
{showDetail.federated_join_url && (
<a
href={showDetail.federated_join_url}
target="_blank"
rel="noopener noreferrer"
className="btn-primary text-sm w-full justify-center"
>
<Video size={14} />
{t('calendar.joinFederatedMeeting')}
</a>
)}
{showDetail.organizer_name && (
<div className="text-xs text-th-text-s">
{t('calendar.organizer')}: {showDetail.organizer_name}
</div>
)}
{/* Actions */}
<div className="flex flex-wrap items-center gap-2 pt-4 border-t border-th-border">
<button onClick={() => handleDownloadICS(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
<Download size={14} />
{t('calendar.downloadICS')}
</button>
{showDetail.is_owner && (
<>
<button onClick={() => openEdit(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
<Edit size={14} />
{t('common.edit')}
</button>
<button onClick={() => openShareModal(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
<Share2 size={14} />
{t('calendar.share')}
</button>
<button onClick={() => { setShowFedShare(showDetail); setShowDetail(null); }} className="btn-ghost text-xs py-1.5 px-3">
<Globe size={14} />
{t('calendar.sendFederated')}
</button>
<button onClick={() => handleDelete(showDetail)} className="btn-ghost text-xs py-1.5 px-3 text-th-error hover:text-th-error">
<Trash2 size={14} />
{t('common.delete')}
</button>
</>
)}
</div>
</div>
</Modal>
)}
{/* Share Modal */}
{showShare && (
<Modal title={t('calendar.shareEvent')} onClose={() => setShowShare(null)}>
<div className="space-y-4">
<div className="relative">
<UserPlus size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={shareSearch}
onChange={e => searchUsers(e.target.value)}
className="input-field pl-11"
placeholder={t('room.shareSearchPlaceholder')}
/>
{shareResults.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-th-card border border-th-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
{shareResults.map(u => (
<button
key={u.id}
onClick={() => handleShare(u.id)}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-text-s truncate">{u.email}</div>
</div>
</button>
))}
</div>
)}
</div>
{sharedUsers.length > 0 && (
<div className="space-y-2">
{sharedUsers.map(u => (
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-text-s truncate">{u.email}</div>
</div>
</div>
<button onClick={() => handleUnshare(u.id)} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors">
<X size={16} />
</button>
</div>
))}
</div>
)}
</div>
</Modal>
)}
{/* Federation Share Modal */}
{showFedShare && (
<Modal title={t('calendar.sendFederatedTitle')} onClose={() => setShowFedShare(null)}>
<p className="text-sm text-th-text-s mb-4">{t('calendar.sendFederatedDesc')}</p>
<form onSubmit={handleFedSend} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
<input
type="text"
value={fedAddress}
onChange={e => setFedAddress(e.target.value)}
className="input-field"
placeholder={t('federation.addressPlaceholder')}
required
/>
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
</div>
<div className="flex items-center gap-3 pt-2 border-t border-th-border">
<button type="button" onClick={() => setShowFedShare(null)} className="btn-secondary flex-1">
{t('common.cancel')}
</button>
<button type="submit" disabled={fedSending} className="btn-primary flex-1">
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
{t('calendar.send')}
</button>
</div>
</form>
</Modal>
)}
</div>
);
}
// Helpers
function toLocalDateTimeStr(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
return `${y}-${m}-${d}T${h}:${min}`;
}
function formatTime(dateStr) {
const d = new Date(dateStr);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

View File

@@ -111,6 +111,13 @@ export const themes = [
group: 'Community',
colors: { bg: '#161924', accent: '#b30051', text: '#dadada' },
},
{
id: 'red-modular-light',
name: 'Red Modular Light',
type: 'light',
group: 'Community',
colors: { bg: '#ffffff', accent: '#e60000', text: '#000000' },
},
];
export function getThemeById(id) {