feat(web): Kalender som menupunkt, med Googles begivenheder lagt over egne opgaver
Samme gitter som Kommende, givet en liste mere at tegne. Ikke et gitter nummer to, og grunden er den del, der er nem at få næsten rigtig: et mål skal afgøre ud fra `dataTransfer.types` alene, om det tager imod, og to kopier af det er to steder at være næsten rigtig. En opgave trukket her flyttes præcis som alle andre steder, fordi det er den samme kode, der gør det. En begivenhed er ikke en opgave, og cellen siger det før et eneste ord er læst: en flade af farve, hvor en opgave er et kort med en farvet kant. Den kan ikke trækkes — forbindelsen er læsende, og en chip, der letter fra siden og så ikke vil lande, læses som en fejl frem for som en regel — og den er ikke en knap, for der er ikke noget at lukke ved en andens møde. Farven er Googles egen for den kalender, så to kalendere i ét gitter kendes fra hinanden på den farve, personen allerede kender dem på. Farven tjekkes frem for at blive troet: den ender i en inline `style`, og en værdi, der ikke er en farve, kan lukke erklæringen og åbne en anden. Linket tegnes kun, hvis det begynder med `https://` — et href er dér, hvor en `javascript:`-adresse bliver til et klik, der kører. Prøven bruger uge-visningen med vilje: en månedscelle skæres af ved tre chips, og resten af suiten lægger opgaver på andre dage. Datoen læses ud af siden, ikke ud af testprocessen, som de øvrige har lært at gøre.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
<script>
|
||||
/** Gmail, the calendar feed, and the mail-to-task address. */
|
||||
/** Gmail, Google Calendar, the calendar feed, and the mail-to-task address. */
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app } from '$lib/stores.svelte.js';
|
||||
import { page } from '$app/stores';
|
||||
import { t, plural } from '$lib/i18n.svelte.js';
|
||||
import { t, plural, tag } from '$lib/i18n.svelte.js';
|
||||
|
||||
// --- Gmail ------------------------------------------------------------------
|
||||
|
||||
@@ -136,6 +136,109 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCalendars(event) {
|
||||
event.preventDefault();
|
||||
savingCalendars = true;
|
||||
calendarsSaved = false;
|
||||
try {
|
||||
await api.setCalendars(shownCalendars);
|
||||
await loadCalendar();
|
||||
calendarsSaved = true;
|
||||
} 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];
|
||||
}
|
||||
|
||||
// --- calendar feed -------------------------------------------------------------
|
||||
|
||||
let feedURL = $state('');
|
||||
@@ -355,6 +458,86 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<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>
|
||||
<input id="calendar-redirect" class="mono" readonly value={calendar.redirect_uri} />
|
||||
</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}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<header>
|
||||
<h2>{t('int.feed')}</h2>
|
||||
@@ -529,6 +712,38 @@
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user