172 lines
5.5 KiB
Svelte
172 lines
5.5 KiB
Svelte
<script lang="ts">
|
|
import CheckIcon from '@lucide/svelte/icons/check';
|
|
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
|
import { page } from '$app/state';
|
|
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
|
import { Button } from '$lib/components/ui/button';
|
|
import * as Card from '$lib/components/ui/card';
|
|
import * as Command from '$lib/components/ui/command';
|
|
import { Input } from '$lib/components/ui/input';
|
|
import { Label } from '$lib/components/ui/label';
|
|
import * as Popover from '$lib/components/ui/popover';
|
|
import { Switch } from '$lib/components/ui/switch';
|
|
import { m } from '$lib/paraglide/messages';
|
|
import {
|
|
listTimezones,
|
|
setNtp,
|
|
setTime,
|
|
setTimezone,
|
|
systemTime
|
|
} from '$lib/remotes/system.remote';
|
|
import { getWhoami } from '$lib/remotes/system.remote';
|
|
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
|
import { toast } from 'svelte-sonner';
|
|
|
|
const machineId = $derived(page.params.machineId!);
|
|
const time = $derived(systemTime(machineId));
|
|
const tzs = $derived(listTimezones(machineId));
|
|
const formId = $props.id();
|
|
const whoami = $derived(getWhoami(machineId));
|
|
const canWrite = $derived(hasPermission('system', 'write', whoami.current?.permissions));
|
|
|
|
let tzOpen = $state(false);
|
|
let saving = $state(false);
|
|
|
|
// ponytail: builds an RFC3339 UTC string from a datetime-local value (treated as local).
|
|
function rfc3339FromLocal(v: string) {
|
|
return new Date(v).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
}
|
|
|
|
async function withSaving<T>(fn: () => Promise<T>) {
|
|
saving = true;
|
|
try {
|
|
await fn();
|
|
toast.success(m.saved());
|
|
} catch (e) {
|
|
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<PageMeta title={m.seo_title_system_datetime()} description={m.seo_desc_system_datetime()} />
|
|
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
|
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_system_datetime()}</h1>
|
|
|
|
<svelte:boundary>
|
|
{#snippet failed(err)}
|
|
<Card.Root>
|
|
<Card.Content class="text-destructive py-6">{(err as Error).message}</Card.Content>
|
|
</Card.Root>
|
|
{/snippet}
|
|
{@const t = await time}
|
|
{@const zones = await tzs}
|
|
|
|
<Card.Root>
|
|
<Card.Header>
|
|
<Card.Title>{m.system_time_current()}</Card.Title>
|
|
</Card.Header>
|
|
<Card.Content class="flex flex-wrap items-center gap-x-8 gap-y-2 text-sm">
|
|
<div class="flex items-baseline gap-2">
|
|
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider"
|
|
>{m.system_time_timezone()}</span
|
|
>
|
|
<span class="font-mono font-medium">{t.timezone}</span>
|
|
</div>
|
|
<div class="flex items-baseline gap-2">
|
|
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider"
|
|
>{m.system_time_clock()}</span
|
|
>
|
|
<span class="font-mono tabular-nums">{t.time}</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider">NTP</span
|
|
>
|
|
<Switch
|
|
checked={t.ntp}
|
|
disabled={saving || !t.can_ntp || !canWrite}
|
|
onCheckedChange={(v) => withSaving(() => setNtp({ enabled: v, machineId }))}
|
|
/>
|
|
</div>
|
|
<div class="text-muted-foreground text-xs">
|
|
{t.ntp_synchronized ? m.system_time_ntp_synced() : m.system_time_ntp_not_synced()}
|
|
</div>
|
|
</Card.Content>
|
|
<Card.Content class="border-t pt-4">
|
|
<Popover.Root bind:open={tzOpen}>
|
|
<Popover.Trigger>
|
|
{#snippet child({ props })}
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={tzOpen}
|
|
class="w-full justify-between sm:w-80"
|
|
disabled={!canWrite}
|
|
{...props}
|
|
>
|
|
{t.timezone}
|
|
<ChevronsUpDownIcon class="size-4 opacity-50" />
|
|
</Button>
|
|
{/snippet}
|
|
</Popover.Trigger>
|
|
<Popover.Content class="w-80 p-0">
|
|
<Command.Root>
|
|
<Command.Input placeholder={m.system_time_search_timezone_placeholder()} />
|
|
<Command.List class="max-h-72">
|
|
<Command.Empty>{m.system_time_no_timezone_found()}</Command.Empty>
|
|
{#each zones as z (z)}
|
|
<Command.Item
|
|
value={z}
|
|
onSelect={async () => {
|
|
tzOpen = false;
|
|
if (z === t.timezone) return;
|
|
await withSaving(() => setTimezone({ machineId, timezone: z }));
|
|
}}
|
|
>
|
|
<CheckIcon
|
|
class={'mr-2 size-4 ' + (z === t.timezone ? 'opacity-100' : 'opacity-0')}
|
|
/>
|
|
{z}
|
|
</Command.Item>
|
|
{/each}
|
|
</Command.List>
|
|
</Command.Root>
|
|
</Popover.Content>
|
|
</Popover.Root>
|
|
</Card.Content>
|
|
</Card.Root>
|
|
|
|
<Card.Root>
|
|
<Card.Header>
|
|
<Card.Title>{m.system_time_manual()}</Card.Title>
|
|
<Card.Description>{m.system_time_manual_hint()}</Card.Description>
|
|
</Card.Header>
|
|
<Card.Content>
|
|
<form
|
|
class="flex flex-col gap-2 sm:flex-row sm:items-end"
|
|
onsubmit={async (e) => {
|
|
e.preventDefault();
|
|
const fd = new FormData(e.currentTarget);
|
|
const v = String(fd.get('time') ?? '').trim();
|
|
if (!v) return;
|
|
await withSaving(() => setTime({ machineId, time: rfc3339FromLocal(v) }));
|
|
}}
|
|
>
|
|
<div class="flex grow flex-col gap-1.5">
|
|
<Label for={formId + 'time'}>{m.system_time_current()}</Label>
|
|
<Input
|
|
id={formId + 'time'}
|
|
name="time"
|
|
type="datetime-local"
|
|
step="1"
|
|
disabled={t.ntp}
|
|
/>
|
|
</div>
|
|
<Button type="submit" disabled={t.ntp || saving || !canWrite}>{m.save()}</Button>
|
|
</form>
|
|
</Card.Content>
|
|
</Card.Root>
|
|
</svelte:boundary>
|
|
</div>
|