Initial commit: OL Rescue Outlook add-in (New Outlook + Graph)
Made-with: Cursor
This commit is contained in:
329
src/app.ts
Normal file
329
src/app.ts
Normal file
@@ -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<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
props: Record<string, string | undefined> = {},
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const item = Office.context.mailbox.item;
|
||||
if (!item) {
|
||||
setStatus("Open a message first.");
|
||||
return;
|
||||
}
|
||||
setStatus("Reading open message…");
|
||||
await new Promise<void>((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.");
|
||||
}
|
||||
}
|
||||
80
src/auth.ts
Normal file
80
src/auth.ts
Normal file
@@ -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<void> {
|
||||
await msal.initialize();
|
||||
await msal.handleRedirectPromise();
|
||||
}
|
||||
|
||||
export async function signIn(msal: IPublicClientApplication): Promise<AccountInfo> {
|
||||
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<string> {
|
||||
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;
|
||||
}
|
||||
127
src/graph.ts
Normal file
127
src/graph.ts
Normal file
@@ -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<T>(token: string, pathOrUrl: string, init?: RequestInit): Promise<T> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
export async function listInboxChildFolders(token: string): Promise<GraphMailFolder[]> {
|
||||
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<GraphMailFolder | undefined> {
|
||||
const folders = await listInboxChildFolders(token);
|
||||
return folders.find((f) => f.displayName === displayName);
|
||||
}
|
||||
|
||||
export async function createInboxChildFolder(
|
||||
token: string,
|
||||
displayName: string,
|
||||
): Promise<GraphMailFolder> {
|
||||
return graphJson<GraphMailFolder>(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<GraphMailFolder> {
|
||||
const existing = await findChildFolderByName(token, displayName);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
return createInboxChildFolder(token, displayName);
|
||||
}
|
||||
|
||||
async function listMessagesMatchingFilter(
|
||||
token: string,
|
||||
filter: string,
|
||||
): Promise<GraphMessage[]> {
|
||||
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#<id>".
|
||||
* Falls back to subject-only filter if the tenant rejects bodyPreview in $filter.
|
||||
*/
|
||||
export async function findMessagesWithTrackingId(
|
||||
token: string,
|
||||
trackingId: string,
|
||||
): Promise<GraphMessage[]> {
|
||||
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<void> {
|
||||
await graphJson<unknown>(token, `/me/messages/${encodeURIComponent(messageId)}/move`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ destinationId: destinationFolderId }),
|
||||
});
|
||||
}
|
||||
14
src/main.ts
Normal file
14
src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/// <reference types="office-js" />
|
||||
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.";
|
||||
});
|
||||
28
src/parse.ts
Normal file
28
src/parse.ts
Normal file
@@ -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: "<id> <short title>" 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;
|
||||
}
|
||||
35
src/storage.ts
Normal file
35
src/storage.ts
Normal file
@@ -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));
|
||||
}
|
||||
221
src/styles.css
Normal file
221
src/styles.css
Normal file
@@ -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);
|
||||
}
|
||||
9
src/vite-env.d.ts
vendored
Normal file
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_AZURE_CLIENT_ID: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user