merge: Google Kalender som menupunkt, med Verdandes egen kalender lagt over
Release / Build and publish image (push) Failing after 9m58s
Release / Build and publish image (push) Failing after 9m58s
Læs bredt, skriv snævert — decision 5 i planen, og kun den første halvdel: kalenderen læses, og der skrives ingenting tilbage. **OAuth-blokeringen er reel og blev ikke gået udenom.** En klient registreret som *Internal* tager kun konti i sit eget workspace — også til kalendere, ikke kun Gmail. Andreas' private konto bliver afvist med `org_internal`, og det er klienten, der virker efter hensigten, ikke en fejl. Fladen siger det nu med ord, man kan handle på, frem for at vise Googles egen skærm, der ikke gør. Vejen udenom, som ikke er bygget: kalenderens egen hemmelige ICS-adresse. Ingen OAuth, virker for enhver konto, og `internal/ics` findes allerede. Det er det næste stykke, og det eneste, der låser den private konto op. `internal/google` er flyttet ud af `internal/gmail`, fordi de to er det samme flow, der er uenige om én streng. Egen callback-adresse frem for at dele Gmails: én callback, der finder ud af hvilken token-butik den skal skrive i, går galt ved at overskrive en Gmail-forbindelse, der virkede. Egne tabeller frem for `mailboxes`. `trigger_kind`, `seen` og `last_uid` svarer alle på "hvad gør en besked til en opgave", og ingenting her bliver til en opgave; én konto har mange kalendere, og `mailboxes` har ikke plads til en en-til-mange. Afgørende i praksis: `mailboxes.kind` har en CHECK, og SQLite kan ikke ændre en — en tredje værdi koster den tabelombygning, som migration 0021 skulle igennem. Begivenheder erstattes pr. kalender pr. vindue frem for at flettes med et sync-token: et vindue, der slettes og skrives forfra, kan ikke komme ud af trit, og et sync-token er endnu en ting, der skal være rigtig for altid, for at gitteret ikke stille viser noget forkert. `CalendarView` er udvidet, ikke kopieret. En begivenhed er et fyldt bånd, hvor en opgave er et kort med en farvet kant, så de to læses forskelligt før et ord er læst — og den kan ikke trækkes og er aldrig en knap: der er ikke noget at gøre færdigt ved en andens møde. To værn på det, der kommer fra Google: farven skal matche et hex-mønster, før den må stå i en style, og linket bliver kun til et href, hvis det begynder med https. Konflikten i jobs.go var to nye jobs skrevet på samme linje. Begge skal med. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,10 @@ const MESSAGES = {
|
||||
// Same shape for a mailbox read over IMAP: quoted when the host said something,
|
||||
// this when it did not.
|
||||
mailbox_failed: 'error.mailboxRefused',
|
||||
// And for a calendar read from Google. Its own code, because the sentence this
|
||||
// is wrapped in names where it came from — and "Gmail said no" about a calendar
|
||||
// sends somebody to the wrong panel.
|
||||
calendar_failed: 'error.calendarRefused',
|
||||
last_admin: 'error.lastAdmin'
|
||||
};
|
||||
|
||||
@@ -84,6 +88,9 @@ export function humanMessage(err) {
|
||||
if (err.code === 'gmail_failed' && err.message) {
|
||||
return t('error.gmailSaid', { what: err.message });
|
||||
}
|
||||
if (err.code === 'calendar_failed' && err.message) {
|
||||
return t('error.calendarSaid', { what: err.message });
|
||||
}
|
||||
|
||||
const key = MESSAGES[err.code];
|
||||
if (key) return t(key);
|
||||
@@ -365,6 +372,16 @@ export const api = {
|
||||
authorizeGmail: () => post('/gmail/authorize'),
|
||||
syncGmail: () => post('/gmail/sync'),
|
||||
|
||||
// The Google Calendar connection. Read-only: there is nothing here that writes
|
||||
// an event, only which calendars to look at.
|
||||
getCalendar: () => get('/calendar'),
|
||||
setCalendars: (shown) => put('/calendar/calendars', { shown }),
|
||||
disconnectCalendar: () => del('/calendar'),
|
||||
authorizeCalendar: () => post('/calendar/authorize'),
|
||||
syncCalendar: () => post('/calendar/sync'),
|
||||
calendarEvents: (from, to) =>
|
||||
get(`/calendar/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`),
|
||||
|
||||
// Notes. Listing and searching are one endpoint because they answer the same
|
||||
// question with different words.
|
||||
notes: (params = {}) => {
|
||||
|
||||
@@ -49,7 +49,19 @@
|
||||
* flight swallows the click that would page. A week that straddles the
|
||||
* 31st and the 1st has both days in the same row.
|
||||
*/
|
||||
span = 'month'
|
||||
span = 'month',
|
||||
/**
|
||||
* Events read from somewhere else, laid over the same grid.
|
||||
*
|
||||
* An event is not a task, and the cell has to say so. It has a span of time
|
||||
* rather than a day, it cannot be ticked off, and — while the connection is
|
||||
* read-only — it cannot be moved: verdande holds a copy of somebody else's
|
||||
* calendar, and a chip that lets itself be dragged is a promise the server
|
||||
* has no way to keep.
|
||||
*
|
||||
* Empty everywhere but Kalender, so no other view pays for it.
|
||||
*/
|
||||
events = []
|
||||
} = $props();
|
||||
|
||||
let cursor = $state(span === 'week' ? startOfWeek(new Date()) : startOfMonth(new Date()));
|
||||
@@ -137,6 +149,66 @@
|
||||
(!projectId || t.project_id === projectId)
|
||||
);
|
||||
|
||||
/**
|
||||
* Which cells an event covers, rather than where it starts. A trip from Friday
|
||||
* to Monday is in all four, and asking only about the start day would draw it
|
||||
* once and leave three days looking clear.
|
||||
*
|
||||
* Ordered here rather than trusted from the caller. The server does sort, but a
|
||||
* cell that shows 14:00 above 09:15 does not look like a list in the wrong
|
||||
* order — it looks like the clock on the chip is wrong, which is a far worse
|
||||
* thing to have to rule out. All-day first, because an all-day event is a band
|
||||
* over the whole day rather than something that happens at a point in it.
|
||||
*/
|
||||
const eventsOn = (date) => {
|
||||
const day = iso(date);
|
||||
return events
|
||||
.filter((e) => e.start_day <= day && e.end_day >= day)
|
||||
.toSorted((a, b) => {
|
||||
if (a.all_day !== b.all_day) return a.all_day ? -1 : 1;
|
||||
return (a.starts_at ?? '').localeCompare(b.starts_at ?? '');
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* The time on the chip, and only where it means something.
|
||||
*
|
||||
* An all-day event has no time. A timed one shows its start, and only on the
|
||||
* day it starts: a meeting that runs past midnight would otherwise claim to
|
||||
* begin at 20:00 on both days.
|
||||
*
|
||||
* The clock is read out of the string rather than parsed into a Date. Google
|
||||
* writes the event's own offset into it, and handing that to the browser asks
|
||||
* *this device* what time it is — so a phone in another time zone would show a
|
||||
* Copenhagen meeting an hour out.
|
||||
*/
|
||||
function timeOn(event, date) {
|
||||
if (event.all_day || !event.starts_at) return '';
|
||||
if (event.start_day !== iso(date)) return '';
|
||||
return event.starts_at.slice(11, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* The calendar's own colour, or nothing.
|
||||
*
|
||||
* Checked rather than trusted, even though it comes from Google: this goes into
|
||||
* an inline `style`, and a value that is not a colour is a value that can close
|
||||
* the declaration and open another. A hex triple is the whole of what Google
|
||||
* sends, so anything else is not a colour that failed to render — it is a
|
||||
* string that has no business being here.
|
||||
*/
|
||||
const HEX = /^#[0-9a-f]{3,8}$/i;
|
||||
const swatch = (colour) => (HEX.test(colour ?? '') ? colour : '');
|
||||
|
||||
/**
|
||||
* Whether the event links back to Google.
|
||||
*
|
||||
* `https:` and nothing else. An href is where a `javascript:` URL becomes a
|
||||
* click that runs, and the address in this field was written by a service, not
|
||||
* by the person looking at it.
|
||||
*/
|
||||
const linkOf = (event) => (String(event.url ?? '').startsWith('https://') ? event.url : '');
|
||||
|
||||
const monthName = $derived(
|
||||
cursor.toLocaleDateString(tag(), { month: 'long', year: 'numeric' })
|
||||
);
|
||||
@@ -232,7 +304,14 @@
|
||||
|
||||
<div class="grid">
|
||||
{#each grid as date (date.toISOString())}
|
||||
{@const tasks = tasksOn(date)}
|
||||
{@const dayEvents = eventsOn(date)}
|
||||
<!-- Events first, then whatever room is left goes to tasks. An event is
|
||||
fixed to a time somebody else chose; a task is only dated, and if
|
||||
something in a full cell has to fall behind "+3 mere", it should be
|
||||
the one that can be moved. -->
|
||||
{@const tasks = tasksOn(date).slice(0, Math.max(0, chipLimit - dayEvents.length))}
|
||||
{@const hidden =
|
||||
tasksOn(date).length - tasks.length + Math.max(0, dayEvents.length - chipLimit)}
|
||||
<!-- A week has no outside: every day in it is the week you asked for. -->
|
||||
{@const outside = span !== 'week' && date.getMonth() !== cursor.getMonth()}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -254,7 +333,36 @@
|
||||
{/if}
|
||||
<span class="number">{date.getDate()}</span>
|
||||
|
||||
{#each tasks.slice(0, chipLimit) as task (task.id)}
|
||||
{#each dayEvents.slice(0, chipLimit) as event (event.id)}
|
||||
{@const link = linkOf(event)}
|
||||
{@const label = t('cal.eventCannotMove', {
|
||||
name: event.summary,
|
||||
calendar: event.calendar_name || t('cal.title')
|
||||
})}
|
||||
<!-- Not draggable, and it says so twice: the attribute stops the
|
||||
browser dragging an anchor of its own accord, and the title says
|
||||
why to anybody who tries. The drop targets would refuse it anyway
|
||||
— they only accept the task MIME type — but a chip that lifts off
|
||||
the page and then will not land reads as a bug rather than as a
|
||||
rule. It is also not a button: there is nothing here to complete,
|
||||
and a checkbox next to a meeting is an offer verdande cannot keep. -->
|
||||
<svelte:element
|
||||
this={link ? 'a' : 'span'}
|
||||
class="event"
|
||||
class:allday={event.all_day}
|
||||
draggable="false"
|
||||
href={link || undefined}
|
||||
target={link ? '_blank' : undefined}
|
||||
rel={link ? 'noreferrer noopener' : undefined}
|
||||
title={link ? `${label} — ${t('cal.openInGoogle')}` : label}
|
||||
style={swatch(event.colour) ? `--event-colour: ${swatch(event.colour)}` : undefined}
|
||||
>
|
||||
{#if timeOn(event, date)}<span class="at">{timeOn(event, date)}</span>{/if}
|
||||
{event.summary}
|
||||
</svelte:element>
|
||||
{/each}
|
||||
|
||||
{#each tasks as task (task.id)}
|
||||
<!-- Draggable as well as clickable: within a month, moving something
|
||||
to another day is the whole reason to be looking at a month. The
|
||||
browser needs a few pixels of movement before it calls a press a
|
||||
@@ -271,8 +379,8 @@
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if tasks.length > chipLimit}
|
||||
<span class="more">{t('view.more', { n: tasks.length - chipLimit })}</span>
|
||||
{#if hidden > 0}
|
||||
<span class="more">{t('view.more', { n: hidden })}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -451,6 +559,58 @@
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
/* An event reads as a different kind of thing at a glance, before any of the
|
||||
words are read: a solid bar of colour where a task is a card with a coloured
|
||||
edge. That is the whole distinction the eye needs — a task is something you
|
||||
can move and finish, an event is something that is simply happening.
|
||||
|
||||
The colour is Google's own for that calendar, so two calendars in one grid
|
||||
are told apart by the colour the person already knows them by. `--line-strong`
|
||||
is the fallback for a calendar Google gave no colour, which is rare and not
|
||||
worth a second decision. */
|
||||
.event {
|
||||
--event-colour: var(--line-strong);
|
||||
font-size: var(--text-xs);
|
||||
text-align: left;
|
||||
display: block;
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour);
|
||||
background: var(--surface-sunken);
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
/* A drag that cannot land is worse than one that cannot start. The grab
|
||||
cursor is the promise; this withholds it. */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.event[href] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event:hover {
|
||||
background: var(--surface);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* An all-day event has no clock to show, so the colour carries it instead:
|
||||
filled rather than merely edged, which is what an all-day band looks like in
|
||||
every calendar anybody has used. */
|
||||
.event.allday {
|
||||
border-left: none;
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--event-colour) 45%, transparent);
|
||||
}
|
||||
|
||||
.at {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ink-faint);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.more {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
@@ -482,6 +642,29 @@
|
||||
background: var(--p3);
|
||||
}
|
||||
|
||||
/* Counted rather than read, like the task chips beside it — a 45px column
|
||||
fits no title. A bar rather than a dot, so an event is still tellable from
|
||||
a task at a glance: what the month grid says on a phone is "how much is
|
||||
on this day, and how much of it is somebody else's". */
|
||||
.event {
|
||||
font-size: 0;
|
||||
padding: 0;
|
||||
height: 4px;
|
||||
border-radius: 1px;
|
||||
border-left: none;
|
||||
box-shadow: none;
|
||||
background: var(--event-colour);
|
||||
}
|
||||
|
||||
.event.allday {
|
||||
background: var(--event-colour);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.at {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.more {
|
||||
font-size: 10px;
|
||||
}
|
||||
@@ -536,6 +719,27 @@
|
||||
background: var(--surface-raised);
|
||||
}
|
||||
|
||||
/* Readable again in the week, for the reason the task chips are: a
|
||||
full-width row has room for a title, and a week somebody opened on
|
||||
purpose is one they want to read rather than count. */
|
||||
.calendar.week .event {
|
||||
font-size: var(--text-xs);
|
||||
height: auto;
|
||||
padding: 1px var(--s1);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--event-colour);
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.calendar.week .event.allday {
|
||||
border-left: none;
|
||||
background: color-mix(in oklab, var(--event-colour) 22%, var(--ground));
|
||||
}
|
||||
|
||||
.calendar.week .at {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.calendar.week .more {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
@@ -145,7 +145,16 @@
|
||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||
// eksisterende en anden.
|
||||
{ key: 'notes', href: '/noter', label: 'notes.title' }
|
||||
{ key: 'notes', href: '/noter', label: 'notes.title' },
|
||||
// Kalenderen bærer et gitter, af samme grund som Noter bærer et ark: den
|
||||
// er ikke endnu et filter over de samme opgaver. Den er de samme opgaver
|
||||
// med noget andet lagt over — Googles begivenheder — og en prik ville
|
||||
// sige, at den hørte til blandt I dag og Kommende.
|
||||
//
|
||||
// Sidst i rækken, fordi `navOrder` føjer en ukendt nøgle til bagest: står
|
||||
// den et andet sted her, ser en ny konto én rækkefølge og alle
|
||||
// eksisterende en anden.
|
||||
{ key: 'calendar', href: '/kalender', label: 'nav.calendar' }
|
||||
].filter(Boolean)
|
||||
);
|
||||
|
||||
@@ -488,6 +497,26 @@
|
||||
/>
|
||||
<path d="M9 1.7V5h3.3" fill="none" stroke="currentColor" stroke-width="1.4" />
|
||||
</svg>
|
||||
{:else if item.key === 'calendar'}
|
||||
<svg class="mark" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<rect
|
||||
x="2.2"
|
||||
y="3.2"
|
||||
width="11.6"
|
||||
height="10.6"
|
||||
rx="1.4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
/>
|
||||
<path
|
||||
d="M2.2 6.4h11.6M5.6 2.2v2M10.4 2.2v2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.4"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<span class="dot" class:today={item.key === 'today'} aria-hidden="true"></span>
|
||||
{/if}
|
||||
|
||||
@@ -80,6 +80,7 @@ export const da = {
|
||||
'nav.upcoming': 'Kommende',
|
||||
'nav.delegated': 'Venter på andre',
|
||||
'nav.inbox': 'Indbakke',
|
||||
'nav.calendar': 'Kalender',
|
||||
'nav.settings': 'Indstillinger',
|
||||
'nav.signOut': 'Log ud',
|
||||
'nav.main': 'Hovedmenu',
|
||||
@@ -593,7 +594,7 @@ export const da = {
|
||||
'Klienten er sat i miljøet (VERDANDE_GMAIL_CLIENT_ID), så den kan ikke ændres her. Det er med vilje: en værdi i runens manifest er en beslutning, der bliver anvendt igen ved hver genstart, og en formular, der kunne overskrive den, ville gøre manifestet til en løgn.',
|
||||
'int.oauthClient': 'OAuth-klient hos Google',
|
||||
'int.registerHint':
|
||||
'Registrér en klient i Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, slå Gmail API til, og indsæt de to værdier her. Din egen konto skal stå som testbruger, så længe appen ikke er verificeret hos Google.',
|
||||
'Registrér en klient i Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, og indsæt de to værdier her. Den samme klient bruges til både Gmail og Google Kalender: slå det API til, du skal bruge, og tilføj begge redirect-URI\'er — den nedenfor og kalenderens, som står i panelet Google Kalender. Din egen konto skal stå som testbruger, så længe appen ikke er verificeret hos Google.',
|
||||
'int.redirectURI': 'Godkendt redirect-URI',
|
||||
'int.clientID': 'Klient-id',
|
||||
'int.clientSecret': 'Klienthemmelighed',
|
||||
@@ -633,6 +634,32 @@ export const da = {
|
||||
'int.server': 'Server',
|
||||
'int.caldavAuth':
|
||||
'Brugernavn er din e-mail. Adgangskoden er en API-token — ikke din rigtige adgangskode.',
|
||||
'int.calendar': 'Google Kalender',
|
||||
'int.calendarHint':
|
||||
'Læses, aldrig skrives. Begivenhederne vises i Kalender ved siden af dine egne opgaver med forfaldsdato — de kan ikke flyttes eller lukkes herfra, for de hører til i Google.',
|
||||
'int.connectCalendar': 'Forbind Google Kalender',
|
||||
'int.calendarConnectedAs': 'Forbundet som',
|
||||
'int.calendarSameClient':
|
||||
'Bruger den samme OAuth-klient som Gmail ovenfor. Google Calendar API skal være slået til, og denne adresse skal stå som godkendt redirect-URI.',
|
||||
'int.whichCalendars': 'Hvilke kalendere vises',
|
||||
'int.noCalendars': 'Kontoen har ingen kalendere at vise.',
|
||||
'int.calendarLastSync': 'Sidst hentet {when}.',
|
||||
'int.calendarNeverSynced': 'Ikke hentet endnu.',
|
||||
'int.calendarFetchedOne': '{n} begivenhed hentet.',
|
||||
'int.calendarFetchedMany': '{n} begivenheder hentet.',
|
||||
'int.calendarNothingNew': 'Ingen begivenheder i vinduet.',
|
||||
'int.calendarConnected': 'Google Kalender er forbundet.',
|
||||
'int.calendarState':
|
||||
'Svaret fra Google passede ikke til det forsøg, der blev startet her. Prøv igen.',
|
||||
'int.calendarExpired': 'Forsøget udløb undervejs. Prøv igen.',
|
||||
'int.calendarInvalid': 'Svaret fra Google kunne ikke læses. Prøv igen.',
|
||||
'int.calendarFailed': 'Google afviste ombytningen af koden.',
|
||||
'int.calendarNoRefresh':
|
||||
'Google sendte ingen refresh-token. Fjern verdande under din Google-konto og forbind igen.',
|
||||
'int.calendarDenied': 'Adgangen blev afvist hos Google.',
|
||||
'int.calendarOrgInternal':
|
||||
'Google-appen er registreret som Internal og tager kun imod konti i organisationen. En privat konto kan ikke godkende den, uanset hvad der står på skærmen.',
|
||||
'int.calendarOther': 'Google svarede: {what}',
|
||||
'int.gmailFetchedOne': '{n} opgave hentet.',
|
||||
'int.gmailFetchedMany': '{n} opgaver hentet.',
|
||||
'int.gmailNothingNew': 'Ingen nye beskeder.',
|
||||
@@ -647,6 +674,18 @@ export const da = {
|
||||
'int.gmailDenied': 'Adgangen blev afvist hos Google.',
|
||||
'int.gmailOther': 'Gmail svarede: {what}',
|
||||
|
||||
// --- kalenderen, med Googles begivenheder lagt over ---------------------------------
|
||||
'cal.title': 'Kalender',
|
||||
'cal.notConnected':
|
||||
'Ingen kalender er forbundet endnu. Det gøres under Indstillinger → Integrationer.',
|
||||
'cal.settings': 'Indstillinger',
|
||||
'cal.noneChosen': 'Ingen af kontoens kalendere er valgt endnu.',
|
||||
'cal.allDay': 'Hele dagen',
|
||||
'cal.openInGoogle': 'Åbn i Google Kalender',
|
||||
'cal.beyondWindow':
|
||||
'Begivenheder gemmes fra {from} til {to}. Uden for det viser gitteret kun dine egne opgaver.',
|
||||
'cal.eventCannotMove': '{name} — en begivenhed i {calendar}. Den kan ikke flyttes herfra.',
|
||||
|
||||
// --- a project ----------------------------------------------------------------------
|
||||
'project.name': 'Projektets navn',
|
||||
'project.clickToRename': 'Klik for at omdøbe',
|
||||
@@ -789,6 +828,10 @@ export const da = {
|
||||
'Denne instans kan ikke genstarte sig selv: {missing} er ikke sat. En container kan ikke udskifte sit eget image — panelet gør arbejdet, og det her er kun måden at bede om det.',
|
||||
|
||||
'error.gmailSaid': 'Gmail svarede: {what}',
|
||||
'error.calendarRefused': 'Google afviste kaldet. Prøv at forbinde kalenderen igen.',
|
||||
'error.calendarSaid': 'Google svarede: {what}',
|
||||
'int.disconnectCalendarQuestion':
|
||||
'Afbryd forbindelsen til Google Kalender? Tokens bliver glemt, og den hentede kopi af begivenhederne bliver slettet. Dine egne opgaver rører den ikke.',
|
||||
|
||||
'error.mailboxRefused': 'Postkassen kunne ikke læses.',
|
||||
'error.mailboxSaid': 'Postkassen svarede: {what}',
|
||||
|
||||
@@ -76,6 +76,7 @@ export const en = {
|
||||
'nav.upcoming': 'Upcoming',
|
||||
'nav.delegated': 'Waiting on others',
|
||||
'nav.inbox': 'Inbox',
|
||||
'nav.calendar': 'Calendar',
|
||||
'nav.settings': 'Settings',
|
||||
'nav.signOut': 'Sign out',
|
||||
'nav.main': 'Main menu',
|
||||
@@ -589,7 +590,7 @@ export const en = {
|
||||
'The client is set in the environment (VERDANDE_GMAIL_CLIENT_ID), so it cannot be changed here. That is deliberate: a value in the Rune’s manifest is a decision reapplied on every restart, and a form that could overwrite it would make the manifest a lie.',
|
||||
'int.oauthClient': 'OAuth client at Google',
|
||||
'int.registerHint':
|
||||
'Register a client in Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application, enable the Gmail API, and paste the two values here. Your own account has to be listed as a test user for as long as the app is unverified with Google.',
|
||||
'Register a client in Google Cloud Console → APIs & Services → Credentials → OAuth client ID → Web application and paste the two values here. The same client serves both Gmail and Google Calendar: enable whichever API you need, and add both redirect URIs — the one below and the calendar\'s, which is shown in the Google Calendar panel. Your own account has to be listed as a test user for as long as the app is unverified with Google.',
|
||||
'int.redirectURI': 'Authorised redirect URI',
|
||||
'int.clientID': 'Client ID',
|
||||
'int.clientSecret': 'Client secret',
|
||||
@@ -629,6 +630,31 @@ export const en = {
|
||||
'int.server': 'Server',
|
||||
'int.caldavAuth':
|
||||
'The username is your email. The password is an API token — not your real password.',
|
||||
'int.calendar': 'Google Calendar',
|
||||
'int.calendarHint':
|
||||
'Read, never written. The events appear in Calendar beside your own tasks with due dates — they cannot be moved or completed from here, because they belong to Google.',
|
||||
'int.connectCalendar': 'Connect Google Calendar',
|
||||
'int.calendarConnectedAs': 'Connected as',
|
||||
'int.calendarSameClient':
|
||||
'Uses the same OAuth client as Gmail above. The Google Calendar API has to be enabled, and this address has to be listed as an authorised redirect URI.',
|
||||
'int.whichCalendars': 'Which calendars are shown',
|
||||
'int.noCalendars': 'The account has no calendars to show.',
|
||||
'int.calendarLastSync': 'Last fetched {when}.',
|
||||
'int.calendarNeverSynced': 'Not fetched yet.',
|
||||
'int.calendarFetchedOne': '{n} event fetched.',
|
||||
'int.calendarFetchedMany': '{n} events fetched.',
|
||||
'int.calendarNothingNew': 'No events in the window.',
|
||||
'int.calendarConnected': 'Google Calendar is connected.',
|
||||
'int.calendarState': 'Google’s reply did not match the attempt that started here. Try again.',
|
||||
'int.calendarExpired': 'The attempt expired on the way. Try again.',
|
||||
'int.calendarInvalid': 'Google’s reply could not be read. Try again.',
|
||||
'int.calendarFailed': 'Google refused to exchange the code.',
|
||||
'int.calendarNoRefresh':
|
||||
'Google sent no refresh token. Remove verdande from your Google account and connect again.',
|
||||
'int.calendarDenied': 'Access was refused at Google.',
|
||||
'int.calendarOrgInternal':
|
||||
'The Google app is registered as Internal and only accepts accounts inside the organisation. A private account cannot consent to it, whatever the screen says.',
|
||||
'int.calendarOther': 'Google replied: {what}',
|
||||
'int.gmailFetchedOne': '{n} task fetched.',
|
||||
'int.gmailFetchedMany': '{n} tasks fetched.',
|
||||
'int.gmailNothingNew': 'No new messages.',
|
||||
@@ -642,6 +668,17 @@ export const en = {
|
||||
'int.gmailDenied': 'Access was refused at Google.',
|
||||
'int.gmailOther': 'Gmail replied: {what}',
|
||||
|
||||
// --- the calendar, with Google’s events laid over ------------------------------------
|
||||
'cal.title': 'Calendar',
|
||||
'cal.notConnected': 'No calendar is connected yet. That is done under Settings → Integrations.',
|
||||
'cal.settings': 'Settings',
|
||||
'cal.noneChosen': 'None of the account’s calendars are chosen yet.',
|
||||
'cal.allDay': 'All day',
|
||||
'cal.openInGoogle': 'Open in Google Calendar',
|
||||
'cal.beyondWindow':
|
||||
'Events are kept from {from} to {to}. Outside that the grid shows only your own tasks.',
|
||||
'cal.eventCannotMove': '{name} — an event in {calendar}. It cannot be moved from here.',
|
||||
|
||||
// --- a project ----------------------------------------------------------------------
|
||||
'project.name': 'The project’s name',
|
||||
'project.clickToRename': 'Click to rename',
|
||||
@@ -784,6 +821,10 @@ export const en = {
|
||||
'This instance cannot restart itself: {missing} are not set. A container cannot replace its own image — the panel does the work, and this is only the way to ask.',
|
||||
|
||||
'error.gmailSaid': 'Gmail said: {what}',
|
||||
'error.calendarRefused': 'Google refused the call. Try connecting the calendar again.',
|
||||
'error.calendarSaid': 'Google replied: {what}',
|
||||
'int.disconnectCalendarQuestion':
|
||||
'Disconnect Google Calendar? The tokens are forgotten and the fetched copy of the events is deleted. Your own tasks are untouched.',
|
||||
|
||||
'error.mailboxRefused': 'The mailbox could not be read.',
|
||||
'error.mailboxSaid': 'The mailbox answered: {what}',
|
||||
|
||||
@@ -835,6 +835,43 @@ function read() {
|
||||
|
||||
export const upcomingView = new UpcomingView();
|
||||
|
||||
/**
|
||||
* Month or week on Kalender.
|
||||
*
|
||||
* Its own key rather than Kommende's, even though the two switchers look alike.
|
||||
* They are not the same choice: Kommende opens on a list of the next seven days
|
||||
* and offers a grid, and Kalender is the grid — sharing the value would mean
|
||||
* choosing a week on one and finding the other had changed, and one of them would
|
||||
* have to answer for the `list` mode the other does not have.
|
||||
*
|
||||
* In localStorage rather than on the account, and for the same reason as the
|
||||
* sidebar's width: a grid of seven columns needs room, and the answer on a phone
|
||||
* is not the answer on a wide monitor.
|
||||
*/
|
||||
const CALENDAR_MODES = ['week', 'month'];
|
||||
|
||||
class CalendarViewMode {
|
||||
mode = $state(readCalendarMode());
|
||||
|
||||
set(next) {
|
||||
if (!CALENDAR_MODES.includes(next)) return;
|
||||
this.mode = next;
|
||||
try {
|
||||
localStorage.setItem('verdande:calendar', next);
|
||||
} catch {
|
||||
// Private browsing; the choice simply will not persist.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readCalendarMode() {
|
||||
if (typeof localStorage === 'undefined') return 'month';
|
||||
const stored = localStorage.getItem('verdande:calendar');
|
||||
return CALENDAR_MODES.includes(stored) ? stored : 'month';
|
||||
}
|
||||
|
||||
export const calendarView = new CalendarViewMode();
|
||||
|
||||
/**
|
||||
* Whether the lists show what has already been finished.
|
||||
*
|
||||
|
||||
@@ -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,120 @@
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
calendarsSaved = true;
|
||||
// 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();
|
||||
}
|
||||
} 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 +469,87 @@
|
||||
{/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} />
|
||||
<p class="hint">{t('int.calendarSameClient')}</p>
|
||||
</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 +724,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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script>
|
||||
/**
|
||||
* Kalender: Google's events and verdande's own dated tasks in one grid.
|
||||
*
|
||||
* The same component Kommende uses, given a second list to draw. Not a second
|
||||
* grid, and the reason is the part that is easy to get subtly wrong: a drop
|
||||
* target has to decide from `dataTransfer.types` alone whether it accepts a
|
||||
* drag, and a second copy of that is a second place for it to be almost right.
|
||||
* A task dragged here reschedules exactly as it does everywhere else, because
|
||||
* it is the same code doing it.
|
||||
*
|
||||
* A month and a week, and no list. The list is Kommende's answer — seven days
|
||||
* of work, in order — and this view is the other question: what does the day
|
||||
* already have in it.
|
||||
*/
|
||||
import { api, humanMessage } from '$lib/api.js';
|
||||
import { app, calendarView } from '$lib/stores.svelte.js';
|
||||
import CalendarView from '$lib/components/CalendarView.svelte';
|
||||
import { t } from '$lib/i18n.svelte.js';
|
||||
|
||||
let events = $state([]);
|
||||
/** Null until the first answer, so nothing flashes before it is known. */
|
||||
let window_ = $state(null);
|
||||
let connected = $state(null);
|
||||
/** Whether any calendar in the connected account is ticked. */
|
||||
let anyShown = $state(false);
|
||||
/** The dates the grid is showing, so the notice below can compare against them. */
|
||||
let showing = $state(null);
|
||||
|
||||
$effect(() => {
|
||||
api
|
||||
.getCalendar()
|
||||
.then((status) => {
|
||||
connected = status.connected;
|
||||
anyShown = (status.calendars ?? []).some((c) => c.shown);
|
||||
})
|
||||
.catch(() => {
|
||||
// A calendar that could not be asked about is not a reason to withhold
|
||||
// the grid: the tasks in it are verdande's own and do not depend on
|
||||
// Google being reachable.
|
||||
connected = false;
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Loads the dates a grid is showing, whichever grid it is.
|
||||
*
|
||||
* Both halves at once, because they answer the same question about the same
|
||||
* cells. The limit on the tasks is raised well above the default for the reason
|
||||
* Kommende raises it: a busy month across every project is more than two hundred
|
||||
* rows, and a silently truncated grid is a calendar that quietly lies about a
|
||||
* day being clear.
|
||||
*/
|
||||
function loadRange({ from, to }) {
|
||||
showing = { from, to };
|
||||
app.loadTasks({ due_from: from, due_before: to, limit: 500 });
|
||||
api
|
||||
.calendarEvents(from, to)
|
||||
.then((data) => {
|
||||
events = data.events;
|
||||
window_ = { from: data.from, to: data.to };
|
||||
})
|
||||
.catch((e) => {
|
||||
// The grid still draws, with the tasks in it. Saying so once beats a
|
||||
// view that renders half of itself and explains nothing.
|
||||
events = [];
|
||||
app.toast(humanMessage(e));
|
||||
});
|
||||
}
|
||||
|
||||
// Whether the grid has been paged past the window verdande keeps a copy of.
|
||||
//
|
||||
// Said out loud rather than left as an empty month. A calendar showing no events
|
||||
// looks exactly like a calendar with no events in it, and the difference between
|
||||
// "nothing is booked" and "I have not looked that far" is the whole reason
|
||||
// somebody opened this.
|
||||
let beyond = $derived(
|
||||
connected &&
|
||||
window_ &&
|
||||
showing &&
|
||||
(showing.from < window_.from || showing.to > window_.to)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="view">
|
||||
<header>
|
||||
<h1>{t('cal.title')}</h1>
|
||||
<div class="views" role="group" aria-label={t('view.mode')}>
|
||||
{#each [['week', t('view.week')], ['month', t('view.month')]] as [value, label]}
|
||||
<button
|
||||
class:active={calendarView.mode === value}
|
||||
onclick={() => calendarView.set(value)}
|
||||
aria-pressed={calendarView.mode === value}>{label}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if connected === false}
|
||||
<!-- Shown once, and quietly. The grid underneath is already useful without a
|
||||
Google account in it — it is verdande's own calendar, which is what this
|
||||
view was before anything was laid over it. -->
|
||||
<p class="hint">
|
||||
{t('cal.notConnected')}
|
||||
<a href="/indstillinger/integrationer">{t('cal.settings')}</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Connected, and nothing ticked. Worth its own sentence rather than an empty
|
||||
grid: "no calendar is chosen" and "nothing is booked" look identical, and
|
||||
only one of them is something to do about. -->
|
||||
{#if connected && !anyShown}
|
||||
<p class="hint">
|
||||
{t('cal.noneChosen')}
|
||||
<a href="/indstillinger/integrationer">{t('cal.settings')}</a>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if beyond}
|
||||
<p class="hint">{t('cal.beyondWindow', { from: window_.from, to: window_.to })}</p>
|
||||
{/if}
|
||||
|
||||
{#if calendarView.mode === 'week'}
|
||||
<CalendarView span="week" {events} onrange={loadRange} />
|
||||
{:else}
|
||||
<CalendarView {events} onrange={loadRange} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* A month grid needs the width a reading column deliberately withholds — the
|
||||
same allowance Kommende and the project page make for theirs. */
|
||||
.view {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: var(--s6) var(--s4) var(--s8);
|
||||
}
|
||||
|
||||
/* Wraps, for the same reason the project header does: a title and a switcher
|
||||
are more than a phone's width, and the switcher belongs underneath rather
|
||||
than squeezing the heading. */
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s3);
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--s5);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--text-2xl);
|
||||
flex: 1 1 8ch;
|
||||
min-width: 8ch;
|
||||
}
|
||||
|
||||
.views {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
flex: none;
|
||||
background: var(--line);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.views button {
|
||||
padding: var(--s1) var(--s3);
|
||||
font-size: var(--text-sm);
|
||||
background: var(--ground);
|
||||
color: var(--ink-muted);
|
||||
transition:
|
||||
background var(--fast) var(--ease),
|
||||
color var(--fast) var(--ease);
|
||||
}
|
||||
|
||||
.views button:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.views button.active {
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 var(--s4);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.hint a {
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user