Add initial PsiTransfer Filelink provider scaffold

This commit is contained in:
Jordan Wages 2026-04-19 01:26:45 -05:00
commit 953cd501a7
16 changed files with 6283 additions and 1 deletions

141
ui/account.css Normal file
View file

@ -0,0 +1,141 @@
:root {
color-scheme: light;
--bg: #f5efe5;
--paper: #fffaf2;
--ink: #1d1c1a;
--muted: #6b6257;
--accent: #b3541e;
--accent-2: #e8c9a8;
--line: #dcc9b5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Georgia, "Times New Roman", serif;
color: var(--ink);
background:
radial-gradient(circle at top left, #f3dcc3, transparent 28%),
linear-gradient(180deg, #efe5d8 0%, var(--bg) 100%);
}
.page {
max-width: 760px;
margin: 0 auto;
padding: 32px 20px 48px;
}
.hero {
margin-bottom: 20px;
}
.eyebrow {
margin: 0 0 8px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--accent);
font-size: 0.78rem;
}
h1 {
margin: 0 0 12px;
font-size: clamp(2rem, 5vw, 3.2rem);
line-height: 0.95;
}
.intro,
.hint,
.status {
color: var(--muted);
}
.card {
background: color-mix(in srgb, var(--paper) 92%, white);
border: 1px solid var(--line);
border-radius: 18px;
padding: 20px;
box-shadow: 0 16px 40px rgba(74, 45, 16, 0.08);
}
.meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
font-size: 0.95rem;
}
form {
display: grid;
gap: 14px;
}
label {
display: grid;
gap: 6px;
}
label span {
font-weight: 600;
}
input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fffdf9;
color: var(--ink);
font: inherit;
}
input:focus {
outline: 2px solid color-mix(in srgb, var(--accent) 45%, white);
outline-offset: 1px;
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
}
button {
border: 0;
border-radius: 999px;
padding: 10px 18px;
background: var(--accent);
color: white;
font: inherit;
cursor: pointer;
}
button:hover {
background: #954515;
}
.status {
min-height: 1.5em;
margin: 14px 0 0;
}
code {
background: var(--accent-2);
padding: 2px 6px;
border-radius: 6px;
}
@media (max-width: 560px) {
.page {
padding: 20px 14px 32px;
}
.meta {
flex-direction: column;
}
}

65
ui/account.html Normal file
View file

@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PsiTransfer Account</title>
<link rel="stylesheet" href="account.css" />
</head>
<body>
<main class="page">
<header class="hero">
<p class="eyebrow">Thunderbird Filelink</p>
<h1>PsiTransfer Account</h1>
<p class="intro">
Local account settings for the PsiTransfer Filelink provider. The minimal v1 path uploads one Thunderbird attachment into one PsiTransfer bucket and returns the bucket share URL.
</p>
</header>
<section class="card">
<div class="meta">
<span>Account ID</span>
<code id="account-id">unknown</code>
</div>
<form id="account-form">
<label>
<span>Base PsiTransfer URL</span>
<input id="base-url" name="baseUrl" type="url" placeholder="https://files.example.com/" required />
</label>
<label>
<span>Upload app path</span>
<input id="upload-app-path" name="uploadAppPath" type="text" placeholder="/ or upload/" />
</label>
<label>
<span>Optional upload password</span>
<input id="upload-password" name="uploadPassword" type="password" placeholder="x-passwd header value" />
</label>
<label>
<span>Default retention / expiry</span>
<input id="default-retention" name="defaultRetention" type="text" placeholder="604800 or one-time" />
</label>
<label>
<span>Custom SID behavior</span>
<input id="custom-sid" name="customSid" type="text" placeholder="Leave blank for generated SID" />
</label>
<div class="actions">
<button type="submit">Save</button>
</div>
</form>
<p id="status" class="status" role="status"></p>
<p class="hint">
`uploadAppPath` defaults to `/`. Set it only if the PsiTransfer server mounts uploads under a subpath relative to the base URL.
</p>
</section>
</main>
<script type="module" src="account.js"></script>
</body>
</html>

70
ui/account.js Normal file
View file

@ -0,0 +1,70 @@
import { AccountStore } from "../src/cloudfile/account-store.js";
const accountId = new URL(location.href).searchParams.get("accountId");
const form = document.getElementById("account-form");
const statusNode = document.getElementById("status");
const accountIdNode = document.getElementById("account-id");
const accountStore = new AccountStore();
function setStatus(message, isError = false) {
statusNode.textContent = message;
statusNode.style.color = isError ? "#8a1f11" : "";
}
function formDataToConfig(formElement) {
const data = new FormData(formElement);
return {
baseUrl: String(data.get("baseUrl") || "").trim(),
uploadAppPath: String(data.get("uploadAppPath") || "").trim() || "/",
uploadPassword: String(data.get("uploadPassword") || "").trim(),
defaultRetention: String(data.get("defaultRetention") || "").trim() || "604800",
customSid: String(data.get("customSid") || "").trim()
};
}
async function load() {
if (!accountId) {
setStatus("Missing Thunderbird cloudFile accountId.", true);
form.querySelector("button").disabled = true;
return;
}
accountIdNode.textContent = accountId;
const config = await accountStore.get(accountId);
if (!config) {
return;
}
document.getElementById("base-url").value = config.baseUrl || "";
document.getElementById("upload-app-path").value = config.uploadAppPath || "/";
document.getElementById("upload-password").value = config.uploadPassword || "";
document.getElementById("default-retention").value = config.defaultRetention || "604800";
document.getElementById("custom-sid").value = config.customSid || "";
}
form.addEventListener("submit", async event => {
event.preventDefault();
if (!accountId) {
setStatus("Cannot save without an accountId.", true);
return;
}
try {
const config = formDataToConfig(form);
await accountStore.set(accountId, config);
await browser.cloudFile.updateAccount(accountId, {
configured: Boolean(config.baseUrl),
managementUrl: browser.runtime.getURL(`ui/account.html?accountId=${encodeURIComponent(accountId)}`)
});
setStatus("Settings saved.");
} catch (caughtError) {
setStatus(caughtError instanceof Error ? caughtError.message : String(caughtError), true);
}
});
load().catch(caughtError => {
setStatus(caughtError instanceof Error ? caughtError.message : String(caughtError), true);
});