GdzieWjade/src/components/PlaceDetail.tsx

143 lines
6.2 KiB
TypeScript
Raw Normal View History

2026-10-03 13:33:50 +02:00
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)}`;
}