commit 21e2ac98cd1794a577f54f83871d2185220939ec Author: oceans2alaska Date: Mon Apr 6 23:01:07 2026 -0700 Initial commit: OL Rescue Outlook add-in (New Outlook + Graph) Made-with: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bf52dbd --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Azure AD app registration (single-page application). See README. +VITE_AZURE_CLIENT_ID= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..faac4a8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,54 @@ +# Dependencies +node_modules/ + +# Production build +dist/ +build/ + +# TypeScript +*.tsbuildinfo + +# Environment — never commit secrets; template is committed +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Test / coverage +coverage/ +.nyc_output/ + +# Caches +.cache/ +.vite/ +.parcel-cache/ +.eslintcache +*.cache + +# IDE / editors (keep team-shared .vscode/*.json in repo if you add them with !) +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +Desktop.ini + +# Optional local HTTPS / signing material (never commit real certs) +*.pem +*.p12 +*.pfx + +# Archives from npm pack / releases +*.tgz diff --git a/README.md b/README.md new file mode 100644 index 0000000..9a8067c --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# OL Rescue + +Outlook **Office Web Add-in** that recreates the old “OL Helper” workflow for **New Outlook** (and Outlook on the web): you define a **Tracking ID** and an **Inbox subfolder name**, then the add-in uses **Microsoft Graph** to create the folder (if needed) and move messages that contain `TrackingID#…` in the subject or preview. + +Classic COM/VSTO add-ins do not run in New Outlook. The supported path is **Office.js + Graph**, which this project uses. + +## What you need + +- **Node.js 20+** and npm (for local HTTPS dev and builds). +- **Microsoft 365 / Outlook** with add-ins allowed by your org. +- An **Azure AD app registration** (see below) with Graph delegated permissions: **Mail.ReadWrite**, **User.Read**. + +## Azure AD app (summary) + +1. In [Azure Portal](https://portal.azure.com) → **Microsoft Entra ID** → **App registrations** → **New registration**. +2. Name it (e.g. “OL Rescue”), supported account types per your tenant, **Redirect URI**: platform **Single-page application (SPA)**, URI `https://localhost:3000` (and later your production URL). +3. **API permissions** → **Microsoft Graph** → **Delegated**: add **Mail.ReadWrite**, **User.Read** → **Grant admin consent** if required. +4. **Overview** → copy **Application (client) ID**. + +## Run locally + +```bash +cd "/path/to/OL Rescue!" +cp .env.example .env +# Set VITE_AZURE_CLIENT_ID to your client ID +npm install +npm run dev +``` + +Vite serves **HTTPS** on port **3000** (self-signed cert; your browser will warn once). + +## Sideload the manifest in Outlook (Windows New Outlook) + +1. Ensure `manifest.xml` still points to `https://localhost:3000/index.html` (default for dev). +2. In Outlook: **File** → **Manage Add-ins** / **Get Add-ins** → **My add-ins** → **Add a custom add-in** → **Add from file…** → choose `manifest.xml`. + +If your org blocks custom add-ins, an admin may need to deploy via **Centralized Deployment** or allow sideloading. + +## Use the add-in + +1. Open a ticket email and open the **OL Rescue** task pane. +2. **Sign in with Microsoft** (Graph consent). +3. **Fill from open message** to parse `TrackingID#…` and suggest a folder name, or type **Tracking ID** and **Inbox folder name** manually. +4. **Save case**, then **Create folder & move mail** on that case to create `Inbox\` and move matching messages. + +Saved cases are stored in **browser localStorage** for this add-in origin. + +## Production + +1. Host the `dist/` folder (or your static host) over **HTTPS**. +2. Update **Application ID URI / redirect URIs** in Azure for your real origin. +3. Edit **manifest.xml** `SourceLocation`, `IconUrl`, and `SupportUrl` to your HTTPS URLs, then rebuild (`npm run build`) and redeploy. + +## Limits vs old OL Helper + +- **Not automatic on every new message** unless you add something else (e.g. Outlook rules calling a service, or Power Automate). This tool is **on-demand** from the task pane. +- **Graph search** uses `$filter` on subject/body preview; very unusual formats might need a small follow-up change. +- **Shared mailboxes** need extra Graph permissions and possibly UX changes; this MVP targets your primary mailbox (`/me/…`). + +## License + +Use and modify for your work as needed. diff --git a/index.html b/index.html new file mode 100644 index 0000000..8a0c872 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + OL Rescue + + + +
+ + + diff --git a/manifest.xml b/manifest.xml new file mode 100644 index 0000000..2a388d4 --- /dev/null +++ b/manifest.xml @@ -0,0 +1,39 @@ + + + 0b5e2d6a-8a1c-4f2e-9e0b-4a1c2d3e4f5a + 1.0.0.0 + OL Rescue + en-US + + + + + + + https://localhost:3000 + https://login.microsoftonline.com + + + + + + + + + + +
+ + + 450 + +
+
+ ReadWriteItem + + + + false +
diff --git a/package.json b/package.json new file mode 100644 index 0000000..6aaaca8 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "ol-rescue", + "private": true, + "version": "1.0.0", + "type": "module", + "description": "Outlook add-in: file mail by internal TrackingID into named folders (New Outlook / web)", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@azure/msal-browser": "^4.27.0" + }, + "devDependencies": { + "@types/office-js": "^1.0.534", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "@vitejs/plugin-basic-ssl": "^1.2.0" + } +} diff --git a/public/icon-128.png b/public/icon-128.png new file mode 100644 index 0000000..4b7f5a1 Binary files /dev/null and b/public/icon-128.png differ diff --git a/public/icon-32.png b/public/icon-32.png new file mode 100644 index 0000000..609fbf8 Binary files /dev/null and b/public/icon-32.png differ diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..aa680b3 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,329 @@ +import { + createMsal, + ensureActiveAccount, + getActiveAccount, + getGraphAccessToken, + initializeAuth, + signIn, + signOut, +} from "./auth"; +import { + findMessagesWithTrackingId, + findOrCreateInboxChildFolder, + moveMessageToFolder, +} from "./graph"; +import { extractTrackingId, suggestFolderDisplayName } from "./parse"; +import { type CaseEntry, loadCases, saveCases } from "./storage"; +import "./styles.css"; + +function el( + tag: K, + props: Record = {}, + children: (Node | string)[] = [], +): HTMLElement { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(props)) { + if (v === undefined) { + continue; + } + if (k === "className") { + node.className = v; + } else if (k.startsWith("on") && typeof v === "string") { + // not used + } else { + node.setAttribute(k, v); + } + } + for (const c of children) { + node.append(typeof c === "string" ? document.createTextNode(c) : c); + } + return node; +} + +function getClientId(): string | undefined { + const id = import.meta.env.VITE_AZURE_CLIENT_ID?.trim(); + return id || undefined; +} + +export async function initApp(): Promise { + const root = document.getElementById("app"); + if (!root) { + return; + } + + const clientId = getClientId(); + if (!clientId) { + root.replaceChildren( + el("div", { className: "panel" }, [ + el("h1", {}, ["OL Rescue"]), + el("p", {}, [ + "Add your Azure AD app client ID to ", + el("code", {}, [".env"]), + " as ", + el("code", {}, ["VITE_AZURE_CLIENT_ID"]), + ", then restart ", + el("code", {}, ["npm run dev"]), + ".", + ]), + ]), + ); + return; + } + + const msal = createMsal(clientId); + await initializeAuth(msal); + ensureActiveAccount(msal); + + let cases = loadCases(); + + const statusEl = el("div", { className: "status", role: "status" }); + const logEl = el("pre", { className: "log" }); + + function setStatus(msg: string): void { + statusEl.textContent = msg; + } + + function appendLog(line: string): void { + logEl.textContent = `${logEl.textContent ?? ""}${line}\n`; + logEl.scrollTop = logEl.scrollHeight; + } + + const trackingInput = el("input", { + type: "text", + className: "input", + placeholder: "e.g. 2603260030007249", + "aria-label": "Tracking ID", + }) as HTMLInputElement; + + const folderInput = el("input", { + type: "text", + className: "input", + placeholder: 'e.g. 2603260030007249 Crazy Netflix Case', + "aria-label": "Folder name under Inbox", + }) as HTMLInputElement; + + const caseList = el("div", { className: "case-list" }); + + function persist(): void { + saveCases(cases); + } + + function renderCaseList(): void { + caseList.replaceChildren(); + if (cases.length === 0) { + caseList.append(el("p", { className: "muted" }, ["No saved cases yet."])); + return; + } + for (const c of cases) { + const row = el("div", { className: "case-row" }); + row.append( + el("div", { className: "case-meta" }, [ + el("strong", {}, [c.folderDisplayName]), + el("span", { className: "muted" }, [` · TrackingID#${c.trackingId}`]), + ]), + ); + const actions = el("div", { className: "case-actions" }); + const syncBtn = el("button", { type: "button", className: "btn btn-small" }, [ + "Create folder & move mail", + ]); + syncBtn.addEventListener("click", () => void syncCase(c.id)); + const removeBtn = el("button", { type: "button", className: "btn btn-small btn-ghost" }, [ + "Remove", + ]); + removeBtn.addEventListener("click", () => { + cases = cases.filter((x) => x.id !== c.id); + persist(); + renderCaseList(); + }); + actions.append(syncBtn, removeBtn); + row.append(actions); + caseList.append(row); + } + } + + async function syncCase(caseId: string): Promise { + const entry = cases.find((c) => c.id === caseId); + if (!entry) { + return; + } + setStatus("Working…"); + appendLog(`— ${entry.folderDisplayName}`); + try { + const token = await getGraphAccessToken(msal); + appendLog("Ensuring folder exists under Inbox…"); + const folder = await findOrCreateInboxChildFolder(token, entry.folderDisplayName); + entry.graphFolderId = folder.id; + persist(); + appendLog(`Finding messages with TrackingID#${entry.trackingId}…`); + const messages = await findMessagesWithTrackingId(token, entry.trackingId); + appendLog(`Found ${messages.length} message(s).`); + let moved = 0; + for (const m of messages) { + try { + await moveMessageToFolder(token, m.id, folder.id); + moved += 1; + appendLog(`Moved: ${m.subject ?? m.id}`); + } catch (e) { + appendLog(`Skip/fail: ${m.subject ?? m.id} — ${e instanceof Error ? e.message : String(e)}`); + } + } + appendLog(`Done. Moved ${moved} message(s).`); + setStatus(`Last run: moved ${moved} of ${messages.length}.`); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + appendLog(`Error: ${msg}`); + setStatus("Failed — see log."); + } + } + + async function fillFromOpenMessage(): Promise { + const item = Office.context.mailbox.item; + if (!item) { + setStatus("Open a message first."); + return; + } + setStatus("Reading open message…"); + await new Promise((resolve, reject) => { + item.subject.getAsync((r) => { + if (r.status !== Office.AsyncResultStatus.Succeeded) { + reject(new Error(r.error?.message ?? "Could not read subject.")); + return; + } + const subject = r.value ?? ""; + const id = extractTrackingId(subject); + if (id) { + trackingInput.value = id; + folderInput.value = suggestFolderDisplayName(subject, id); + setStatus("Filled from subject."); + } else { + item.body.getAsync(Office.CoercionType.Text, (br) => { + if (br.status !== Office.AsyncResultStatus.Succeeded) { + reject(new Error(br.error?.message ?? "Could not read body.")); + return; + } + const bodyText = br.value ?? ""; + const id2 = extractTrackingId(bodyText); + if (id2) { + trackingInput.value = id2; + folderInput.value = suggestFolderDisplayName(subject || "Case", id2); + setStatus("Filled from body text."); + } else { + setStatus("No TrackingID# found in subject or body."); + } + resolve(); + }); + return; + } + resolve(); + }); + }).catch((e) => { + setStatus(e instanceof Error ? e.message : String(e)); + }); + } + + function addOrUpdateCase(): void { + const trackingId = trackingInput.value.trim(); + const folderDisplayName = folderInput.value.trim(); + if (!/^\d+$/.test(trackingId)) { + setStatus("Tracking ID should be digits only (from TrackingID#…)."); + return; + } + if (!folderDisplayName) { + setStatus("Enter a folder name."); + return; + } + const existing = cases.find((c) => c.trackingId === trackingId); + if (existing) { + existing.folderDisplayName = folderDisplayName; + } else { + cases.push({ + id: crypto.randomUUID(), + trackingId, + folderDisplayName, + }); + } + persist(); + renderCaseList(); + setStatus("Case saved."); + } + + const authBar = el("div", { className: "auth-bar" }); + const userLabel = el("span", { className: "muted" }); + + function renderAuth(): void { + authBar.replaceChildren(); + const acc = getActiveAccount(msal); + if (acc) { + userLabel.textContent = acc.username ?? acc.name ?? "Signed in"; + const outBtn = el("button", { type: "button", className: "btn btn-ghost" }, ["Sign out"]); + outBtn.addEventListener("click", () => { + signOut(msal); + userLabel.textContent = ""; + renderAuth(); + setStatus("Signed out."); + }); + authBar.append(userLabel, outBtn); + } else { + const inBtn = el("button", { type: "button", className: "btn" }, ["Sign in with Microsoft"]); + inBtn.addEventListener("click", () => { + void (async () => { + try { + await signIn(msal); + renderAuth(); + setStatus("Signed in."); + } catch (e) { + setStatus(e instanceof Error ? e.message : String(e)); + } + })(); + }); + authBar.append(inBtn); + } + } + + renderAuth(); + renderCaseList(); + + const saveCaseBtn = el("button", { type: "button", className: "btn" }, ["Save case"]); + const fillFromBtn = el("button", { type: "button", className: "btn btn-secondary" }, [ + "Fill from open message", + ]); + saveCaseBtn.addEventListener("click", () => addOrUpdateCase()); + fillFromBtn.addEventListener("click", () => void fillFromOpenMessage()); + + root.replaceChildren( + el("div", { className: "wrap" }, [ + el("header", { className: "header" }, [ + el("h1", {}, ["OL Rescue"]), + el("p", { className: "lede" }, [ + "Save a case (Tracking ID + folder name), then move matching mail into an Inbox subfolder. Works in ", + el("strong", {}, ["New Outlook"]), + " via Microsoft Graph.", + ]), + authBar, + ]), + el("section", { className: "panel" }, [ + el("h2", {}, ["Case"]), + el("label", { className: "label" }, ["Tracking ID"]), + trackingInput, + el("label", { className: "label" }, ["Inbox folder name"]), + folderInput, + el("div", { className: "btn-row" }, [saveCaseBtn, fillFromBtn]), + ]), + el("section", { className: "panel" }, [ + el("h2", {}, ["Saved cases"]), + caseList, + ]), + el("section", { className: "panel" }, [ + el("h2", {}, ["Activity"]), + statusEl, + logEl, + ]), + ]), + ); + + if (getActiveAccount(msal)) { + setStatus("Ready."); + } else { + setStatus("Sign in to use Microsoft Graph."); + } +} diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..f143600 --- /dev/null +++ b/src/auth.ts @@ -0,0 +1,80 @@ +import { + InteractionRequiredAuthError, + type AccountInfo, + type IPublicClientApplication, + PublicClientApplication, +} from "@azure/msal-browser"; + +const GRAPH_SCOPES = [ + "https://graph.microsoft.com/Mail.ReadWrite", + "https://graph.microsoft.com/User.Read", +]; + +export function createMsal(clientId: string): PublicClientApplication { + return new PublicClientApplication({ + auth: { + clientId, + authority: "https://login.microsoftonline.com/common", + redirectUri: window.location.origin, + }, + cache: { + cacheLocation: "localStorage", + storeAuthStateInCookie: true, + }, + }); +} + +export async function initializeAuth(msal: IPublicClientApplication): Promise { + await msal.initialize(); + await msal.handleRedirectPromise(); +} + +export async function signIn(msal: IPublicClientApplication): Promise { + await initializeAuth(msal); + const result = await msal.loginPopup({ scopes: GRAPH_SCOPES }); + if (!result.account) { + throw new Error("Sign-in did not return an account."); + } + msal.setActiveAccount(result.account); + return result.account; +} + +/** Call after initializeAuth when a single cached account should be used. */ +export function ensureActiveAccount(msal: IPublicClientApplication): void { + if (msal.getActiveAccount()) { + return; + } + const accounts = msal.getAllAccounts(); + if (accounts.length === 1) { + msal.setActiveAccount(accounts[0]); + } +} + +export function signOut(msal: IPublicClientApplication): void { + const account = msal.getActiveAccount() ?? msal.getAllAccounts()[0]; + if (account) { + void msal.logoutPopup({ account }); + } +} + +export async function getGraphAccessToken(msal: IPublicClientApplication): Promise { + await initializeAuth(msal); + const account = msal.getActiveAccount() ?? msal.getAllAccounts()[0]; + if (!account) { + throw new Error("Not signed in."); + } + try { + const result = await msal.acquireTokenSilent({ scopes: GRAPH_SCOPES, account }); + return result.accessToken; + } catch (e) { + if (e instanceof InteractionRequiredAuthError) { + const result = await msal.acquireTokenPopup({ scopes: GRAPH_SCOPES, account }); + return result.accessToken; + } + throw e; + } +} + +export function getActiveAccount(msal: IPublicClientApplication): AccountInfo | null { + return msal.getActiveAccount() ?? msal.getAllAccounts()[0] ?? null; +} diff --git a/src/graph.ts b/src/graph.ts new file mode 100644 index 0000000..fa7df26 --- /dev/null +++ b/src/graph.ts @@ -0,0 +1,127 @@ +const GRAPH = "https://graph.microsoft.com/v1.0"; + +function escapeODataString(s: string): string { + return s.replace(/'/g, "''"); +} + +export interface GraphMessage { + id: string; + subject?: string; +} + +export interface GraphMailFolder { + id: string; + displayName: string; +} + +async function graphJson(token: string, pathOrUrl: string, init?: RequestInit): Promise { + const url = pathOrUrl.startsWith("https://") ? pathOrUrl : `${GRAPH}${pathOrUrl}`; + const res = await fetch(url, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + ...(init?.headers ?? {}), + }, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Graph ${res.status}: ${text || res.statusText}`); + } + return res.json() as Promise; +} + +export async function listInboxChildFolders(token: string): Promise { + const out: GraphMailFolder[] = []; + let next: string | null = + "/me/mailFolders/inbox/childFolders?$select=id,displayName&$top=999"; + + while (next) { + const page = await graphJson<{ value: GraphMailFolder[]; "@odata.nextLink"?: string }>( + token, + next, + ); + out.push(...page.value); + next = page["@odata.nextLink"] ?? null; + } + return out; +} + +export async function findChildFolderByName( + token: string, + displayName: string, +): Promise { + const folders = await listInboxChildFolders(token); + return folders.find((f) => f.displayName === displayName); +} + +export async function createInboxChildFolder( + token: string, + displayName: string, +): Promise { + return graphJson(token, "/me/mailFolders/inbox/childFolders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ displayName }), + }); +} + +export async function findOrCreateInboxChildFolder( + token: string, + displayName: string, +): Promise { + const existing = await findChildFolderByName(token, displayName); + if (existing) { + return existing; + } + return createInboxChildFolder(token, displayName); +} + +async function listMessagesMatchingFilter( + token: string, + filter: string, +): Promise { + const out: GraphMessage[] = []; + let next: string | null = + `/me/messages?$filter=${encodeURIComponent(filter)}&$select=id,subject&$top=999`; + + while (next) { + const page = await graphJson<{ value: GraphMessage[]; "@odata.nextLink"?: string }>( + token, + next, + ); + out.push(...page.value); + next = page["@odata.nextLink"] ?? null; + } + return out; +} + +/** + * Finds messages whose subject or preview contains "TrackingID#". + * Falls back to subject-only filter if the tenant rejects bodyPreview in $filter. + */ +export async function findMessagesWithTrackingId( + token: string, + trackingId: string, +): Promise { + const needle = `TrackingID#${trackingId}`; + const q = escapeODataString(needle); + const combined = `(contains(subject,'${q}') or contains(bodyPreview,'${q}'))`; + const subjectOnly = `contains(subject,'${q}')`; + try { + return await listMessagesMatchingFilter(token, combined); + } catch { + return listMessagesMatchingFilter(token, subjectOnly); + } +} + +export async function moveMessageToFolder( + token: string, + messageId: string, + destinationFolderId: string, +): Promise { + await graphJson(token, `/me/messages/${encodeURIComponent(messageId)}/move`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ destinationId: destinationFolderId }), + }); +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..198c912 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,14 @@ +/// +import { initApp } from "./app"; + +Office.onReady((info) => { + const root = document.getElementById("app"); + if (!root) { + return; + } + if (info.host === Office.HostType.Outlook) { + void initApp(); + return; + } + root.textContent = "Open OL Rescue in Outlook (desktop or web) to use this add-in."; +}); diff --git a/src/parse.ts b/src/parse.ts new file mode 100644 index 0000000..fa7ca7e --- /dev/null +++ b/src/parse.ts @@ -0,0 +1,28 @@ +/** Matches internal ticket markers like TrackingID#2603260030007249 */ +const TRACKING_RE = /TrackingID#(\d+)/i; + +export function extractTrackingId(text: string): string | null { + const m = TRACKING_RE.exec(text); + return m ? m[1] : null; +} + +/** + * Builds a suggested folder name: " " from a message subject line. + */ +export function suggestFolderDisplayName(subject: string, trackingId: string): string { + let title = subject.trim(); + const lower = title.toLowerCase(); + const idx = lower.indexOf("trackingid#"); + if (idx > 0) { + title = title.slice(0, idx).trim(); + title = title.replace(/\s*-\s*$/u, "").trim(); + } + title = title.replace(/^(re|fw|fwd)\s*:\s*/giu, "").trim(); + title = title.replace(/^\[external\]\s*/giu, "").trim(); + title = title.replace(/\s+/gu, " ").trim(); + if (title.length > 80) { + title = `${title.slice(0, 77)}...`; + } + const folder = `${trackingId} ${title || "Case"}`.trim(); + return folder.length > 255 ? folder.slice(0, 255) : folder; +} diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..2210478 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,35 @@ +export interface CaseEntry { + id: string; + trackingId: string; + folderDisplayName: string; + graphFolderId?: string; +} + +const KEY = "ol-rescue-cases-v1"; + +export function loadCases(): CaseEntry[] { + try { + const raw = localStorage.getItem(KEY); + if (!raw) { + return []; + } + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter( + (x): x is CaseEntry => + typeof x === "object" && + x !== null && + typeof (x as CaseEntry).id === "string" && + typeof (x as CaseEntry).trackingId === "string" && + typeof (x as CaseEntry).folderDisplayName === "string", + ); + } catch { + return []; + } +} + +export function saveCases(cases: CaseEntry[]): void { + localStorage.setItem(KEY, JSON.stringify(cases)); +} diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..3085c0c --- /dev/null +++ b/src/styles.css @@ -0,0 +1,221 @@ +:root { + color-scheme: light dark; + --bg: #0f1419; + --panel: #1a2332; + --text: #e8eef7; + --muted: #8b9cb3; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --border: #2d3a4f; + --radius: 10px; + font-family: + "Segoe UI Variable", + "Segoe UI", + system-ui, + -apple-system, + sans-serif; + font-size: 14px; + line-height: 1.45; + color: var(--text); + background: var(--bg); +} + +@media (prefers-color-scheme: light) { + :root { + --bg: #f4f6fa; + --panel: #ffffff; + --text: #111827; + --muted: #6b7280; + --border: #e5e7eb; + } +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; +} + +.wrap { + max-width: 520px; + margin: 0 auto; + padding: 16px 14px 28px; +} + +.header h1 { + margin: 0 0 6px; + font-size: 1.35rem; + font-weight: 650; + letter-spacing: -0.02em; +} + +.lede { + margin: 0 0 12px; + color: var(--muted); + font-size: 0.92rem; +} + +.auth-bar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + min-height: 36px; +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 14px 14px 16px; + margin-top: 12px; +} + +.panel h2 { + margin: 0 0 10px; + font-size: 0.85rem; + font-weight: 650; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); +} + +.label { + display: block; + margin-top: 10px; + margin-bottom: 4px; + font-size: 0.82rem; + color: var(--muted); +} + +.input { + width: 100%; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); + color: var(--text); + font: inherit; +} + +.input:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.btn-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.btn { + appearance: none; + border: none; + border-radius: 8px; + padding: 8px 14px; + font: inherit; + font-weight: 600; + cursor: pointer; + background: var(--accent); + color: #fff; +} + +.btn:hover { + background: var(--accent-hover); +} + +.btn-secondary { + background: transparent; + color: var(--accent); + border: 1px solid var(--accent); +} + +.btn-secondary:hover { + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.btn-ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--border); +} + +.btn-ghost:hover { + color: var(--text); + border-color: var(--muted); +} + +.btn-small { + padding: 6px 10px; + font-size: 0.85rem; +} + +.status { + font-size: 0.9rem; + margin-bottom: 8px; + min-height: 1.2em; +} + +.log { + margin: 0; + max-height: 200px; + overflow: auto; + padding: 10px; + border-radius: 8px; + background: var(--bg); + border: 1px solid var(--border); + font-size: 0.78rem; + white-space: pre-wrap; + word-break: break-word; +} + +.muted { + color: var(--muted); + font-size: 0.9rem; +} + +.case-list { + display: flex; + flex-direction: column; + gap: 10px; +} + +.case-row { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg); +} + +.case-meta { + display: flex; + flex-direction: column; + gap: 2px; +} + +.case-meta strong { + font-weight: 650; + font-size: 0.95rem; +} + +.case-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +code { + font-family: ui-monospace, "Cascadia Code", monospace; + font-size: 0.88em; + padding: 1px 5px; + border-radius: 4px; + background: color-mix(in srgb, var(--muted) 18%, transparent); +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..dd56790 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,9 @@ +/// + +interface ImportMetaEnv { + readonly VITE_AZURE_CLIENT_ID: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c145aee --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "lib": ["ES2022", "DOM"], + "types": ["vite/client", "office-js"] + }, + "include": ["src/**/*.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..e85ebf5 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "vite"; +import basicSsl from "@vitejs/plugin-basic-ssl"; + +export default defineConfig({ + plugins: [basicSsl()], + server: { + port: 3000, + strictPort: true, + https: {}, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, +});