feat(outlook-addin): remove add-in files and update server to prevent serving static files
All checks were successful
Build & Push Docker Image / build (push) Successful in 5m59s

feat(i18n): add translations for "Add to Outlook" and "Add to Google Calendar"
feat(calendar): implement functionality to generate Outlook and Google Calendar event links
This commit is contained in:
2026-03-02 11:49:01 +01:00
parent 9275c20d19
commit 2a8ded5211
13 changed files with 58 additions and 739 deletions

View File

@@ -1,57 +0,0 @@
# 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)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 312 B

View File

@@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 182 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 605 B

View File

@@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 187 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 188 B

View File

@@ -1,99 +0,0 @@
<?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://redlight.scrunkly.cat/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>
<!-- VersionOverrides V1.0 (Desktop) -->
<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>
</Host>
</Hosts>
<Resources>
<bt:Images>
<bt:Image id="icon16" DefaultValue="https://redlight.scrunkly.cat/outlook-addin/assets/icon-16.png" />
<bt:Image id="icon32" DefaultValue="https://redlight.scrunkly.cat/outlook-addin/assets/icon-32.png" />
<bt:Image id="icon80" DefaultValue="https://redlight.scrunkly.cat/outlook-addin/assets/icon-80.png" />
</bt:Images>
<bt:Urls>
<bt:Url id="taskpaneUrl" DefaultValue="https://redlight.scrunkly.cat/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>

View File

@@ -1,565 +0,0 @@
<!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

@@ -59,15 +59,10 @@ async function start() {
app.use('/api/federation', calendarRoutes);
app.get('/.well-known/redlight', wellKnownHandler);
// Serve Outlook Add-in static files (before SPA catch-all)
app.use('/outlook-addin', express.static(path.join(__dirname, '..', 'outlook-addin')));
// Serve static files in production
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '..', 'dist')));
app.get('*', (req, res) => {
// Don't serve SPA for outlook-addin paths
if (req.path.startsWith('/outlook-addin')) return res.status(404).end();
res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'));
});
}

View File

@@ -445,6 +445,8 @@
"sat": "Sa",
"sun": "So",
"downloadICS": "ICS herunterladen",
"addToOutlook": "Zu Outlook hinzufügen",
"addToGoogleCalendar": "Zu Google Kalender",
"icsDownloaded": "ICS-Datei heruntergeladen",
"icsFailed": "ICS-Datei konnte nicht heruntergeladen werden",
"share": "Teilen",

View File

@@ -445,6 +445,8 @@
"sat": "Sat",
"sun": "Sun",
"downloadICS": "Download ICS",
"addToOutlook": "Add to Outlook",
"addToGoogleCalendar": "Google Calendar",
"icsDownloaded": "ICS file downloaded",
"icsFailed": "Could not download ICS file",
"share": "Share",

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo } from 'react';
import {
ChevronLeft, ChevronRight, Plus, Calendar as CalendarIcon, Clock, Video,
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send,
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink,
} from 'lucide-react';
import api from '../services/api';
import { useAuth } from '../contexts/AuthContext';
@@ -229,6 +229,41 @@ export default function Calendar() {
}
};
const buildOutlookUrl = (ev) => {
const start = new Date(ev.start_time);
const end = new Date(ev.end_time);
const fmt = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
const baseUrl = window.location.origin;
const location = ev.room_uid ? `${baseUrl}/join/${ev.room_uid}` : '';
const body = [ev.description || '', location ? `\n\nMeeting: ${location}` : ''].join('');
const params = new URLSearchParams({
rru: 'addevent',
subject: ev.title,
startdt: start.toISOString(),
enddt: end.toISOString(),
body: body.trim(),
location,
allday: 'false',
path: '/calendar/action/compose',
});
return `https://outlook.live.com/calendar/0/action/compose?${params.toString()}`;
};
const buildGoogleCalUrl = (ev) => {
const fmt = (d) => new Date(d).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
const baseUrl = window.location.origin;
const location = ev.room_uid ? `${baseUrl}/join/${ev.room_uid}` : '';
const details = [ev.description || '', location ? `\nMeeting: ${location}` : ''].join('');
const params = new URLSearchParams({
action: 'TEMPLATE',
text: ev.title,
dates: `${fmt(ev.start_time)}/${fmt(ev.end_time)}`,
details: details.trim(),
location,
});
return `https://calendar.google.com/calendar/render?${params.toString()}`;
};
// Share functions
const openShareModal = async (ev) => {
setShowShare(ev);
@@ -601,6 +636,24 @@ export default function Calendar() {
{/* Actions */}
<div className="flex flex-wrap items-center gap-2 pt-4 border-t border-th-border">
<a
href={buildOutlookUrl(showDetail)}
target="_blank"
rel="noopener noreferrer"
className="btn-ghost text-xs py-1.5 px-3 inline-flex items-center gap-1.5 no-underline"
>
<ExternalLink size={14} />
{t('calendar.addToOutlook')}
</a>
<a
href={buildGoogleCalUrl(showDetail)}
target="_blank"
rel="noopener noreferrer"
className="btn-ghost text-xs py-1.5 px-3 inline-flex items-center gap-1.5 no-underline"
>
<ExternalLink size={14} />
{t('calendar.addToGoogleCalendar')}
</a>
<button onClick={() => handleDownloadICS(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
<Download size={14} />
{t('calendar.downloadICS')}