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
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s
This commit is contained in:
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- federation-try
|
||||
- develop-calendar
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
|
||||
57
outlook-addin/README.md
Normal file
57
outlook-addin/README.md
Normal 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)
|
||||
4
outlook-addin/assets/icon-16.svg
Normal file
4
outlook-addin/assets/icon-16.svg
Normal 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 |
4
outlook-addin/assets/icon-32.svg
Normal file
4
outlook-addin/assets/icon-32.svg
Normal 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 |
4
outlook-addin/assets/icon-80.svg
Normal file
4
outlook-addin/assets/icon-80.svg
Normal 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
117
outlook-addin/manifest.xml
Normal 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
565
outlook-addin/taskpane.html
Normal 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()">
|
||||
✔ 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 = '✔ Insert Meeting Link';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// ─── Utilities ──────────────────────────────────────────────────────────
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
489
server/routes/calendar.js
Normal 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;
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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') },
|
||||
];
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
744
src/pages/Calendar.jsx
Normal 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' });
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user