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
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:
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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>
|
||||
@@ -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()">
|
||||
✔ 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>
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user