143 lines
6.2 KiB
TypeScript
143 lines
6.2 KiB
TypeScript
|
|
import { forwardRef, useState } from 'react';
|
||
|
|
import { useI18n } from '../lib/i18n';
|
||
|
|
import { DEMO_PLACES } from '../lib/demo';
|
||
|
|
import type { Assessment, Place, ReportAttr, UserReport } from '../lib/types';
|
||
|
|
import { Alert, FactCard, Icon, VERDICT_STYLE, VerdictTag } from './ui';
|
||
|
|
import { ReportForm } from './ReportForm';
|
||
|
|
|
||
|
|
export type RefreshState =
|
||
|
|
| { kind: 'idle' }
|
||
|
|
| { kind: 'loading' }
|
||
|
|
| { kind: 'ok'; at: string }
|
||
|
|
| { kind: 'fail' };
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
place: Place;
|
||
|
|
assessment: Assessment;
|
||
|
|
reports: UserReport[];
|
||
|
|
snapshotDate: string;
|
||
|
|
refresh: RefreshState;
|
||
|
|
onBack: () => void;
|
||
|
|
onRouteTo: () => void;
|
||
|
|
onShowOnMap: () => void;
|
||
|
|
onRefresh: () => void;
|
||
|
|
onAddReport: (r: Omit<UserReport, 'id' | 'createdAt'>) => void;
|
||
|
|
onRemoveReport: (id: string) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const PlaceDetail = forwardRef<HTMLHeadingElement, Props>(function PlaceDetail(p, headingRef) {
|
||
|
|
const { t, tm, date } = useI18n();
|
||
|
|
const [reporting, setReporting] = useState<ReportAttr | null>(null);
|
||
|
|
const [thanks, setThanks] = useState(false);
|
||
|
|
const { place, assessment } = p;
|
||
|
|
const demo = DEMO_PLACES.find((d) => d.id === place.id);
|
||
|
|
const address = [place.tags['addr:street'], place.tags['addr:housenumber']].filter(Boolean).join(' ');
|
||
|
|
const mine = p.reports.filter((r) => r.placeId === place.id);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<article aria-labelledby="detail-title">
|
||
|
|
<button type="button" className="usa-button usa-button--unstyled display-flex flex-align-center margin-bottom-2" onClick={p.onBack}>
|
||
|
|
<Icon name="arrow_back" size={3} className="margin-right-05" />
|
||
|
|
{t('detail.back')}
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<h2 id="detail-title" ref={headingRef} tabIndex={-1} className="margin-0">
|
||
|
|
{place.name ?? t('common.noName')}
|
||
|
|
</h2>
|
||
|
|
{address && <p className="margin-top-05 margin-bottom-0 text-base-dark">{address}</p>}
|
||
|
|
|
||
|
|
{demo && (
|
||
|
|
<div className="margin-top-1">
|
||
|
|
<span className="usa-tag bg-ink text-white">{t('detail.demo')}</span>
|
||
|
|
<p className="font-sans-xs margin-top-1 margin-bottom-0">
|
||
|
|
{t('detail.demoNote', { state: t(`verdict.${demoState(demo.demoState)}`) })}
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className={`usa-alert ${VERDICT_STYLE[assessment.verdict].alert} margin-top-2`} role="status">
|
||
|
|
<div className="usa-alert__body">
|
||
|
|
<h3 className="usa-alert__heading"><VerdictTag verdict={assessment.verdict} /></h3>
|
||
|
|
<p className="usa-alert__text">{t(`verdict.${assessment.verdict}.long`)}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="display-flex flex-wrap margin-top-2">
|
||
|
|
<button type="button" className="usa-button" onClick={p.onRouteTo}>
|
||
|
|
<Icon name="directions_walk" size={3} className="margin-right-05" />{t('detail.routeTo')}
|
||
|
|
</button>
|
||
|
|
<button type="button" className="usa-button usa-button--outline tablet:display-none" onClick={p.onShowOnMap}>
|
||
|
|
<Icon name="map" size={3} className="margin-right-05" />{t('view.map')}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<h3 className="margin-top-3">{t('detail.facts')}</h3>
|
||
|
|
<ul className="usa-list--unstyled margin-0 padding-0">
|
||
|
|
{assessment.facts.map((f) => (
|
||
|
|
<FactCard key={f.key} fact={f} onReport={() => { setReporting(f.key as ReportAttr); setThanks(false); }} />
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
|
||
|
|
{thanks && <Alert kind="success" slim role="status">{t('rep.thanks')}</Alert>}
|
||
|
|
{reporting && (
|
||
|
|
<ReportForm
|
||
|
|
placeId={place.id}
|
||
|
|
initialAttr={reporting}
|
||
|
|
onCancel={() => setReporting(null)}
|
||
|
|
onSubmit={(r) => { p.onAddReport(r); setReporting(null); setThanks(true); }}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<h3 className="margin-top-3">{t('detail.reports')}</h3>
|
||
|
|
<p className="font-sans-xs text-base-dark">{t('detail.reports.note')}</p>
|
||
|
|
{mine.length === 0 ? (
|
||
|
|
<p>{t('detail.reports.none')}</p>
|
||
|
|
) : (
|
||
|
|
<ul className="usa-list">
|
||
|
|
{mine.map((r) => (
|
||
|
|
<li key={r.id}>
|
||
|
|
<strong>{t(`rep.attr.${r.attr}`)}:</strong>{' '}
|
||
|
|
{typeof r.value === 'number' ? `${r.value} ${t('unit.cm')}` : t(`rep.${r.value}`)}
|
||
|
|
{r.demo && <span className="usa-tag margin-left-1 bg-ink text-white">{t('detail.reports.demo')}</span>}
|
||
|
|
<span className="text-base-dark"> — {date(r.createdAt.slice(0, 10))}</span>
|
||
|
|
{r.note && <div className="text-base-dark font-sans-xs">{r.note}</div>}
|
||
|
|
{!r.demo && (
|
||
|
|
<button type="button" className="usa-button usa-button--unstyled margin-left-1 font-sans-xs" onClick={() => p.onRemoveReport(r.id)}>
|
||
|
|
{t('detail.reports.remove')}
|
||
|
|
</button>
|
||
|
|
)}
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div className="border-top-1px border-base-lighter padding-top-2 margin-top-3">
|
||
|
|
{!place.demo && (
|
||
|
|
<>
|
||
|
|
<button type="button" className="usa-button usa-button--outline" onClick={p.onRefresh} disabled={p.refresh.kind === 'loading'}>
|
||
|
|
<Icon name="update" size={3} className="margin-right-05" />
|
||
|
|
{p.refresh.kind === 'loading' ? t('detail.refreshing') : t('detail.refresh')}
|
||
|
|
</button>
|
||
|
|
<a className="usa-button usa-button--unstyled margin-left-1" href={`https://www.openstreetmap.org/${osmPath(place.id)}`} target="_blank" rel="noreferrer">
|
||
|
|
{t('detail.openOsm')}
|
||
|
|
<Icon name="launch" size={2} className="margin-left-05" />
|
||
|
|
</a>
|
||
|
|
</>
|
||
|
|
)}
|
||
|
|
{place.demo && <p className="font-sans-xs text-base-dark">{t('detail.refreshDemo')}</p>}
|
||
|
|
<div aria-live="polite" className="margin-top-1">
|
||
|
|
{p.refresh.kind === 'ok' && <Alert kind="success" slim>{t('detail.refreshed', { time: new Date(p.refresh.at).toLocaleTimeString() })}</Alert>}
|
||
|
|
{p.refresh.kind === 'fail' && <Alert kind="warning" role="alert">{t('detail.refreshFail', { date: date(p.snapshotDate) })}</Alert>}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</article>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
const demoState = (s: string) => (s === 'verified' ? 'match' : s === 'conflict' ? 'conflict' : 'incomplete');
|
||
|
|
|
||
|
|
function osmPath(id: string) {
|
||
|
|
const kind = id[0] === 'n' ? 'node' : id[0] === 'w' ? 'way' : 'relation';
|
||
|
|
return `${kind}/${id.slice(1)}`;
|
||
|
|
}
|