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.use('/api/federation', calendarRoutes);
app.get('/.well-known/redlight', wellKnownHandler); 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 // Serve static files in production
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, '..', 'dist'))); app.use(express.static(path.join(__dirname, '..', 'dist')));
app.get('*', (req, res) => { 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')); res.sendFile(path.join(__dirname, '..', 'dist', 'index.html'));
}); });
} }

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { import {
ChevronLeft, ChevronRight, Plus, Calendar as CalendarIcon, Clock, Video, 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'; } from 'lucide-react';
import api from '../services/api'; import api from '../services/api';
import { useAuth } from '../contexts/AuthContext'; 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 // Share functions
const openShareModal = async (ev) => { const openShareModal = async (ev) => {
setShowShare(ev); setShowShare(ev);
@@ -601,6 +636,24 @@ export default function Calendar() {
{/* Actions */} {/* Actions */}
<div className="flex flex-wrap items-center gap-2 pt-4 border-t border-th-border"> <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"> <button onClick={() => handleDownloadICS(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
<Download size={14} /> <Download size={14} />
{t('calendar.downloadICS')} {t('calendar.downloadICS')}