mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(admin): the phone's API-keys card, rebuilt like the desktop tab
The desktop tab was reshaped around one idea: a key should be the exception rather than the thing you are expected to paste. The phone still had the older shape, where Google wore the "Recommended" chip and the TREK Places API was not on the screen at all. Two pages, opposite advice, same setting. So the phone gets the same three comparable blocks in the same order: the TREK index first, with its own ribbon and a folded panel of what it does and does not carry, then the two keys under theirs. The Google caveat is said before the field rather than after it, because somebody about to paste a key should read it while deciding. The four Google switches moved under the key they spend, folded away: they are set once and never touched again, and open they were longer than the rest of the card together. Open-Meteo left the card entirely, as it did on the desktop, since it takes no key and had the loudest treatment of anything there. The provider choice is TREK's own picker now. A native select is drawn by the operating system and was the one control on the page that did not belong to the app. Its description was also describing a TREK that no longer exists: it said one service answers place search. The index and OpenStreetMap answer every search and the setting only decides who is asked once they come up empty (maps.service.ts, keyedProvider). Both that line and the missing-key notice say so now, in all 23 languages, and the two key hints have short forms for the narrow screen.
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { AlertTriangle, RefreshCw, Sun } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, ChevronDown, RefreshCw } from 'lucide-react'
|
||||
import { adminApi, authApi } from '../../../api/client'
|
||||
import { getApiErrorMessage } from '../../../types'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
import type { useAdmin } from '../../../pages/admin/useAdmin'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import { MBlockDisclosure, MProviderBlock, MTrekApiBlock } from './MApiProviderBlocks'
|
||||
import MSetPickerSheet from '../settings/MSetPickerSheet'
|
||||
import {
|
||||
MAdminButton,
|
||||
MAdminCard,
|
||||
@@ -23,6 +26,7 @@ interface MAdminSettingsSectionProps {
|
||||
// types, API keys, OIDC and the danger zone — the full desktop settings tab
|
||||
// relaid as mobile cards. All state and mutations come from useAdmin.
|
||||
export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectionProps) {
|
||||
const [providerPickerOpen, setProviderPickerOpen] = useState(false)
|
||||
const {
|
||||
toast,
|
||||
setPlacesPhotosEnabled, setPlacesAutocompleteEnabled, setPlacesDetailsEnabled, setPlacesEnrichEnabled, setPlaceShadowEnabled,
|
||||
@@ -217,20 +221,22 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
<MAdminCard>
|
||||
<MAdminCardHead title={t('admin.apiKeys')} hint={t('admin.apiKeysHint')} />
|
||||
<div className="space-y-3">
|
||||
{/* Comparable blocks in the order we would have somebody choose them,
|
||||
matching the desktop tab: the free source first, the keys after it.
|
||||
The recommendation used to sit on the Google field here, which told
|
||||
a phone admin the opposite of what the same setting says on a
|
||||
laptop. */}
|
||||
<MTrekApiBlock />
|
||||
|
||||
{/* The keys belong to the operator on a managed install, same as on the
|
||||
desktop tab; the provider choice below stays the admin's. */}
|
||||
{!managed && (<>
|
||||
<MAdminField
|
||||
label={
|
||||
<span className="flex items-center gap-2">
|
||||
{t('admin.mapsKey')}
|
||||
<span className="rounded-full bg-[color:color-mix(in_srgb,var(--m-st-confirmed)_14%,transparent)] px-[7px] py-[2px] font-geist text-[0.5625rem] font-bold text-[color:var(--m-st-confirmed)]">
|
||||
{t('admin.recommended')}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
hint={t('admin.mapsKeyHintLong')}
|
||||
>
|
||||
<MProviderBlock title={t('admin.mapsKey')} badge={t('admin.googleCaveat.badge')} tone="caution">
|
||||
{/* Said before the field, not after it: somebody about to paste a key
|
||||
should read this while deciding, not once they already have. */}
|
||||
<p className="font-geist text-[0.625rem] leading-relaxed text-m-faint">
|
||||
{t('admin.mapsKeyHintShort')}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<MAdminSecretInput
|
||||
@@ -251,63 +257,19 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
</MAdminButton>
|
||||
</div>
|
||||
{validation.maps === true && (
|
||||
<p className="mt-1 font-geist text-[0.625rem] font-bold text-[color:var(--m-st-confirmed)]">
|
||||
<p className="font-geist text-[0.625rem] font-bold text-[color:var(--m-st-confirmed)]">
|
||||
{t('admin.keyValid')}
|
||||
</p>
|
||||
)}
|
||||
{validation.maps === false && (
|
||||
<p className="mt-1 font-geist text-[0.625rem] font-bold text-[color:var(--m-st-danger)]">
|
||||
<p className="font-geist text-[0.625rem] font-bold text-[color:var(--m-st-danger)]">
|
||||
{t('admin.keyInvalid')}
|
||||
</p>
|
||||
)}
|
||||
</MAdminField>
|
||||
|
||||
{/* No Test button, same as the desktop tab: /auth/validate-keys only
|
||||
knows how to probe Google Places and OpenWeatherMap. */}
|
||||
<MAdminField label={t('admin.amapKey')} hint={t('admin.amapKeyHint')}>
|
||||
<MAdminSecretInput
|
||||
aria-label={t('admin.amapKey')}
|
||||
value={amapKey}
|
||||
onChange={(e) => setAmapKey(e.target.value)}
|
||||
placeholder={t('settings.keyPlaceholder')}
|
||||
/>
|
||||
</MAdminField>
|
||||
|
||||
<MAdminField label={t('admin.unsplashKey')} hint={t('admin.unsplashKeyHint')}>
|
||||
<MAdminSecretInput
|
||||
aria-label={t('admin.unsplashKey')}
|
||||
value={unsplashKey}
|
||||
onChange={(e) => setUnsplashKey(e.target.value)}
|
||||
placeholder={t('settings.keyPlaceholder')}
|
||||
/>
|
||||
</MAdminField>
|
||||
</>)}
|
||||
|
||||
<MAdminField label={t('admin.placesProvider.title')} hint={t('admin.placesProvider.subtitle')}>
|
||||
<select
|
||||
value={placesProvider}
|
||||
disabled={savingPlacesProvider}
|
||||
onChange={(e) => handleSavePlacesProvider(e.target.value)}
|
||||
className="h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="auto">{t('admin.placesProvider.auto')}</option>
|
||||
<option value="google">{t('admin.placesProvider.google')}</option>
|
||||
<option value="amap">{t('admin.placesProvider.amap')}</option>
|
||||
<option value="openstreetmap">{t('admin.placesProvider.openstreetmap')}</option>
|
||||
</select>
|
||||
{/* From app-config, like the desktop card: the key fields are empty
|
||||
on a managed install and on an operator key set by environment. */}
|
||||
{((placesProvider === 'google' && !hasMapsKey) || (placesProvider === 'amap' && !hasAmapKey)) && (
|
||||
<p className="mt-1 font-geist text-[0.625rem] font-bold text-[color:var(--m-st-pending)]">
|
||||
{t('admin.placesProvider.missingKey')}
|
||||
</p>
|
||||
)}
|
||||
</MAdminField>
|
||||
|
||||
<div>
|
||||
{/* The four Google switches trade away quota that is the operator's on
|
||||
a managed install; the search log below is the instance's own. */}
|
||||
{!managed && (<>
|
||||
{/* What the key is allowed to be spent on. Under the key rather than
|
||||
loose at the foot of the card: every one of them is a Google
|
||||
request, and none of them mean anything without it. */}
|
||||
<MBlockDisclosure label={t('admin.placesUsageTitle')}>
|
||||
<MAdminRow
|
||||
title={t('admin.placesPhotos.title')}
|
||||
hint={t('admin.placesPhotos.subtitle')}
|
||||
@@ -388,8 +350,71 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>)}
|
||||
</MBlockDisclosure>
|
||||
</MProviderBlock>
|
||||
|
||||
{/* No Test button, same as the desktop tab: /auth/validate-keys only
|
||||
knows how to probe Google Places and OpenWeatherMap. */}
|
||||
<MProviderBlock title={t('admin.amapKey')}>
|
||||
<p className="font-geist text-[0.625rem] leading-relaxed text-m-faint">{t('admin.amapKeyHintShort')}</p>
|
||||
<MAdminSecretInput
|
||||
aria-label={t('admin.amapKey')}
|
||||
value={amapKey}
|
||||
onChange={(e) => setAmapKey(e.target.value)}
|
||||
placeholder={t('settings.keyPlaceholder')}
|
||||
/>
|
||||
</MProviderBlock>
|
||||
|
||||
<MProviderBlock title={t('admin.unsplashKey')}>
|
||||
<p className="font-geist text-[0.625rem] leading-relaxed text-m-faint">{t('admin.unsplashKeyHint')}</p>
|
||||
<MAdminSecretInput
|
||||
aria-label={t('admin.unsplashKey')}
|
||||
value={unsplashKey}
|
||||
onChange={(e) => setUnsplashKey(e.target.value)}
|
||||
placeholder={t('settings.keyPlaceholder')}
|
||||
/>
|
||||
</MProviderBlock>
|
||||
</>)}
|
||||
|
||||
<MAdminField label={t('admin.placesProvider.title')} hint={t('admin.placesProvider.subtitle')}>
|
||||
{/* The app's own picker rather than a bare <select>: a native one is
|
||||
drawn by the operating system and was the one control on this page
|
||||
that did not belong to TREK. Same sheet the settings selects use. */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={savingPlacesProvider}
|
||||
onClick={() => setProviderPickerOpen(true)}
|
||||
className="flex h-[42px] w-full items-center gap-2 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-left text-[0.84375rem] text-m-ink disabled:opacity-50"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{t(`admin.placesProvider.${placesProvider}`)}</span>
|
||||
<ChevronDown size={15} strokeWidth={2.2} className="flex-none text-m-faint" aria-hidden />
|
||||
</button>
|
||||
<MSetPickerSheet
|
||||
open={providerPickerOpen}
|
||||
onClose={() => setProviderPickerOpen(false)}
|
||||
title={t('admin.placesProvider.title')}
|
||||
value={placesProvider}
|
||||
onSelect={(value) => { if (value !== placesProvider) void handleSavePlacesProvider(value) }}
|
||||
options={[
|
||||
{ value: 'auto', label: t('admin.placesProvider.auto') },
|
||||
{ value: 'google', label: t('admin.placesProvider.google') },
|
||||
{ value: 'amap', label: t('admin.placesProvider.amap') },
|
||||
{ value: 'openstreetmap', label: t('admin.placesProvider.openstreetmap') },
|
||||
]}
|
||||
/>
|
||||
{/* From app-config, like the desktop card: the key fields are empty
|
||||
on a managed install and on an operator key set by environment. */}
|
||||
{((placesProvider === 'google' && !hasMapsKey) || (placesProvider === 'amap' && !hasAmapKey)) && (
|
||||
<p className="mt-1 font-geist text-[0.625rem] font-bold text-[color:var(--m-st-pending)]">
|
||||
{t('admin.placesProvider.missingKey')}
|
||||
</p>
|
||||
)}
|
||||
</MAdminField>
|
||||
|
||||
<div>
|
||||
{/* The instance's own search log, which is not Google's to spend. */}
|
||||
<MAdminRow
|
||||
first
|
||||
title={t('admin.placeShadow.title')}
|
||||
hint={t('admin.placeShadow.subtitle')}
|
||||
trailing={
|
||||
@@ -411,39 +436,6 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Open-Meteo weather info */}
|
||||
<div className="rounded-2xl border border-[color:color-mix(in_srgb,var(--m-st-confirmed)_28%,transparent)] bg-[color:color-mix(in_srgb,var(--m-st-confirmed)_10%,transparent)] p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-7 w-7 flex-none items-center justify-center rounded-lg bg-[color:var(--m-st-confirmed)] text-white">
|
||||
<Sun size={14} strokeWidth={2.2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-extrabold text-m-ink">
|
||||
{t('admin.weather.title')}
|
||||
</span>
|
||||
<span className="flex-none rounded-full bg-[color:color-mix(in_srgb,var(--m-st-confirmed)_14%,transparent)] px-2 py-[2px] font-geist text-[0.5625rem] font-bold text-[color:var(--m-st-confirmed)]">
|
||||
{t('admin.weather.badge')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 font-geist text-[0.625rem] leading-relaxed text-m-muted">
|
||||
{t('admin.weather.description')}
|
||||
</p>
|
||||
<p className="mt-1 font-geist text-[0.59375rem] leading-relaxed text-m-faint">
|
||||
{t('admin.weather.locationHint')}
|
||||
</p>
|
||||
<div className="mt-2 space-y-[6px]">
|
||||
{[
|
||||
{ title: t('admin.weather.forecast'), desc: t('admin.weather.forecastDesc') },
|
||||
{ title: t('admin.weather.climate'), desc: t('admin.weather.climateDesc') },
|
||||
{ title: t('admin.weather.requests'), desc: t('admin.weather.requestsDesc') },
|
||||
].map((row) => (
|
||||
<div key={row.title} className="rounded-xl bg-[color:var(--m-ic)] px-3 py-2">
|
||||
<p className="text-[0.6875rem] font-bold text-m-ink">{row.title}</p>
|
||||
<p className="mt-[1px] font-geist text-[0.59375rem] text-m-muted">{row.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!managed && (
|
||||
<MAdminButton busy={savingKeys} onClick={handleSaveApiKeys}>
|
||||
{t('common.save')}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useState } from 'react'
|
||||
import { Check, ChevronRight, Globe2, KeyRound, Library, ShieldOff, WifiOff, X } from 'lucide-react'
|
||||
import TrekMark from '../../../components/shared/TrekMark'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
|
||||
/**
|
||||
* The API-keys card's two building blocks, in the phone's own material.
|
||||
*
|
||||
* The desktop tab was rebuilt around one idea: a key should be the exception
|
||||
* rather than the thing you are expected to paste. The TREK Places API leads,
|
||||
* the paid providers follow as comparable blocks, and the "recommended" mark
|
||||
* belongs to the free option instead of to the Google field. The phone still
|
||||
* had the older shape, where Google wore the recommendation and TREK was not on
|
||||
* the screen at all, which is the opposite advice on the same setting.
|
||||
*
|
||||
* Rebuilt rather than shared with the desktop component: every surface, radius
|
||||
* and type size here comes from the mobile tokens, and the desktop card is a
|
||||
* two-column layout with a hover-revealed disclosure. What is shared is the
|
||||
* wording, key for key, so the two pages cannot drift in what they claim.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Proper nouns, so they are not translated. Naming them is also a licence
|
||||
* obligation rather than decoration: ODbL and CC BY-SA both require attribution
|
||||
* wherever their content is shown.
|
||||
*/
|
||||
const SOURCES = ['Overture Maps Foundation', 'OpenStreetMap', 'Wikivoyage', 'Wikimedia']
|
||||
|
||||
const chip = 'rounded-full border border-[color:var(--m-rowbr)] px-2 py-[2px] font-geist text-[0.625rem]'
|
||||
|
||||
/** One group of chips inside the disclosure: what you get, what you do not, where it comes from. */
|
||||
function ChipGroup({
|
||||
icon,
|
||||
label,
|
||||
items,
|
||||
note,
|
||||
dashed = false,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
items: string[]
|
||||
note: string
|
||||
dashed?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px]">
|
||||
<p className="flex items-center gap-[6px] text-[0.6875rem] font-bold text-m-ink">
|
||||
{icon}
|
||||
{label}
|
||||
</p>
|
||||
<ul className="mt-2 flex flex-wrap gap-[5px]">
|
||||
{items.map(item => (
|
||||
<li key={item} className={`${chip} ${dashed ? 'border-dashed text-m-faint' : 'text-m-muted'}`}>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 font-geist text-[0.625rem] leading-relaxed text-m-faint">{note}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The TREK Places API, above the paid keys because it is the alternative to them. */
|
||||
export function MTrekApiBlock() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const facts = [
|
||||
{ Icon: Globe2, text: t('admin.trekApi.factPlaces') },
|
||||
{ Icon: KeyRound, text: t('admin.trekApi.factNoKey') },
|
||||
{ Icon: WifiOff, text: t('admin.trekApi.factOffline') },
|
||||
{ Icon: ShieldOff, text: t('admin.trekApi.factPrivacy') },
|
||||
]
|
||||
|
||||
// The same fields the desktop card lists, reusing words TREK already has.
|
||||
const fields = [
|
||||
t('places.formName'),
|
||||
t('collections.coordinates'),
|
||||
t('places.formCategory'),
|
||||
t('places.formAddress'),
|
||||
t('admin.trekApi.fieldPhone'),
|
||||
t('common.email'),
|
||||
t('places.formWebsite'),
|
||||
t('places.formDescription'),
|
||||
t('inspector.openingHours'),
|
||||
t('admin.trekApi.fieldStableId'),
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="relative mt-[18px]">
|
||||
{/* The recommendation sits on the border, as on the desktop card, and small:
|
||||
a wide coloured banner would shout over the settings beside it. */}
|
||||
<span className="pointer-events-none absolute -top-2 left-3 z-10 rounded-md bg-m-act px-2 py-[1px] font-geist text-[0.5625rem] font-bold tracking-[.12em] text-m-actfg uppercase">
|
||||
{t('admin.trekApi.badgeDefault')}
|
||||
</span>
|
||||
|
||||
<div className="overflow-hidden rounded-[16px] border border-[color:var(--m-act)] bg-[color:var(--m-ic)]">
|
||||
<div className="px-[14px] pt-[18px] pb-[13px]">
|
||||
<TrekMark className="h-6 w-auto text-m-ink" aria-label="TREK Places API" />
|
||||
<p className="mt-[9px] text-[0.8125rem] leading-relaxed text-m-muted">{t('admin.trekApi.tagline')}</p>
|
||||
<ul className="mt-3 grid grid-cols-1 gap-[7px]">
|
||||
{facts.map(({ Icon, text }) => (
|
||||
<li key={text} className="flex items-center gap-2 font-geist text-[0.6875rem] text-m-muted">
|
||||
<Icon size={13} strokeWidth={2} className="flex-none text-m-faint" aria-hidden />
|
||||
{text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* A button rather than <details>: the phone shell styles its own
|
||||
disclosures and Safari draws a marker on the summary that no reset
|
||||
reaches reliably. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[14px] py-[10px] text-left"
|
||||
>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
strokeWidth={2.2}
|
||||
className={`flex-none text-m-faint transition-transform ${open ? 'rotate-90' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[0.8125rem] font-semibold text-m-muted">{t('admin.trekApi.more')}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="space-y-[9px] px-[14px] pt-1 pb-[14px]">
|
||||
<ChipGroup
|
||||
icon={<Check size={13} strokeWidth={2.4} className="text-[color:var(--m-st-confirmed)]" aria-hidden />}
|
||||
label={t('admin.trekApi.included')}
|
||||
items={fields}
|
||||
note={t('admin.trekApi.includedNote')}
|
||||
/>
|
||||
<ChipGroup
|
||||
icon={<X size={13} strokeWidth={2.4} className="text-m-faint" aria-hidden />}
|
||||
label={t('admin.trekApi.notIncluded')}
|
||||
items={[t('admin.trekApi.notRatings'), t('admin.trekApi.notPhotos')]}
|
||||
note={t('admin.trekApi.notIncludedNote')}
|
||||
dashed
|
||||
/>
|
||||
<ChipGroup
|
||||
icon={<Library size={13} strokeWidth={2.2} className="text-m-faint" aria-hidden />}
|
||||
label={t('admin.trekApi.sourcesLabel')}
|
||||
items={SOURCES}
|
||||
note={t('admin.trekApi.sourcesNote')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A folded group of settings that belong to the block above them.
|
||||
*
|
||||
* Closed to begin with, and on purpose: the four Google switches are set once
|
||||
* when a key is pasted and never touched again, and open they were longer than
|
||||
* everything else on the card put together.
|
||||
*/
|
||||
export function MBlockDisclosure({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<div className="-mx-[14px] -mb-[13px] mt-1 border-t border-[color:var(--m-rowbr)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center gap-2 px-[14px] py-[10px] text-left"
|
||||
>
|
||||
<ChevronRight
|
||||
size={15}
|
||||
strokeWidth={2.2}
|
||||
className={`flex-none text-m-faint transition-transform ${open ? 'rotate-90' : ''}`}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="text-[0.8125rem] font-semibold text-m-muted">{label}</span>
|
||||
</button>
|
||||
{open && <div className="px-[14px] pb-[10px]">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One paid provider, in the same shape as the TREK block above it, so the card
|
||||
* reads as a list of comparable options rather than one highlighted thing with
|
||||
* loose fields underneath.
|
||||
*/
|
||||
export function MProviderBlock({
|
||||
title,
|
||||
badge,
|
||||
tone = 'muted',
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
badge?: string
|
||||
/** `caution` marks a provider that costs something other than money. Never `danger`: using a key is a legitimate choice. */
|
||||
tone?: 'muted' | 'caution'
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
// The ribbon rides on the top border, so the block needs room above it or the
|
||||
// capitals touch whatever ends the block before.
|
||||
<div className={`relative ${badge ? 'mt-[18px]' : 'mt-3'}`}>
|
||||
{badge && (
|
||||
<span
|
||||
className={`pointer-events-none absolute -top-2 left-3 z-10 rounded-md border px-2 py-[1px] font-geist text-[0.5625rem] font-bold tracking-[.12em] uppercase ${
|
||||
tone === 'caution'
|
||||
? 'border-[color:var(--m-st-pending)] bg-[color:color-mix(in_srgb,var(--m-st-pending)_16%,var(--m-sheetop))] text-[color:var(--m-st-pending)]'
|
||||
: 'border-[color:var(--m-rowbr)] bg-[color:var(--m-sheetop)] text-m-faint'
|
||||
}`}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
<div className="overflow-hidden rounded-[16px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]">
|
||||
<div className="px-[14px] pt-[18px] pb-[13px]">
|
||||
<p className="text-[0.8125rem] font-semibold text-m-ink">{title}</p>
|
||||
<div className="mt-[10px] space-y-3">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -300,7 +300,7 @@ describe('AdminSettingsTab', () => {
|
||||
// failing, which is quiet enough to be mistaken for the provider working.
|
||||
renderTab({ placesProvider: 'amap', hasAmapKey: false });
|
||||
expect(
|
||||
within(card('API Keys')).getByText(/falls back to OpenStreetMap/i),
|
||||
within(card('API Keys')).getByText(/TREK index and OpenStreetMap alone/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user