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