2026-08-17 10:39:46 +02:00
|
|
|
<script>
|
2026-08-20 07:22:25 +02:00
|
|
|
/** Gmail, Google Calendar, the calendar feed, and the mail-to-task address. */
|
2026-08-17 10:39:46 +02:00
|
|
|
import { api, humanMessage } from '$lib/api.js';
|
|
|
|
|
import { app } from '$lib/stores.svelte.js';
|
|
|
|
|
import { page } from '$app/stores';
|
2026-08-20 07:22:25 +02:00
|
|
|
import { t, plural, tag } from '$lib/i18n.svelte.js';
|
2026-08-17 10:39:46 +02:00
|
|
|
|
|
|
|
|
// --- Gmail ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
let gmail = $state(null);
|
|
|
|
|
let savingGmail = $state(false);
|
|
|
|
|
let gmailSaved = $state(false);
|
|
|
|
|
let syncing = $state(false);
|
|
|
|
|
|
2026-08-18 09:05:18 +02:00
|
|
|
// The OAuth client is the instance's registration with Google, not anybody's
|
|
|
|
|
// mailbox — so it is read only for administrators, and only when they are the
|
|
|
|
|
// ones who could act on it.
|
|
|
|
|
let client = $state(null);
|
|
|
|
|
let clientId = $state('');
|
|
|
|
|
let clientSecret = $state('');
|
|
|
|
|
let savingClient = $state(false);
|
|
|
|
|
let clientSaved = $state(false);
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
if (!app.user?.is_admin) return;
|
|
|
|
|
api
|
|
|
|
|
.gmailClient()
|
|
|
|
|
.then((c) => {
|
|
|
|
|
client = c;
|
|
|
|
|
clientId = c.client_id ?? '';
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {
|
|
|
|
|
// Not worth a toast: the panel simply does not offer the fields, which
|
|
|
|
|
// is the same thing it does for everybody who is not an administrator.
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function saveClient(event) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
savingClient = true;
|
|
|
|
|
clientSaved = false;
|
|
|
|
|
try {
|
|
|
|
|
await api.setGmailClient({ client_id: clientId, client_secret: clientSecret });
|
|
|
|
|
// Cleared rather than kept: the field is a password field and the value is
|
|
|
|
|
// now stored. Leaving it filled is how a secret ends up in a screenshot.
|
|
|
|
|
clientSecret = '';
|
|
|
|
|
client = await api.gmailClient();
|
|
|
|
|
clientSaved = true;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
} finally {
|
|
|
|
|
savingClient = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 10:39:46 +02:00
|
|
|
/**
|
|
|
|
|
* What the OAuth callback redirected back with. It arrives as a query parameter
|
|
|
|
|
* because the callback is a top-level navigation from Google — there is no fetch
|
|
|
|
|
* whose response could have carried it.
|
|
|
|
|
*/
|
|
|
|
|
const CALLBACK = {
|
2026-08-18 09:46:08 +02:00
|
|
|
connected: { tone: 'ok', text: t('int.gmailConnected') },
|
2026-08-17 10:39:46 +02:00
|
|
|
state: {
|
|
|
|
|
tone: 'bad',
|
2026-08-18 09:46:08 +02:00
|
|
|
text: t('int.gmailState')
|
2026-08-17 10:39:46 +02:00
|
|
|
},
|
2026-08-18 09:46:08 +02:00
|
|
|
expired: { tone: 'bad', text: t('int.gmailExpired') },
|
|
|
|
|
invalid: { tone: 'bad', text: t('int.gmailInvalid') },
|
|
|
|
|
failed: { tone: 'bad', text: t('int.gmailFailed') },
|
2026-08-17 10:39:46 +02:00
|
|
|
norefresh: {
|
|
|
|
|
tone: 'bad',
|
2026-08-18 09:46:08 +02:00
|
|
|
text: t('int.gmailNoRefresh')
|
2026-08-17 10:39:46 +02:00
|
|
|
},
|
2026-08-18 09:46:08 +02:00
|
|
|
access_denied: { tone: 'bad', text: t('int.gmailDenied') }
|
2026-08-17 10:39:46 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let callback = $derived.by(() => {
|
|
|
|
|
const value = $page.url.searchParams.get('gmail');
|
|
|
|
|
if (!value) return null;
|
2026-08-18 09:46:08 +02:00
|
|
|
return CALLBACK[value] ?? { tone: 'bad', text: t('int.gmailOther', { what: value }) };
|
2026-08-17 10:39:46 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
api
|
|
|
|
|
.getGmail()
|
|
|
|
|
.then((g) => (gmail = g))
|
|
|
|
|
.catch((e) => app.toast(humanMessage(e)));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function connect() {
|
|
|
|
|
try {
|
|
|
|
|
const { url } = await api.authorizeGmail();
|
|
|
|
|
location.href = url;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveGmail(event) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
savingGmail = true;
|
|
|
|
|
gmailSaved = false;
|
|
|
|
|
try {
|
|
|
|
|
await api.setGmail({ trigger: gmail.trigger, label: gmail.label ?? '' });
|
|
|
|
|
gmailSaved = true;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
} finally {
|
|
|
|
|
savingGmail = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function syncNow() {
|
|
|
|
|
syncing = true;
|
|
|
|
|
try {
|
|
|
|
|
const result = await api.syncGmail();
|
|
|
|
|
app.toast(
|
2026-08-18 15:14:37 +02:00
|
|
|
result?.created
|
|
|
|
|
? plural(result.created, 'int.gmailFetchedOne', 'int.gmailFetchedMany')
|
|
|
|
|
: t('int.gmailNothingNew')
|
2026-08-17 10:39:46 +02:00
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
} finally {
|
|
|
|
|
syncing = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function disconnectGmail() {
|
2026-08-18 13:28:44 +02:00
|
|
|
if (!confirm(t('int.disconnectQuestion'))) return;
|
2026-08-17 10:39:46 +02:00
|
|
|
try {
|
|
|
|
|
await api.disconnectGmail();
|
|
|
|
|
gmail = await api.getGmail();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 07:22:25 +02:00
|
|
|
// --- Google Calendar -----------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
// Its own panel rather than a second mode inside the Gmail one. They sign in
|
|
|
|
|
// through the same registration and are otherwise nothing alike: one turns mail
|
|
|
|
|
// into work you own, the other shows a copy of somebody else's calendar and
|
|
|
|
|
// never writes to it.
|
|
|
|
|
let calendar = $state(null);
|
|
|
|
|
let savingCalendars = $state(false);
|
|
|
|
|
let calendarsSaved = $state(false);
|
|
|
|
|
let syncingCalendar = $state(false);
|
|
|
|
|
/** The ids ticked in the form, which is not what is stored until Save. */
|
|
|
|
|
let shownCalendars = $state([]);
|
|
|
|
|
|
|
|
|
|
const CALENDAR_CALLBACK = {
|
|
|
|
|
connected: { tone: 'ok', text: t('int.calendarConnected') },
|
|
|
|
|
state: { tone: 'bad', text: t('int.calendarState') },
|
|
|
|
|
expired: { tone: 'bad', text: t('int.calendarExpired') },
|
|
|
|
|
invalid: { tone: 'bad', text: t('int.calendarInvalid') },
|
|
|
|
|
failed: { tone: 'bad', text: t('int.calendarFailed') },
|
|
|
|
|
norefresh: { tone: 'bad', text: t('int.calendarNoRefresh') },
|
|
|
|
|
access_denied: { tone: 'bad', text: t('int.calendarDenied') },
|
|
|
|
|
// The one worth naming. An OAuth app registered as Internal accepts only
|
|
|
|
|
// accounts inside the organisation, and Google's own screen does not say
|
|
|
|
|
// that in words anybody could act on.
|
|
|
|
|
org_internal: { tone: 'bad', text: t('int.calendarOrgInternal') }
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let calendarCallback = $derived.by(() => {
|
|
|
|
|
const value = $page.url.searchParams.get('calendar');
|
|
|
|
|
if (!value) return null;
|
|
|
|
|
return CALENDAR_CALLBACK[value] ?? { tone: 'bad', text: t('int.calendarOther', { what: value }) };
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
loadCalendar();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function loadCalendar() {
|
|
|
|
|
try {
|
|
|
|
|
calendar = await api.getCalendar();
|
|
|
|
|
shownCalendars = calendar.calendars.filter((c) => c.shown).map((c) => c.id);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function connectCalendar() {
|
|
|
|
|
try {
|
|
|
|
|
const { url } = await api.authorizeCalendar();
|
|
|
|
|
location.href = url;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 21:26:13 +02:00
|
|
|
// --- abonnementer ---------------------------------------------------------
|
|
|
|
|
let subscriptionURL = $state('');
|
|
|
|
|
let subscribing = $state(false);
|
|
|
|
|
let subscriptionError = $state('');
|
|
|
|
|
|
|
|
|
|
async function subscribe(event) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
const url = subscriptionURL.trim();
|
|
|
|
|
if (!url) return;
|
|
|
|
|
subscribing = true;
|
|
|
|
|
subscriptionError = '';
|
|
|
|
|
try {
|
|
|
|
|
await api.subscribeCalendar(url, '');
|
|
|
|
|
subscriptionURL = '';
|
|
|
|
|
calendar = await api.getCalendar();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Feltets egen fejl frem for en toast: den hører til det felt, der lige
|
|
|
|
|
// blev skrevet i, og en toast, der forsvinder, er en fejlmeddelelse, man
|
|
|
|
|
// skal huske.
|
|
|
|
|
subscriptionError = e.fields?.url ?? humanMessage(e);
|
|
|
|
|
} finally {
|
|
|
|
|
subscribing = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function unsubscribe(sub) {
|
|
|
|
|
if (!confirm(t('int.unsubscribeQuestion', { name: sub.account || sub.url }))) return;
|
|
|
|
|
try {
|
|
|
|
|
await api.unsubscribeCalendar(sub.id);
|
|
|
|
|
calendar = await api.getCalendar();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 07:22:25 +02:00
|
|
|
async function saveCalendars(event) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
savingCalendars = true;
|
|
|
|
|
calendarsSaved = false;
|
|
|
|
|
try {
|
|
|
|
|
await api.setCalendars(shownCalendars);
|
|
|
|
|
calendarsSaved = true;
|
2026-08-20 07:27:42 +02:00
|
|
|
// And fetch, without being asked again.
|
|
|
|
|
//
|
|
|
|
|
// Ticking a calendar is somebody saying "show me this one". Saving the
|
|
|
|
|
// choice and leaving the grid empty until the sweep comes round a quarter
|
|
|
|
|
// of an hour later is a calendar that says a fortnight is clear — and the
|
|
|
|
|
// person is on this page, watching, with the button under their hand.
|
|
|
|
|
// Also what refreshes the panel: syncCalendarNow reloads it when it is done.
|
|
|
|
|
if (shownCalendars.length) {
|
|
|
|
|
await syncCalendarNow();
|
|
|
|
|
} else {
|
|
|
|
|
await loadCalendar();
|
|
|
|
|
}
|
2026-08-20 07:22:25 +02:00
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
} finally {
|
|
|
|
|
savingCalendars = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function syncCalendarNow() {
|
|
|
|
|
syncingCalendar = true;
|
|
|
|
|
try {
|
|
|
|
|
const result = await api.syncCalendar();
|
|
|
|
|
app.toast(
|
|
|
|
|
result?.events
|
|
|
|
|
? plural(result.events, 'int.calendarFetchedOne', 'int.calendarFetchedMany')
|
|
|
|
|
: t('int.calendarNothingNew')
|
|
|
|
|
);
|
|
|
|
|
await loadCalendar();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
} finally {
|
|
|
|
|
syncingCalendar = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function disconnectCalendar() {
|
|
|
|
|
if (!confirm(t('int.disconnectCalendarQuestion'))) return;
|
|
|
|
|
try {
|
|
|
|
|
await api.disconnectCalendar();
|
|
|
|
|
await loadCalendar();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function toggleCalendar(id) {
|
|
|
|
|
shownCalendars = shownCalendars.includes(id)
|
|
|
|
|
? shownCalendars.filter((c) => c !== id)
|
|
|
|
|
: [...shownCalendars, id];
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 10:39:46 +02:00
|
|
|
// --- calendar feed -------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
let feedURL = $state('');
|
|
|
|
|
let mailAddress = $state('');
|
2026-09-08 14:03:32 +02:00
|
|
|
// Krogen ind i indbakken: den URL, en genvej på telefonen eller et script kan
|
|
|
|
|
// skubbe en linje tekst til.
|
|
|
|
|
let hookURL = $state('');
|
2026-08-17 14:32:42 +02:00
|
|
|
/** Null until loaded, so the warning does not flash before the answer is in. */
|
|
|
|
|
let mailConfigured = $state(null);
|
2026-08-17 10:39:46 +02:00
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
api.feed().then((r) => (feedURL = r.url)).catch(() => {});
|
2026-08-17 14:32:42 +02:00
|
|
|
api
|
|
|
|
|
.getMailAddress()
|
|
|
|
|
.then((r) => {
|
|
|
|
|
mailAddress = r.address;
|
|
|
|
|
mailConfigured = r.configured;
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {});
|
2026-09-08 14:03:32 +02:00
|
|
|
api
|
|
|
|
|
.getHookURL()
|
|
|
|
|
.then((r) => (hookURL = r.url))
|
|
|
|
|
.catch(() => {});
|
2026-08-17 10:39:46 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function rotateFeed() {
|
2026-08-18 13:28:44 +02:00
|
|
|
if (!confirm(t('int.newLinkQuestion'))) return;
|
2026-08-17 10:39:46 +02:00
|
|
|
try {
|
|
|
|
|
feedURL = (await api.rotateFeed()).url;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function rotateMail() {
|
2026-08-18 13:28:44 +02:00
|
|
|
if (!confirm(t('int.newAddressQuestion'))) return;
|
2026-08-17 10:39:46 +02:00
|
|
|
try {
|
2026-08-17 14:32:42 +02:00
|
|
|
const r = await api.rotateMailAddress();
|
|
|
|
|
mailAddress = r.address;
|
|
|
|
|
mailConfigured = r.configured;
|
2026-08-17 10:39:46 +02:00
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 14:03:32 +02:00
|
|
|
async function rotateHook() {
|
|
|
|
|
if (!confirm(t('int.newHookQuestion'))) return;
|
|
|
|
|
try {
|
|
|
|
|
hookURL = (await api.rotateHookURL()).url;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 10:39:46 +02:00
|
|
|
async function copy(text) {
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(text);
|
2026-08-18 13:28:44 +02:00
|
|
|
app.toast(t('int.copied'));
|
2026-08-17 10:39:46 +02:00
|
|
|
} catch {
|
2026-08-18 13:28:44 +02:00
|
|
|
app.toast(t('int.copyFailed'));
|
2026-08-17 10:39:46 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-08-18 22:22:31 +02:00
|
|
|
|
|
|
|
|
// --- mailboxes ----------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
let mailboxes = $state([]);
|
|
|
|
|
let addingMailbox = $state(false);
|
|
|
|
|
let connecting = $state(false);
|
|
|
|
|
let syncingId = $state(null);
|
|
|
|
|
let newMailbox = $state({ host: '', username: '', password: '' });
|
|
|
|
|
|
|
|
|
|
$effect(() => {
|
|
|
|
|
loadMailboxes();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function loadMailboxes() {
|
|
|
|
|
try {
|
|
|
|
|
mailboxes = (await api.mailboxes()).mailboxes ?? [];
|
|
|
|
|
} catch {
|
|
|
|
|
// A settings page that will not render because one panel could not load
|
|
|
|
|
// is worse than a panel that is empty.
|
|
|
|
|
mailboxes = [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function addMailbox(event) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
connecting = true;
|
|
|
|
|
try {
|
|
|
|
|
// The server dials before it saves, so an error here is the host's own
|
|
|
|
|
// refusal — a wrong app password, a host that is not listening.
|
|
|
|
|
await api.addMailbox({ ...newMailbox });
|
|
|
|
|
newMailbox = { host: '', username: '', password: '' };
|
|
|
|
|
addingMailbox = false;
|
|
|
|
|
await loadMailboxes();
|
|
|
|
|
app.toast(t('int.mailboxConnected'));
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
} finally {
|
|
|
|
|
connecting = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function syncMailbox(box) {
|
|
|
|
|
syncingId = box.id;
|
|
|
|
|
try {
|
|
|
|
|
const result = await api.syncMailbox(box.id);
|
|
|
|
|
app.toast(
|
|
|
|
|
result?.created
|
|
|
|
|
? plural(result.created, 'int.gmailFetchedOne', 'int.gmailFetchedMany')
|
|
|
|
|
: t('int.gmailNothingNew')
|
|
|
|
|
);
|
|
|
|
|
await loadMailboxes();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
} finally {
|
|
|
|
|
syncingId = null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function removeMailbox(box) {
|
|
|
|
|
if (!confirm(t('int.disconnectMailbox', { name: box.name }))) return;
|
|
|
|
|
try {
|
|
|
|
|
await api.deleteMailbox(box.id);
|
|
|
|
|
await loadMailboxes();
|
|
|
|
|
} catch (e) {
|
|
|
|
|
app.toast(humanMessage(e));
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-17 10:39:46 +02:00
|
|
|
</script>
|
|
|
|
|
|
2026-09-12 09:56:00 +02:00
|
|
|
<section class="panel" id="gmail">
|
2026-08-17 10:39:46 +02:00
|
|
|
<header>
|
2026-08-18 09:46:08 +02:00
|
|
|
<h2>{t('int.gmail')}</h2>
|
2026-08-17 10:39:46 +02:00
|
|
|
<p class="hint">
|
2026-08-18 09:46:08 +02:00
|
|
|
{t('int.gmailHint')}
|
2026-08-17 10:39:46 +02:00
|
|
|
</p>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
{#if callback}
|
|
|
|
|
<p class="callback" data-tone={callback.tone}>{callback.text}</p>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
{#if gmail === null}
|
|
|
|
|
<p class="empty">…</p>
|
|
|
|
|
{:else if !gmail.connected}
|
|
|
|
|
<div class="row">
|
2026-08-18 09:05:18 +02:00
|
|
|
<button class="primary" onclick={connect} disabled={client && !client.client_id}>
|
2026-08-18 09:46:08 +02:00
|
|
|
{t('int.connectGmail')}
|
2026-08-18 09:05:18 +02:00
|
|
|
</button>
|
2026-08-17 10:39:46 +02:00
|
|
|
</div>
|
2026-08-18 09:05:18 +02:00
|
|
|
|
|
|
|
|
<!-- The registration behind the button, for whoever can do something about
|
|
|
|
|
it. Google issues no Gmail access to an unregistered client, and
|
2026-09-04 08:57:07 +02:00
|
|
|
gmail.modify is a restricted scope — so this step cannot be skipped by
|
2026-08-18 09:05:18 +02:00
|
|
|
any app. It can stop requiring a redeploy, which is what this is. -->
|
|
|
|
|
{#if app.user?.is_admin && client}
|
|
|
|
|
{#if client.from_env}
|
|
|
|
|
<p class="hint">
|
2026-08-18 09:46:08 +02:00
|
|
|
{t('int.fromEnv')}
|
2026-08-18 09:05:18 +02:00
|
|
|
</p>
|
|
|
|
|
{:else}
|
|
|
|
|
<details class="setup" open={!client.client_id}>
|
2026-08-18 09:46:08 +02:00
|
|
|
<summary>{t('int.oauthClient')}</summary>
|
2026-08-18 09:05:18 +02:00
|
|
|
|
|
|
|
|
<p class="hint">
|
2026-08-18 09:46:08 +02:00
|
|
|
{t('int.registerHint')}
|
2026-08-18 09:05:18 +02:00
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
<label for="redirect">{t('int.redirectURI')}</label>
|
2026-08-18 09:05:18 +02:00
|
|
|
<!-- Read-only and spelled out: it is derived from VERDANDE_BASE_URL,
|
|
|
|
|
it has to match Google's copy exactly, and the error Google gives
|
|
|
|
|
when it does not names neither value. Google afviser desuden
|
|
|
|
|
private IP-adresser over http — det skal være https. -->
|
2026-09-12 09:18:49 +02:00
|
|
|
<output id="redirect">{client.redirect_uri}</output>
|
2026-08-18 09:05:18 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<form onsubmit={saveClient}>
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
<label for="client-id">{t('int.clientID')}</label>
|
2026-08-18 09:05:18 +02:00
|
|
|
<input id="client-id" bind:value={clientId} autocomplete="off" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
<label for="client-secret">{t('int.clientSecret')}</label>
|
2026-08-18 09:05:18 +02:00
|
|
|
<input
|
|
|
|
|
id="client-secret"
|
|
|
|
|
type="password"
|
|
|
|
|
bind:value={clientSecret}
|
|
|
|
|
autocomplete="off"
|
2026-08-18 09:46:08 +02:00
|
|
|
placeholder={client.has_secret ? t('int.secretStored') : ''}
|
2026-08-18 09:05:18 +02:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
2026-08-18 09:46:08 +02:00
|
|
|
<button class="primary" type="submit" disabled={savingClient}>{t('int.save')}</button>
|
|
|
|
|
{#if clientSaved}<span class="saved">{t('int.saved')}</span>{/if}
|
2026-08-18 09:05:18 +02:00
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</details>
|
|
|
|
|
{/if}
|
|
|
|
|
{:else if client && !client.client_id}
|
|
|
|
|
<p class="hint">
|
2026-08-18 09:46:08 +02:00
|
|
|
{t('int.noClient')}
|
2026-08-18 09:05:18 +02:00
|
|
|
</p>
|
|
|
|
|
{/if}
|
2026-08-17 10:39:46 +02:00
|
|
|
{:else}
|
2026-08-18 09:46:08 +02:00
|
|
|
<p class="hint">{t('int.connectedAs')} <strong>{gmail.email || t('int.unknownAccount')}</strong>.</p>
|
2026-08-17 10:39:46 +02:00
|
|
|
|
|
|
|
|
<form onsubmit={saveGmail}>
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
<label for="trigger">{t('int.whatMakesATask')}</label>
|
2026-08-17 10:39:46 +02:00
|
|
|
<select id="trigger" bind:value={gmail.trigger}>
|
2026-08-18 09:46:08 +02:00
|
|
|
<option value="starred">{t('int.starred')}</option>
|
|
|
|
|
<option value="label">{t('int.aLabel')}</option>
|
|
|
|
|
<option value="both">{t('int.both')}</option>
|
2026-08-17 10:39:46 +02:00
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if gmail.trigger === 'label' || gmail.trigger === 'both'}
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
<label for="gmail-label">{t('int.gmailLabelName')}</label>
|
|
|
|
|
<input id="gmail-label" bind:value={gmail.label} placeholder={t('int.gmailLabelExample')} />
|
2026-08-17 10:39:46 +02:00
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<div class="row">
|
2026-08-18 09:46:08 +02:00
|
|
|
<button class="primary" type="submit" disabled={savingGmail}>{t('int.save')}</button>
|
|
|
|
|
{#if gmailSaved}<span class="saved">{t('int.saved')}</span>{/if}
|
|
|
|
|
<button class="secondary" onclick={syncNow} disabled={syncing}>{t('int.fetchNow')}</button>
|
|
|
|
|
<button class="danger" onclick={disconnectGmail}>{t('int.disconnect')}</button>
|
2026-08-17 10:39:46 +02:00
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
{/if}
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-09-12 09:56:00 +02:00
|
|
|
<section class="panel" id="kalender">
|
2026-08-20 07:22:25 +02:00
|
|
|
<header>
|
|
|
|
|
<h2>{t('int.calendar')}</h2>
|
|
|
|
|
<p class="hint">{t('int.calendarHint')}</p>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
{#if calendarCallback}
|
|
|
|
|
<p class="callback" data-tone={calendarCallback.tone}>{calendarCallback.text}</p>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
{#if calendar === null}
|
|
|
|
|
<p class="empty">…</p>
|
|
|
|
|
{:else if !calendar.connected}
|
|
|
|
|
<div class="row">
|
|
|
|
|
<button class="primary" onclick={connectCalendar} disabled={!calendar.has_client}>
|
|
|
|
|
{t('int.connectCalendar')}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- The registration is the Gmail panel's — one client, two features — so
|
|
|
|
|
this only spells out the second redirect URI, which has to be added to
|
|
|
|
|
it. It is the single most likely thing to be wrong, and the error Google
|
|
|
|
|
gives for it names neither value. -->
|
|
|
|
|
{#if app.user?.is_admin}
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label for="calendar-redirect">{t('int.redirectURI')}</label>
|
2026-09-12 09:18:49 +02:00
|
|
|
<output id="calendar-redirect" class="mono">{calendar.redirect_uri}</output>
|
2026-08-20 07:33:24 +02:00
|
|
|
<p class="hint">{t('int.calendarSameClient')}</p>
|
2026-08-20 07:22:25 +02:00
|
|
|
</div>
|
|
|
|
|
{:else if !calendar.has_client}
|
|
|
|
|
<p class="hint">{t('int.noClient')}</p>
|
|
|
|
|
{/if}
|
|
|
|
|
{:else}
|
|
|
|
|
<p class="hint">
|
|
|
|
|
{t('int.calendarConnectedAs')}
|
|
|
|
|
<strong>{calendar.account || t('int.unknownAccount')}</strong>.
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<form onsubmit={saveCalendars}>
|
|
|
|
|
{#if calendar.calendars.length}
|
|
|
|
|
<fieldset>
|
|
|
|
|
<legend>{t('int.whichCalendars')}</legend>
|
|
|
|
|
{#each calendar.calendars as c (c.id)}
|
|
|
|
|
<label class="pick">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={shownCalendars.includes(c.id)}
|
|
|
|
|
onchange={() => toggleCalendar(c.id)}
|
|
|
|
|
/>
|
|
|
|
|
<!-- Google's own colour for the calendar, which is what the grid
|
|
|
|
|
draws its events in. Told apart by the colour the person
|
|
|
|
|
already knows it by, not by one of verdande's ten names. -->
|
|
|
|
|
<span class="swatch" style:background={c.colour || 'var(--line-strong)'}></span>
|
|
|
|
|
{c.name}
|
|
|
|
|
</label>
|
|
|
|
|
{/each}
|
|
|
|
|
</fieldset>
|
|
|
|
|
{:else}
|
|
|
|
|
<p class="hint">{t('int.noCalendars')}</p>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<p class="hint">
|
|
|
|
|
{calendar.last_sync_at
|
|
|
|
|
? t('int.calendarLastSync', {
|
|
|
|
|
when: new Date(calendar.last_sync_at).toLocaleString(tag())
|
|
|
|
|
})
|
|
|
|
|
: t('int.calendarNeverSynced')}
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
<button class="primary" type="submit" disabled={savingCalendars}>{t('int.save')}</button>
|
|
|
|
|
{#if calendarsSaved}<span class="saved">{t('int.saved')}</span>{/if}
|
|
|
|
|
<button class="secondary" onclick={syncCalendarNow} disabled={syncingCalendar}>
|
|
|
|
|
{syncingCalendar ? t('int.fetching') : t('int.fetchNow')}
|
|
|
|
|
</button>
|
|
|
|
|
<button class="danger" onclick={disconnectCalendar}>{t('int.disconnect')}</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
{/if}
|
2026-08-20 21:26:13 +02:00
|
|
|
|
|
|
|
|
<!-- Abonnementer står uden for Google-forgreningen ovenfor, og det er hele
|
|
|
|
|
pointen: en konto, der ikke kan bruge instansens OAuth-klient — en privat
|
|
|
|
|
Gmail mod en Internal-registrering — kan stadig abonnere på en adresse. -->
|
|
|
|
|
{#if calendar !== null}
|
|
|
|
|
<div class="subs">
|
2026-09-12 09:56:00 +02:00
|
|
|
<h3 id="abonnementer">{t('int.subscriptions')}</h3>
|
2026-08-20 21:26:13 +02:00
|
|
|
<p class="hint">{t('int.subscriptionsHint')}</p>
|
|
|
|
|
|
|
|
|
|
{#if calendar.subscriptions?.length}
|
|
|
|
|
<ul class="subs-list">
|
|
|
|
|
{#each calendar.subscriptions as sub (sub.id)}
|
|
|
|
|
<li>
|
|
|
|
|
<span class="name">{sub.account || sub.url}</span>
|
|
|
|
|
<span class="url mono">{sub.url}</span>
|
|
|
|
|
<button class="danger" onclick={() => unsubscribe(sub)}>
|
|
|
|
|
{t('int.unsubscribe')}
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
{/each}
|
|
|
|
|
</ul>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<form onsubmit={subscribe}>
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label for="calendar-url">{t('int.subscriptionURL')}</label>
|
|
|
|
|
<input
|
|
|
|
|
id="calendar-url"
|
|
|
|
|
class="mono"
|
|
|
|
|
bind:value={subscriptionURL}
|
|
|
|
|
placeholder="https://… eller webcal://…"
|
|
|
|
|
/>
|
|
|
|
|
{#if subscriptionError}<p class="error">{subscriptionError}</p>{/if}
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<button class="primary" type="submit" disabled={subscribing}>
|
|
|
|
|
{subscribing ? t('int.subscribing') : t('int.subscribe')}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
2026-08-20 07:22:25 +02:00
|
|
|
</section>
|
|
|
|
|
|
2026-09-12 09:56:00 +02:00
|
|
|
<section class="panel" id="kalenderfeed">
|
2026-08-17 10:39:46 +02:00
|
|
|
<header>
|
2026-08-18 09:46:08 +02:00
|
|
|
<h2>{t('int.feed')}</h2>
|
2026-08-17 10:39:46 +02:00
|
|
|
<p class="hint">
|
2026-08-18 09:46:08 +02:00
|
|
|
{t('int.feedHint')}
|
2026-08-17 10:39:46 +02:00
|
|
|
</p>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
<label for="feed">{t('int.address')}</label>
|
2026-09-12 09:18:49 +02:00
|
|
|
<output id="feed" class="mono">{feedURL}</output>
|
2026-08-17 10:39:46 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="row">
|
2026-08-18 09:46:08 +02:00
|
|
|
<button class="secondary" onclick={() => copy(feedURL)}>{t('int.copy')}</button>
|
|
|
|
|
<button class="danger" onclick={rotateFeed}>{t('int.newLink')}</button>
|
2026-08-17 10:39:46 +02:00
|
|
|
</div>
|
|
|
|
|
<p class="hint">
|
2026-08-18 09:46:08 +02:00
|
|
|
{t('int.newLinkHint')}
|
2026-08-17 10:39:46 +02:00
|
|
|
</p>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-09-12 09:56:00 +02:00
|
|
|
<section class="panel" id="mail-til-opgave">
|
2026-08-17 10:39:46 +02:00
|
|
|
<header>
|
2026-08-18 09:46:08 +02:00
|
|
|
<h2>{t('int.mailToTask')}</h2>
|
2026-08-17 10:39:46 +02:00
|
|
|
<p class="hint">
|
2026-08-18 09:46:08 +02:00
|
|
|
{t('int.mailHint')}
|
2026-08-17 10:39:46 +02:00
|
|
|
</p>
|
|
|
|
|
</header>
|
|
|
|
|
|
2026-08-17 14:32:42 +02:00
|
|
|
{#if mailConfigured === false}
|
|
|
|
|
<p class="callback" data-tone="bad">
|
2026-08-18 09:46:08 +02:00
|
|
|
{t('int.noMailServer')}
|
2026-08-17 14:32:42 +02:00
|
|
|
</p>
|
|
|
|
|
{/if}
|
|
|
|
|
|
2026-08-17 10:39:46 +02:00
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
<label for="mail">{t('int.yourAddress')}</label>
|
2026-09-12 09:18:49 +02:00
|
|
|
<output id="mail" class="mono">{mailAddress}</output>
|
2026-08-17 10:39:46 +02:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="row">
|
2026-08-18 09:46:08 +02:00
|
|
|
<button class="secondary" onclick={() => copy(mailAddress)}>{t('int.copy')}</button>
|
|
|
|
|
<button class="danger" onclick={rotateMail}>{t('int.newAddress')}</button>
|
2026-08-17 10:39:46 +02:00
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-09-12 09:56:00 +02:00
|
|
|
<section class="panel" id="krog">
|
2026-09-08 14:03:32 +02:00
|
|
|
<header>
|
|
|
|
|
<h2>{t('int.hookToTask')}</h2>
|
|
|
|
|
<p class="hint">
|
|
|
|
|
{t('int.hookHint')}
|
|
|
|
|
</p>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label for="hook">{t('int.yourHook')}</label>
|
2026-09-12 09:18:49 +02:00
|
|
|
<output id="hook" class="mono">{hookURL}</output>
|
2026-09-08 14:03:32 +02:00
|
|
|
<p class="hint">
|
|
|
|
|
{t('int.hookHow')}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Vist som den kommando, den er. Den, der skal sætte det op, skal kunne
|
|
|
|
|
prøve det af ét sted, før de bygger genvejen på telefonen — og se, at
|
|
|
|
|
kroppen bare er den linje, opgaven skal hedde. -->
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label for="hookcurl">{t('int.hookExample')}</label>
|
2026-09-12 09:18:49 +02:00
|
|
|
<output id="hookcurl" class="mono"
|
|
|
|
|
>{`curl -X POST ${hookURL} \\\n -H "Content-Type: text/plain" \\\n -d "Ring til Anders i morgen p1 #Firma"`}</output
|
2026-09-08 14:03:32 +02:00
|
|
|
>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
<button class="secondary" onclick={() => copy(hookURL)}>{t('int.copy')}</button>
|
|
|
|
|
<button class="danger" onclick={rotateHook}>{t('int.newHook')}</button>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-09-12 09:56:00 +02:00
|
|
|
<section class="panel" id="caldav-server">
|
2026-08-17 10:39:46 +02:00
|
|
|
<header>
|
2026-08-18 09:46:08 +02:00
|
|
|
<h2>{t('int.caldav')}</h2>
|
2026-08-17 10:39:46 +02:00
|
|
|
<p class="hint">
|
2026-08-18 09:46:08 +02:00
|
|
|
{t('int.caldavHint')}
|
2026-08-17 10:39:46 +02:00
|
|
|
</p>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<div class="field">
|
2026-08-18 09:46:08 +02:00
|
|
|
<label for="caldav">{t('int.server')}</label>
|
2026-09-12 09:18:49 +02:00
|
|
|
<output id="caldav" class="mono">{`${location.origin}/caldav/`}</output>
|
2026-08-17 10:39:46 +02:00
|
|
|
<p class="hint">
|
2026-08-18 09:46:08 +02:00
|
|
|
{t('int.caldavAuth')}
|
2026-08-17 10:39:46 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-08-18 22:22:31 +02:00
|
|
|
<!-- Mailboxes read over IMAP: iCloud, Fastmail, any ordinary host. Its own panel
|
|
|
|
|
rather than a second mode inside the Gmail one, because they are not the same
|
|
|
|
|
thing wearing different clothes — one is an app you sign in through, the other
|
|
|
|
|
is a password you hold. -->
|
2026-09-12 09:56:00 +02:00
|
|
|
<section class="panel" id="postkasser">
|
2026-08-18 22:22:31 +02:00
|
|
|
<header>
|
|
|
|
|
<h2>{t('int.mailboxes')}</h2>
|
|
|
|
|
<p class="hint">{t('int.mailboxesHint')}</p>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
{#if mailboxes.length}
|
|
|
|
|
<ul class="mailboxes">
|
|
|
|
|
{#each mailboxes as box (box.id)}
|
|
|
|
|
<li>
|
|
|
|
|
<div class="what">
|
|
|
|
|
<strong>{box.name}</strong>
|
|
|
|
|
<span class="hint">{box.username} · {box.host}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="ghost" onclick={() => syncMailbox(box)} disabled={syncingId === box.id}>
|
|
|
|
|
{syncingId === box.id ? t('int.fetching') : t('int.fetchNow')}
|
|
|
|
|
</button>
|
|
|
|
|
<button class="ghost remove" onclick={() => removeMailbox(box)}>
|
|
|
|
|
{t('int.disconnect')}
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
{/each}
|
|
|
|
|
</ul>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
{#if addingMailbox}
|
|
|
|
|
<form class="add-mailbox" onsubmit={addMailbox}>
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label for="mb-host">{t('int.host')}</label>
|
|
|
|
|
<input id="mb-host" bind:value={newMailbox.host} placeholder="imap.mail.me.com:993" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label for="mb-user">{t('int.username')}</label>
|
|
|
|
|
<input id="mb-user" bind:value={newMailbox.username} autocomplete="off" />
|
|
|
|
|
</div>
|
|
|
|
|
<div class="field">
|
|
|
|
|
<label for="mb-pass">{t('int.appPassword')}</label>
|
|
|
|
|
<input id="mb-pass" type="password" bind:value={newMailbox.password} autocomplete="off" />
|
|
|
|
|
<p class="hint">{t('int.appPasswordHint')}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<button type="submit" class="primary" disabled={connecting}>
|
|
|
|
|
{connecting ? t('int.connecting') : t('int.connect')}
|
|
|
|
|
</button>
|
|
|
|
|
<button type="button" class="ghost" onclick={() => (addingMailbox = false)}>
|
|
|
|
|
{t('account.cancel')}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
{:else}
|
|
|
|
|
<div class="row">
|
|
|
|
|
<button class="ghost" onclick={() => (addingMailbox = true)}>{t('int.addMailbox')}</button>
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-08-17 10:39:46 +02:00
|
|
|
<style>
|
2026-08-18 09:05:18 +02:00
|
|
|
/* Folded away once it is set: it is a one-off registration, and a panel that
|
|
|
|
|
keeps showing its own setup instructions after setup reads as unfinished. */
|
|
|
|
|
.setup {
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
padding: var(--s3);
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--s3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.setup summary {
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-12 09:18:49 +02:00
|
|
|
.setup output {
|
2026-08-18 09:05:18 +02:00
|
|
|
font-family: var(--font-mono);
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 10:39:46 +02:00
|
|
|
form {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--s4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.callback {
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: var(--s3);
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
border: 1px solid;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.callback[data-tone='ok'] {
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
background: var(--accent-sunken);
|
|
|
|
|
border-color: var(--accent);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.callback[data-tone='bad'] {
|
|
|
|
|
color: var(--danger);
|
|
|
|
|
background: var(--danger-sunken);
|
|
|
|
|
border-color: var(--danger);
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 07:22:25 +02:00
|
|
|
fieldset {
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
padding: var(--s3);
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--s2);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
legend {
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
padding: 0 var(--s1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.pick {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
gap: var(--s2);
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* A solid mark beside a label that carries the meaning, so it only has to be
|
|
|
|
|
tellable from its neighbours and visible on both near-black and white — the
|
|
|
|
|
same job the project colours' dots do. */
|
|
|
|
|
.swatch {
|
|
|
|
|
width: 10px;
|
|
|
|
|
height: 10px;
|
|
|
|
|
border-radius: var(--radius-full);
|
|
|
|
|
flex: none;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-17 10:39:46 +02:00
|
|
|
a {
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
}
|
2026-09-12 09:11:26 +02:00
|
|
|
|
|
|
|
|
/* The bullet list this was is why the page scrolled sideways on a phone. A
|
|
|
|
|
subscription address is one word with nothing to break at, so it cannot wrap
|
|
|
|
|
on its own — and the widest element on a page sets the width of the page, not
|
|
|
|
|
of its own line. Hence `anywhere`, which breaks mid-word when there is no
|
|
|
|
|
other opportunity, plus `min-width: 0` so the flex item is allowed to be
|
|
|
|
|
narrower than its content in the first place. */
|
|
|
|
|
.subs-list {
|
|
|
|
|
list-style: none;
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 0;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: var(--s3);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.subs-list li {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
gap: var(--s1);
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.subs-list .url {
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
color: var(--ink-muted);
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.subs-list button {
|
|
|
|
|
margin-top: var(--s1);
|
|
|
|
|
}
|
2026-08-17 10:39:46 +02:00
|
|
|
</style>
|