mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
deb125bedc
The card names place search, address search and routing with what each answers and what is left of today's allowance. Each service is a tile with what is left as its figure, the day's use as a bar and used and limit as badges; the state, the kind of installation, a key from the environment and the time the count starts again sit as badges in the header. The sources add the terrain tiles the routes take their heights from.
207 lines
8.6 KiB
TypeScript
207 lines
8.6 KiB
TypeScript
import type { ServiceAllowance, ServiceIdentityState, ServiceIdentityStatus, ServiceQuotaView } from '@trek/shared';
|
|
import { MapPin, RefreshCw, Route, Search, type LucideIcon } from 'lucide-react';
|
|
import { useRef, useState } from 'react';
|
|
import { useTranslation } from '../../i18n';
|
|
import { useSettingsStore } from '../../store/settingsStore';
|
|
import type { TranslationFn } from '../../types';
|
|
import { getApiErrorMessage } from '../../utils/apiError';
|
|
import { formatClockTime } from '../../utils/formatters';
|
|
import ConfirmDialog from '../shared/ConfirmDialog';
|
|
import { useToast } from '../shared/Toast';
|
|
import { FigureBadge } from '../Tours/paddle/FigureBadge';
|
|
import { useServiceIdentity } from './useServiceIdentity';
|
|
|
|
const STATE_DOT: Record<ServiceIdentityState, string> = {
|
|
connected: 'bg-success',
|
|
registering: 'bg-warning motion-safe:animate-pulse',
|
|
legacy: 'bg-[var(--text-faint)]',
|
|
off: 'bg-[var(--text-faint)]',
|
|
revoked: 'bg-danger',
|
|
};
|
|
|
|
const SERVICE_ICON: Record<string, LucideIcon> = { places: MapPin, photon: Search, valhalla: Route };
|
|
|
|
/** Below a tenth of the day left the bar turns amber, at nothing left red. */
|
|
function barTone(quota: ServiceQuotaView): string {
|
|
if (quota.remaining === 0) return 'bg-danger';
|
|
return quota.remaining < quota.limit / 10 ? 'bg-warning' : 'bg-accent';
|
|
}
|
|
|
|
function figureTone(quota: ServiceQuotaView): string {
|
|
if (quota.remaining === 0) return 'text-danger';
|
|
return quota.remaining < quota.limit / 10 ? 'text-warning' : 'text-content';
|
|
}
|
|
|
|
/** The clock time the counts start again, in the user's format; one for all services, which share the day. */
|
|
function useResetTime(status: ServiceIdentityStatus | null): string | null {
|
|
const is12h = useSettingsStore((s) => s.settings.time_format === '12h');
|
|
const at = status?.services.find((s) => s.quota)?.quota?.resetAt;
|
|
if (!at) return null;
|
|
const reset = new Date(at);
|
|
return formatClockTime(
|
|
`${String(reset.getHours()).padStart(2, '0')}:${String(reset.getMinutes()).padStart(2, '0')}`,
|
|
is12h
|
|
);
|
|
}
|
|
|
|
function tileNote(t: TranslationFn, allowance: ServiceAllowance): string | null {
|
|
if (!allowance.inUse) return t('admin.trekApi.quota.notInUse');
|
|
return allowance.quota ? null : t('admin.trekApi.quota.unavailable');
|
|
}
|
|
|
|
/**
|
|
* One service under the key, as a tile: what is left today as the figure that
|
|
* matters, the day's use as a bar, used and limit as badges. A service not in
|
|
* use is dimmed rather than hidden, so the row reads the same on every install.
|
|
*/
|
|
function ServiceTile({ allowance }: { allowance: ServiceAllowance }) {
|
|
const { t, locale } = useTranslation();
|
|
const number = new Intl.NumberFormat(locale);
|
|
const name = t(`admin.trekApi.quota.service.${allowance.service}`);
|
|
const note = tileNote(t, allowance);
|
|
const quota = allowance.quota;
|
|
const Icon = SERVICE_ICON[allowance.service] ?? MapPin;
|
|
const share = quota && quota.limit > 0 ? Math.min(100, (quota.used / quota.limit) * 100) : 100;
|
|
|
|
return (
|
|
<li
|
|
className={`flex min-w-0 flex-col gap-2.5 rounded-xl border border-edge-faint bg-surface-card p-3 shadow-sm ${allowance.inUse ? '' : 'opacity-60'}`}
|
|
>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="grid h-7 w-7 flex-none place-items-center rounded-lg bg-surface-tertiary text-content-muted">
|
|
<Icon size={14} strokeWidth={2} aria-hidden="true" />
|
|
</span>
|
|
<p className="m-0 truncate text-caption font-medium text-content-secondary">{name}</p>
|
|
</div>
|
|
|
|
{quota && (
|
|
<>
|
|
<div className="min-w-0">
|
|
<p className={`m-0 truncate text-title font-semibold tabular-nums leading-tight ${figureTone(quota)}`}>
|
|
{number.format(quota.remaining)}
|
|
</p>
|
|
<p className="m-0 text-caption text-content-muted">{t('admin.trekApi.quota.remaining')}</p>
|
|
</div>
|
|
<div
|
|
role="progressbar"
|
|
aria-label={name}
|
|
aria-valuemin={0}
|
|
aria-valuemax={quota.limit}
|
|
aria-valuenow={quota.used}
|
|
className="h-1.5 overflow-hidden rounded-full bg-surface-tertiary"
|
|
>
|
|
<div
|
|
className={`h-full rounded-full transition-[width] duration-500 ${barTone(quota)}`}
|
|
style={{ width: `${share}%` }}
|
|
/>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
<FigureBadge label={t('admin.trekApi.quota.used')} value={number.format(quota.used)} />
|
|
<FigureBadge label={t('admin.trekApi.quota.limit')} value={number.format(quota.limit)} />
|
|
</div>
|
|
</>
|
|
)}
|
|
{note && <p className="m-0 text-caption leading-snug text-content-muted">{note}</p>}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function stateHint(t: TranslationFn, status: ServiceIdentityStatus | null, failed: boolean): string | null {
|
|
if (!status) return failed ? t('admin.trekApi.quota.unavailable') : null;
|
|
return status.state === 'connected' ? null : t(`admin.trekApi.quota.hint.${status.state}`);
|
|
}
|
|
|
|
/**
|
|
* How this installation stands with the TREK services: whether it has its key,
|
|
* and for place search, address search and routing today's allowance against
|
|
* what is used, and when the count starts again.
|
|
* Read when the card is in view and refreshed every 30 seconds while it stays.
|
|
* A managed install leaves the identity to its operator, so it offers no new one.
|
|
*/
|
|
export default function ServiceIdentityPanel({ managed = false }: { managed?: boolean }) {
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const panel = useRef<HTMLElement>(null);
|
|
const { status, failed, regenerating, regenerate } = useServiceIdentity(panel);
|
|
const [confirming, setConfirming] = useState(false);
|
|
const resetAt = useResetTime(status);
|
|
|
|
const confirmRegenerate = async () => {
|
|
setConfirming(false);
|
|
try {
|
|
await regenerate();
|
|
toast.success(t('admin.trekApi.quota.regenerated'));
|
|
} catch (err) {
|
|
toast.error(getApiErrorMessage(err, t('common.error')));
|
|
}
|
|
};
|
|
|
|
const hint = stateHint(t, status, failed);
|
|
const canRegenerate = !managed && status !== null && status.keySource !== 'env' && status.state !== 'off';
|
|
|
|
return (
|
|
<section
|
|
ref={panel}
|
|
aria-label={t('admin.trekApi.quota.title')}
|
|
className="flex flex-col gap-3 rounded-xl border border-edge-faint bg-surface-secondary p-3"
|
|
>
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<p className="m-0 inline-flex h-[26px] items-center gap-2 rounded-full bg-surface-card px-2.5 text-caption font-semibold text-content shadow-sm">
|
|
<span aria-hidden="true" className={`h-2 w-2 flex-none rounded-full ${STATE_DOT[status?.state ?? 'off']}`} />
|
|
{status ? t(`admin.trekApi.quota.state.${status.state}`) : t('admin.trekApi.quota.title')}
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{status?.tier && <FigureBadge value={t(`admin.trekApi.quota.tier.${status.tier}`)} />}
|
|
{status?.keySource === 'env' && (
|
|
<FigureBadge
|
|
label={t('admin.trekApi.quota.key')}
|
|
value="TREK_SERVICES_KEY"
|
|
info={t('admin.trekApi.quota.envKey')}
|
|
/>
|
|
)}
|
|
{resetAt && <FigureBadge label={t('admin.trekApi.quota.resets')} value={resetAt} />}
|
|
</div>
|
|
</div>
|
|
|
|
{status && status.services.length > 0 && (
|
|
<ul
|
|
aria-label={t('admin.trekApi.quota.title')}
|
|
className="m-0 grid list-none grid-cols-1 gap-2 p-0 sm:grid-cols-3"
|
|
>
|
|
{status.services.map((allowance) => (
|
|
<ServiceTile key={allowance.service} allowance={allowance} />
|
|
))}
|
|
</ul>
|
|
)}
|
|
{hint && <p className="m-0 text-caption leading-snug text-content-muted">{hint}</p>}
|
|
|
|
{canRegenerate && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setConfirming(true)}
|
|
disabled={regenerating}
|
|
className="inline-flex items-center gap-1.5 self-start rounded-lg text-caption font-medium text-content-secondary hover:text-content disabled:opacity-50"
|
|
>
|
|
<RefreshCw
|
|
size={13}
|
|
strokeWidth={2}
|
|
className={regenerating ? 'motion-safe:animate-spin' : ''}
|
|
aria-hidden="true"
|
|
/>
|
|
{t('admin.trekApi.quota.regenerate')}
|
|
</button>
|
|
)}
|
|
|
|
<ConfirmDialog
|
|
isOpen={confirming}
|
|
onClose={() => setConfirming(false)}
|
|
onConfirm={() => void confirmRegenerate()}
|
|
title={t('admin.trekApi.quota.regenerateTitle')}
|
|
message={t('admin.trekApi.quota.regenerateBody')}
|
|
confirmLabel={t('admin.trekApi.quota.regenerate')}
|
|
danger={false}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|