mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 15:36:33 +00:00
fix(mobile): split the new phone sheets and cards below the lint limits, follow the tests to the new rows
This commit is contained in:
@@ -3,7 +3,7 @@ import type { PaddleRegion, PaddleRegionAutoMode } from '@trek/shared';
|
||||
/** How TREK loads a paddler's region by itself, in the order the switch shows them. */
|
||||
export const AUTO_LOAD_MODES: readonly PaddleRegionAutoMode[] = ['auto', 'ask', 'off'];
|
||||
/** The largest region TREK loads by itself, in megabytes: a district, a state, a small country. */
|
||||
export const AUTO_LOAD_SIZES = [100, 250, 500, 1000, 2000] as const;
|
||||
const AUTO_LOAD_SIZES = [100, 250, 500, 1000, 2000] as const;
|
||||
|
||||
/** The size choices, with a stored size outside the presets kept among them in its place. */
|
||||
export function autoLoadSizes(current: number): number[] {
|
||||
|
||||
@@ -148,12 +148,13 @@ describe('Tours permissions without database setup', () => {
|
||||
expect(remove).toHaveBeenCalledTimes(canAssign ? 1 : 0)
|
||||
const view = vi.fn()
|
||||
const openSheet = vi.fn()
|
||||
rerender(<List planner={{ t: (key: string) => key, tours: [tour], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} canEdit={canEdit} canAssign={canAssign} />)
|
||||
fireEvent.click(screen.getByRole('option'))
|
||||
rerender(<List planner={{ t: (key: string) => key, tours: [tour], places: [], days: [], handlePlaceClick: view, reloadTourPlaceIds: vi.fn() } as never} shell={{ sheet: 'other', openSheet } as never} canEdit={canEdit} canAssign={canAssign} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: new RegExp(tour.name) }))
|
||||
expect(view).toHaveBeenCalledWith(42)
|
||||
const attach = screen.getByRole('button', { name: 'tours.addToDay' }) as HTMLButtonElement
|
||||
expect(attach.disabled).toBe(!canAssign)
|
||||
fireEvent.click(attach)
|
||||
// Without the permission the row offers no plus at all, as an unplanned place's row does.
|
||||
const attach = screen.queryByRole('button', { name: 'tours.addToDay' })
|
||||
expect(Boolean(attach)).toBe(canAssign)
|
||||
if (attach) fireEvent.click(attach)
|
||||
expect(openSheet).toHaveBeenCalledTimes(canAssign ? 1 : 0)
|
||||
})
|
||||
it.each([[false, false], [true, false], [false, true], [true, true]])('rails edit=%s assign=%s separate save and existing-tour attach', (canEdit, canAssign) => {
|
||||
|
||||
@@ -266,9 +266,9 @@ function CatalogSearch({ admin }: { admin: Admin }) {
|
||||
);
|
||||
}
|
||||
|
||||
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||
/** What a region's read stands at: its status, where it came from, its objects, its date and its water. */
|
||||
function RegionBadges({ region }: { region: PaddleRegion }) {
|
||||
const { t, language } = useTranslation();
|
||||
const busy = admin.busyId === region.id;
|
||||
const percent = progressPercent(region);
|
||||
const status =
|
||||
region.status === 'importing' && region.progress
|
||||
@@ -280,6 +280,38 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||
const km = region.network
|
||||
? new Intl.NumberFormat(language, { maximumFractionDigits: 0 }).format(region.network.water_km)
|
||||
: null;
|
||||
return (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<MBadge
|
||||
caps={false}
|
||||
className={TONE[regionStatusTone(region.status)]}
|
||||
icon={region.status === 'importing' ? <Loader2 size={10} className="animate-spin" /> : undefined}
|
||||
>
|
||||
{status}
|
||||
{percent === null ? '' : ` ${new Intl.NumberFormat(language, { style: 'percent' }).format(percent / 100)}`}
|
||||
</MBadge>
|
||||
{region.origin === 'auto' && (
|
||||
<MBadge caps={false} icon={<Sparkles size={10} strokeWidth={2.2} />}>
|
||||
{t('admin.paddleRegions.origin.auto')}
|
||||
</MBadge>
|
||||
)}
|
||||
{region.imported_at && (
|
||||
<MBadge caps={false}>{t('admin.paddleRegions.objects', { count: region.feature_count })}</MBadge>
|
||||
)}
|
||||
{region.imported_at && date && <MBadge caps={false}>{t('admin.paddleRegions.asOf', { date })}</MBadge>}
|
||||
{km !== null && <MBadge caps={false}>{t('admin.paddleRegions.network.km', { km })}</MBadge>}
|
||||
{!region.network && region.imported_at && region.status !== 'importing' && (
|
||||
<MBadge caps={false} className="!text-[color:var(--m-st-pending)]">
|
||||
{t('admin.paddleRegions.network.missing')}
|
||||
</MBadge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||
const { t } = useTranslation();
|
||||
const busy = admin.busyId === region.id;
|
||||
return (
|
||||
<div
|
||||
data-testid="paddle-region-row"
|
||||
@@ -295,31 +327,7 @@ function RegionRow({ region, admin }: { region: PaddleRegion; admin: Admin }) {
|
||||
<div className="truncate text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink">
|
||||
{region.name}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
<MBadge
|
||||
caps={false}
|
||||
className={TONE[regionStatusTone(region.status)]}
|
||||
icon={region.status === 'importing' ? <Loader2 size={10} className="animate-spin" /> : undefined}
|
||||
>
|
||||
{status}
|
||||
{percent === null ? '' : ` ${new Intl.NumberFormat(language, { style: 'percent' }).format(percent / 100)}`}
|
||||
</MBadge>
|
||||
{region.origin === 'auto' && (
|
||||
<MBadge caps={false} icon={<Sparkles size={10} strokeWidth={2.2} />}>
|
||||
{t('admin.paddleRegions.origin.auto')}
|
||||
</MBadge>
|
||||
)}
|
||||
{region.imported_at && (
|
||||
<MBadge caps={false}>{t('admin.paddleRegions.objects', { count: region.feature_count })}</MBadge>
|
||||
)}
|
||||
{region.imported_at && date && <MBadge caps={false}>{t('admin.paddleRegions.asOf', { date })}</MBadge>}
|
||||
{km !== null && <MBadge caps={false}>{t('admin.paddleRegions.network.km', { km })}</MBadge>}
|
||||
{!region.network && region.imported_at && region.status !== 'importing' && (
|
||||
<MBadge caps={false} className="!text-[color:var(--m-st-pending)]">
|
||||
{t('admin.paddleRegions.network.missing')}
|
||||
</MBadge>
|
||||
)}
|
||||
</div>
|
||||
<RegionBadges region={region} />
|
||||
{region.status === 'failed' && region.error && (
|
||||
<p className="mt-1 line-clamp-2 break-words font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-[color:var(--m-st-danger)]">
|
||||
{region.error}
|
||||
|
||||
@@ -219,6 +219,188 @@ function RegionSheet({ region, busy, offline, error, onClose, onSave }: EditorPr
|
||||
);
|
||||
}
|
||||
|
||||
type Catalog = ReturnType<typeof useSchoolHolidayCatalog>;
|
||||
type Removal = { label: string; action: () => Promise<boolean> };
|
||||
|
||||
/** Offline, loading and a failed read, each said where the card starts. */
|
||||
function CatalogNotices({ state, disabled }: { state: Catalog; disabled: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{state.offline && (
|
||||
<p
|
||||
role="status"
|
||||
className="flex items-center gap-2 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2 text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold text-[color:var(--m-st-pending)]"
|
||||
>
|
||||
<WifiOff size={13} className="flex-none" />
|
||||
{t('schoolCatalog.offline')}
|
||||
</p>
|
||||
)}
|
||||
{state.loading && (
|
||||
<p
|
||||
role="status"
|
||||
className="flex items-center gap-2 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-muted"
|
||||
>
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
)}
|
||||
{state.error && !state.editor && (
|
||||
<div role="alert" className="flex items-center gap-2 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2">
|
||||
<span className="min-w-0 flex-1 text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold text-[color:var(--m-st-danger)]">
|
||||
{state.error}
|
||||
</span>
|
||||
<MAdminButton variant="ghost" disabled={disabled} onClick={() => void state.refresh()}>
|
||||
{t('schoolCatalog.retry')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The country shown, with adding one and deleting the shown one (only while it has no regions). */
|
||||
function CountryPicker({
|
||||
state,
|
||||
selected,
|
||||
regionCount,
|
||||
disabled,
|
||||
onSelect,
|
||||
onAdd,
|
||||
onRemove,
|
||||
}: {
|
||||
state: Catalog;
|
||||
selected: string;
|
||||
regionCount: number;
|
||||
disabled: boolean;
|
||||
onSelect: (code: string) => void;
|
||||
onAdd: () => void;
|
||||
onRemove: (removal: Removal) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{state.catalog.countries.length > 0 ? (
|
||||
<div>
|
||||
<span className={LABEL}>{t('schoolCatalog.country')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<CustomSelect
|
||||
value={selected}
|
||||
onChange={(value) => onSelect(String(value))}
|
||||
searchable
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
options={state.catalog.countries.map((item) => ({ value: item.code, label: item.name }))}
|
||||
placeholder={t('schoolCatalog.country')}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('schoolCatalog.addCountry')}
|
||||
disabled={disabled}
|
||||
onClick={onAdd}
|
||||
className={ROUND}
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('schoolCatalog.deleteCountry')}
|
||||
disabled={disabled || regionCount > 0}
|
||||
onClick={() => onRemove({ label: selected, action: () => state.deleteCountry(selected) })}
|
||||
className={ROUND}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
{regionCount > 0 && (
|
||||
<p className="mt-1.5 font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] text-m-faint">
|
||||
{t('schoolCatalog.deleteHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
!state.loading && (
|
||||
<MAdminButton variant="ghost" disabled={disabled} onClick={onAdd} className="self-start">
|
||||
<Plus size={13} strokeWidth={2.2} />
|
||||
{t('schoolCatalog.addCountry')}
|
||||
</MAdminButton>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The regions of a country, each opened or deleted, and the button that adds one. */
|
||||
function RegionList({
|
||||
state,
|
||||
country,
|
||||
regions,
|
||||
disabled,
|
||||
onRemove,
|
||||
}: {
|
||||
state: Catalog;
|
||||
country: string;
|
||||
regions: Catalog['catalog']['regions'];
|
||||
disabled: boolean;
|
||||
onRemove: (removal: Removal) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{regions.length === 0 ? (
|
||||
<p className="rounded-[14px] border border-dashed border-[color:var(--m-rowbr)] px-4 py-4 text-center font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-faint">
|
||||
{t('schoolCatalog.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-[color:var(--m-rowbr)] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]">
|
||||
{regions.map((region) => (
|
||||
<div key={region.id} className="flex items-center gap-2 py-1.5 pe-2 ps-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => void state.openRegion(region.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 px-1.5 py-1 text-start"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-[32px] w-[32px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheetop)] text-m-muted"
|
||||
>
|
||||
<MapPinned size={14} strokeWidth={1.9} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink">
|
||||
{region.name}
|
||||
</span>
|
||||
<ChevronRight aria-hidden="true" size={15} className="flex-none text-m-faint rtl:rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('common.delete')} ${region.name}`}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
onRemove({ label: region.name, action: () => state.deleteRegion(region.id, region.revision) })
|
||||
}
|
||||
className="flex h-[32px] w-[32px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheetop)] text-m-muted disabled:opacity-40"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => state.setEditor({ id: 0, country, name: '', code: '', revision: 0, holidays: [] })}
|
||||
className="flex h-[42px] items-center justify-center gap-1.5 rounded-full border border-dashed border-[color:var(--m-rowbr)] text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-m-muted disabled:opacity-40"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('schoolCatalog.addRegion')}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The school holiday catalog on the phone: the shared holiday regions per
|
||||
* country that everyone can pick in their vacation settings. The state is
|
||||
@@ -229,7 +411,7 @@ export default function MAdminSchoolHolidayCatalog() {
|
||||
const state = useSchoolHolidayCatalog();
|
||||
const [country, setCountry] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [remove, setRemove] = useState<{ label: string; action: () => Promise<boolean> } | null>(null);
|
||||
const [remove, setRemove] = useState<Removal | null>(null);
|
||||
const selectedCountry =
|
||||
state.catalog.countries.find((item) => item.code === country)?.code || state.catalog.countries[0]?.code || '';
|
||||
const regions = state.catalog.regions.filter((region) => region.country === selectedCountry);
|
||||
@@ -257,139 +439,26 @@ export default function MAdminSchoolHolidayCatalog() {
|
||||
<div
|
||||
className={`mt-3 flex flex-col gap-3 border-t border-[color:var(--m-rowbr)] pt-3 ${state.busy ? 'opacity-60' : ''}`}
|
||||
>
|
||||
{state.offline && (
|
||||
<p
|
||||
role="status"
|
||||
className="flex items-center gap-2 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2 text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold text-[color:var(--m-st-pending)]"
|
||||
>
|
||||
<WifiOff size={13} className="flex-none" />
|
||||
{t('schoolCatalog.offline')}
|
||||
</p>
|
||||
)}
|
||||
{state.loading && (
|
||||
<p
|
||||
role="status"
|
||||
className="flex items-center gap-2 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-muted"
|
||||
>
|
||||
<Loader2 size={13} className="animate-spin" />
|
||||
{t('common.loading')}
|
||||
</p>
|
||||
)}
|
||||
{state.error && !state.editor && (
|
||||
<div role="alert" className="flex items-center gap-2 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2">
|
||||
<span className="min-w-0 flex-1 text-[length:calc(11px*var(--fs-scale-caption,1))] font-semibold text-[color:var(--m-st-danger)]">
|
||||
{state.error}
|
||||
</span>
|
||||
<MAdminButton variant="ghost" disabled={disabled} onClick={() => void state.refresh()}>
|
||||
{t('schoolCatalog.retry')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
)}
|
||||
<CatalogNotices state={state} disabled={disabled} />
|
||||
|
||||
{state.catalog.countries.length > 0 ? (
|
||||
<div>
|
||||
<span className={LABEL}>{t('schoolCatalog.country')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<CustomSelect
|
||||
value={selectedCountry}
|
||||
onChange={(value) => setCountry(String(value))}
|
||||
searchable
|
||||
disabled={disabled}
|
||||
size="sm"
|
||||
options={state.catalog.countries.map((item) => ({ value: item.code, label: item.name }))}
|
||||
placeholder={t('schoolCatalog.country')}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('schoolCatalog.addCountry')}
|
||||
disabled={disabled}
|
||||
onClick={() => setAdding(true)}
|
||||
className={ROUND}
|
||||
>
|
||||
<Plus size={16} strokeWidth={2.2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('schoolCatalog.deleteCountry')}
|
||||
disabled={disabled || regions.length > 0}
|
||||
onClick={() =>
|
||||
setRemove({ label: selectedCountry, action: () => state.deleteCountry(selectedCountry) })
|
||||
}
|
||||
className={ROUND}
|
||||
>
|
||||
<Trash2 size={15} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
{regions.length > 0 && (
|
||||
<p className="mt-1.5 font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] text-m-faint">
|
||||
{t('schoolCatalog.deleteHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
!state.loading && (
|
||||
<MAdminButton variant="ghost" disabled={disabled} onClick={() => setAdding(true)} className="self-start">
|
||||
<Plus size={13} strokeWidth={2.2} />
|
||||
{t('schoolCatalog.addCountry')}
|
||||
</MAdminButton>
|
||||
)
|
||||
)}
|
||||
<CountryPicker
|
||||
state={state}
|
||||
selected={selectedCountry}
|
||||
regionCount={regions.length}
|
||||
disabled={disabled}
|
||||
onSelect={setCountry}
|
||||
onAdd={() => setAdding(true)}
|
||||
onRemove={setRemove}
|
||||
/>
|
||||
|
||||
{selectedCountry && (
|
||||
<>
|
||||
{regions.length === 0 ? (
|
||||
<p className="rounded-[14px] border border-dashed border-[color:var(--m-rowbr)] px-4 py-4 text-center font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-faint">
|
||||
{t('schoolCatalog.empty')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-[color:var(--m-rowbr)] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)]">
|
||||
{regions.map((region) => (
|
||||
<div key={region.id} className="flex items-center gap-2 py-1.5 pe-2 ps-1.5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => void state.openRegion(region.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 px-1.5 py-1 text-start"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex h-[32px] w-[32px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheetop)] text-m-muted"
|
||||
>
|
||||
<MapPinned size={14} strokeWidth={1.9} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink">
|
||||
{region.name}
|
||||
</span>
|
||||
<ChevronRight aria-hidden="true" size={15} className="flex-none text-m-faint rtl:rotate-180" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${t('common.delete')} ${region.name}`}
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
setRemove({ label: region.name, action: () => state.deleteRegion(region.id, region.revision) })
|
||||
}
|
||||
className="flex h-[32px] w-[32px] flex-none items-center justify-center rounded-full bg-[color:var(--m-sheetop)] text-m-muted disabled:opacity-40"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() =>
|
||||
state.setEditor({ id: 0, country: selectedCountry, name: '', code: '', revision: 0, holidays: [] })
|
||||
}
|
||||
className="flex h-[42px] items-center justify-center gap-1.5 rounded-full border border-dashed border-[color:var(--m-rowbr)] text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-m-muted disabled:opacity-40"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.2} />
|
||||
{t('schoolCatalog.addRegion')}
|
||||
</button>
|
||||
</>
|
||||
<RegionList
|
||||
state={state}
|
||||
country={selectedCountry}
|
||||
regions={regions}
|
||||
disabled={disabled}
|
||||
onRemove={setRemove}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ import { endDateForNewStart, tripFormError } from '../../../components/Trips/tri
|
||||
|
||||
interface MNewTripSheetProps {
|
||||
open: boolean
|
||||
/** null = create, otherwise edit */
|
||||
trip: DashboardTrip | null
|
||||
/** null or undefined = create, otherwise edit (the planner may not have its trip yet) */
|
||||
trip: DashboardTrip | null | undefined
|
||||
onClose: () => void
|
||||
onSave: (data: TripCreateRequest) => Promise<{ trip?: Trip } | void> | void
|
||||
onCoverUpdate?: (tripId: number, coverUrl: string | null) => void
|
||||
@@ -31,6 +31,9 @@ interface MNewTripSheetProps {
|
||||
onArchive?: () => void
|
||||
}
|
||||
|
||||
const BOX_CLS = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px_12px]'
|
||||
const INPUT_CLS = 'w-full border-none bg-transparent pt-[2px] font-[inherit] text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint'
|
||||
|
||||
/** The reminder presets the desktop form offers; anything else is a custom number of days. */
|
||||
const REMINDER_PRESETS = [0, 1, 3, 9]
|
||||
|
||||
@@ -47,12 +50,12 @@ function FieldLabel({ children }: { children: React.ReactNode }): React.ReactEle
|
||||
* opens it for editing too. Archiving
|
||||
* lives here in edit mode, as decided for the grid cards.
|
||||
*/
|
||||
export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpdate, onArchive }: MNewTripSheetProps): React.ReactElement {
|
||||
export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, onCoverUpdate, onArchive }: MNewTripSheetProps): React.ReactElement {
|
||||
const trip = tripProp ?? null
|
||||
const isEditing = !!trip
|
||||
const { t } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const defaultCurrency = useSettingsStore(s => s.settings.default_currency) || 'EUR'
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const canEditTrip = !isEditing || can('trip_edit', trip)
|
||||
const canUploadCover = !isEditing || can('trip_cover_upload', trip)
|
||||
const currentUser = useAuthStore(s => s.user)
|
||||
@@ -71,7 +74,6 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
|
||||
const [error, setError] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const cover = useTripCoverPicker({ trip, title, onCoverUpdate, releasePreviewOnChange: true })
|
||||
const { coverPreview, uploadingCover, searchQuery, setSearchQuery, searchResults, searchError, searching } = cover
|
||||
// A save whose new dates remove days with something on them, held until the
|
||||
// traveller confirms. The phone has no shift step, so bookings keep their dates.
|
||||
const [pendingRemoval, setPendingRemoval] = useState<{ payload: TripCreateRequest; removal: RangeRemoval | 'unknown' } | null>(null)
|
||||
@@ -149,13 +151,8 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoverFile = cover.selectFile
|
||||
const handleSearch = cover.search
|
||||
const selectUnsplash = cover.selectPhoto
|
||||
const removeCover = cover.removeCover
|
||||
|
||||
const boxCls = 'rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px_12px]'
|
||||
const inputCls = 'w-full border-none bg-transparent pt-[2px] font-[inherit] text-[0.9375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint'
|
||||
const boxCls = BOX_CLS
|
||||
const inputCls = INPUT_CLS
|
||||
|
||||
const removalList = pendingRemoval && pendingRemoval.removal !== 'unknown' ? pendingRemoval.removal : null
|
||||
|
||||
@@ -261,135 +258,17 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
|
||||
</div>
|
||||
|
||||
{showReminder && (
|
||||
<div className={`${boxCls} mt-2`}>
|
||||
<FieldLabel>
|
||||
<span className="inline-flex items-center gap-1"><Bell size={10} strokeWidth={2.4} />{t('trips.reminder')}</span>
|
||||
</FieldLabel>
|
||||
{!remindersEnabled ? (
|
||||
<p className="mt-1 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] leading-snug text-m-muted">{t('trips.reminderDisabledHint')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{REMINDER_PRESETS.map(days => (
|
||||
<MChip key={days} pressable active={!customReminder && reminderDays === days}
|
||||
onClick={() => { setError(''); setReminderDays(days); setCustomReminder(false) }}>
|
||||
{days === 0 ? t('trips.reminderNone') : `${days} ${t(days === 1 ? 'trips.reminderDay' : 'trips.reminderDays')}`}
|
||||
</MChip>
|
||||
))}
|
||||
<MChip pressable active={customReminder}
|
||||
onClick={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}>
|
||||
{t('trips.reminderCustom')}
|
||||
</MChip>
|
||||
</div>
|
||||
{customReminder && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={30}
|
||||
value={reminderDays}
|
||||
onChange={e => setReminderDays(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
|
||||
aria-label={t('trips.reminderDaysBefore')}
|
||||
className="h-[34px] w-16 rounded-full border border-[color:var(--m-rowbr)] bg-transparent px-3 text-center text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink outline-none"
|
||||
/>
|
||||
<span className="font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-muted">{t('trips.reminderDaysBefore')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<ReminderField
|
||||
enabled={remindersEnabled}
|
||||
days={reminderDays}
|
||||
custom={customReminder}
|
||||
onPreset={days => { setError(''); setReminderDays(days); setCustomReminder(false) }}
|
||||
onCustom={() => { setCustomReminder(true); if (REMINDER_PRESETS.includes(reminderDays)) setReminderDays(7) }}
|
||||
onDays={setReminderDays}
|
||||
/>
|
||||
)}
|
||||
|
||||
{canUploadCover && (
|
||||
<div className="mt-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => { void handleCoverFile(e.target.files?.[0]); e.target.value = '' }}
|
||||
/>
|
||||
{coverPreview ? (
|
||||
<div className="relative h-[130px] overflow-hidden rounded-[16px]">
|
||||
<img src={coverPreview} alt="" className="h-full w-full object-cover" />
|
||||
<div className="absolute bottom-2 end-2 flex gap-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploadingCover}
|
||||
className="flex h-[34px] items-center gap-1 rounded-full border border-white/30 bg-white/[.22] px-3 text-[0.6875rem] font-semibold text-white backdrop-blur-[8px]"
|
||||
>
|
||||
<Camera size={13} strokeWidth={2.1} />
|
||||
{uploadingCover ? t('common.uploading') : t('common.change')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={removeCover}
|
||||
aria-label={t('common.delete')}
|
||||
className="flex h-[34px] w-[34px] items-center justify-center rounded-full border border-white/30 bg-white/[.22] text-white backdrop-blur-[8px]"
|
||||
>
|
||||
<X size={14} strokeWidth={2.1} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploadingCover}
|
||||
className="flex w-full items-center justify-center gap-[6px] rounded-[14px] border border-dashed border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[18px] text-[0.8125rem] font-medium text-m-muted"
|
||||
>
|
||||
<Camera size={15} strokeWidth={2} />
|
||||
{uploadingCover ? t('common.uploading') : t('dashboard.mobile.addCoverImage')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
<div className={`${boxCls} min-w-0 flex-1 py-[9px]`}>
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void handleSearch() } }}
|
||||
placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
||||
className={`${inputCls} pt-0 text-[0.8125rem] font-medium`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSearch}
|
||||
disabled={searching || (!searchQuery.trim() && !title.trim())}
|
||||
aria-label={t('dashboard.searchUnsplash')}
|
||||
className="flex h-auto w-[42px] flex-none items-center justify-center rounded-[14px] bg-m-act text-m-actfg disabled:opacity-50"
|
||||
>
|
||||
<Search size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
{searchError && <p className="mt-[6px] px-1 text-[0.6875rem] font-medium text-[color:var(--m-st-danger)]">{searchError}</p>}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
{searchResults.map(photo => (
|
||||
<button
|
||||
key={photo.id}
|
||||
type="button"
|
||||
onClick={() => selectUnsplash(photo)}
|
||||
aria-label={t('dashboard.useUnsplashPhoto', { photographer: photo.photographer || 'Unsplash' })}
|
||||
className={`relative h-20 overflow-hidden rounded-[12px] border ${
|
||||
coverPreview === photo.url ? 'border-[color:var(--m-act)]' : 'border-[color:var(--m-rowbr)]'
|
||||
}`}
|
||||
>
|
||||
<img src={photo.thumb} alt={photo.description || ''} loading="lazy" className="h-full w-full object-cover" />
|
||||
{photo.photographer && (
|
||||
<span className="absolute inset-x-0 bottom-0 truncate bg-black/55 px-[6px] py-1 text-start font-geist text-[0.625rem] text-white">
|
||||
{photo.photographer}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canUploadCover && <CoverField cover={cover} title={title} />}
|
||||
|
||||
{isEditing && onArchive && (
|
||||
<div className="mt-2 rounded-[14px] bg-[color:var(--m-ic)]">
|
||||
@@ -449,3 +328,149 @@ export default function MNewTripSheet({ open, trip, onClose, onSave, onCoverUpda
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** The trip's reminder: the presets, or a number of days of one's own; a hint while reminders are off. */
|
||||
function ReminderField({ enabled, days, custom, onPreset, onCustom, onDays }: {
|
||||
enabled: boolean
|
||||
days: number
|
||||
custom: boolean
|
||||
onPreset: (days: number) => void
|
||||
onCustom: () => void
|
||||
onDays: (days: number) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={`${BOX_CLS} mt-2`}>
|
||||
<FieldLabel>
|
||||
<span className="inline-flex items-center gap-1"><Bell size={10} strokeWidth={2.4} />{t('trips.reminder')}</span>
|
||||
</FieldLabel>
|
||||
{!enabled ? (
|
||||
<p className="mt-1 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] leading-snug text-m-muted">{t('trips.reminderDisabledHint')}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{REMINDER_PRESETS.map(preset => (
|
||||
<MChip key={preset} pressable active={!custom && days === preset} onClick={() => onPreset(preset)}>
|
||||
{preset === 0 ? t('trips.reminderNone') : `${preset} ${t(preset === 1 ? 'trips.reminderDay' : 'trips.reminderDays')}`}
|
||||
</MChip>
|
||||
))}
|
||||
<MChip pressable active={custom} onClick={onCustom}>
|
||||
{t('trips.reminderCustom')}
|
||||
</MChip>
|
||||
</div>
|
||||
{custom && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={30}
|
||||
value={days}
|
||||
onChange={e => onDays(Math.max(1, Math.min(30, Number(e.target.value) || 1)))}
|
||||
aria-label={t('trips.reminderDaysBefore')}
|
||||
className="h-[34px] w-16 rounded-full border border-[color:var(--m-rowbr)] bg-transparent px-3 text-center text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink outline-none"
|
||||
/>
|
||||
<span className="font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-muted">{t('trips.reminderDaysBefore')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The cover: a photo from the device or from Unsplash, with its preview. */
|
||||
function CoverField({ cover, title }: { cover: ReturnType<typeof useTripCoverPicker>; title: string }): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
const { coverPreview, uploadingCover, searchQuery, setSearchQuery, searchResults, searchError, searching } = cover
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={e => { void cover.selectFile(e.target.files?.[0]); e.target.value = '' }}
|
||||
/>
|
||||
{coverPreview ? (
|
||||
<div className="relative h-[130px] overflow-hidden rounded-[16px]">
|
||||
<img src={coverPreview} alt="" className="h-full w-full object-cover" />
|
||||
<div className="absolute bottom-2 end-2 flex gap-[6px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploadingCover}
|
||||
className="flex h-[34px] items-center gap-1 rounded-full border border-white/30 bg-white/[.22] px-3 text-[0.6875rem] font-semibold text-white backdrop-blur-[8px]"
|
||||
>
|
||||
<Camera size={13} strokeWidth={2.1} />
|
||||
{uploadingCover ? t('common.uploading') : t('common.change')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cover.removeCover}
|
||||
aria-label={t('common.delete')}
|
||||
className="flex h-[34px] w-[34px] items-center justify-center rounded-full border border-white/30 bg-white/[.22] text-white backdrop-blur-[8px]"
|
||||
>
|
||||
<X size={14} strokeWidth={2.1} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={uploadingCover}
|
||||
className="flex w-full items-center justify-center gap-[6px] rounded-[14px] border border-dashed border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[18px] text-[0.8125rem] font-medium text-m-muted"
|
||||
>
|
||||
<Camera size={15} strokeWidth={2} />
|
||||
{uploadingCover ? t('common.uploading') : t('dashboard.mobile.addCoverImage')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
<div className={`${BOX_CLS} min-w-0 flex-1 py-[9px]`}>
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void cover.search() } }}
|
||||
placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
||||
className={`${INPUT_CLS} pt-0 text-[0.8125rem] font-medium`}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cover.search}
|
||||
disabled={searching || (!searchQuery.trim() && !title.trim())}
|
||||
aria-label={t('dashboard.searchUnsplash')}
|
||||
className="flex h-auto w-[42px] flex-none items-center justify-center rounded-[14px] bg-m-act text-m-actfg disabled:opacity-50"
|
||||
>
|
||||
<Search size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
{searchError && <p className="mt-[6px] px-1 text-[0.6875rem] font-medium text-[color:var(--m-st-danger)]">{searchError}</p>}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
{searchResults.map(photo => (
|
||||
<button
|
||||
key={photo.id}
|
||||
type="button"
|
||||
onClick={() => cover.selectPhoto(photo)}
|
||||
aria-label={t('dashboard.useUnsplashPhoto', { photographer: photo.photographer || 'Unsplash' })}
|
||||
className={`relative h-20 overflow-hidden rounded-[12px] border ${
|
||||
coverPreview === photo.url ? 'border-[color:var(--m-act)]' : 'border-[color:var(--m-rowbr)]'
|
||||
}`}
|
||||
>
|
||||
<img src={photo.thumb} alt={photo.description || ''} loading="lazy" className="h-full w-full object-cover" />
|
||||
{photo.photographer && (
|
||||
<span className="absolute inset-x-0 bottom-0 truncate bg-black/55 px-[6px] py-1 text-start font-geist text-[0.625rem] text-white">
|
||||
{photo.photographer}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,9 @@ const openSheet = vi.fn()
|
||||
|
||||
function translate(table: Record<string, unknown>) {
|
||||
return (key: string, params?: Record<string, string | number>) => {
|
||||
const value = table[key] ?? en[key]
|
||||
// The one form is all a count of 1 needs here; the real t() picks it by Intl.PluralRules.
|
||||
const one = params?.count === 1 ? table[`${key}.one`] : undefined
|
||||
const value = one ?? table[key] ?? en[key]
|
||||
if (typeof value !== 'string') return key
|
||||
return value.replace(/\{(\w+)\}/g, (_, name: string) => String(params?.[name] ?? ''))
|
||||
}
|
||||
@@ -115,9 +117,10 @@ describe('MToursSelectionList', () => {
|
||||
expect(screen.getByLabelText(en['tours.badge.gpx'] as string)).toHaveTextContent('GPX')
|
||||
})
|
||||
|
||||
it('says once that tours are planned on a bigger screen, in German too', () => {
|
||||
it('says once that tours are planned on a bigger screen, and counts in German', () => {
|
||||
render(<List planner={planner({ t: translate(de) })} shell={shell()} />)
|
||||
expect(screen.getByText('Geplant wird am Desktop')).toBeInTheDocument()
|
||||
// The badge reads the app's language, English in this suite.
|
||||
expect(screen.getByText('Planned on desktop')).toBeInTheDocument()
|
||||
expect(screen.getByText('1 Tour')).toBeInTheDocument()
|
||||
expect(document.body.textContent).not.toMatch(/\b(tours|mobileTrip)\.[a-zA-Z]/)
|
||||
})
|
||||
@@ -126,17 +129,17 @@ describe('MToursSelectionList', () => {
|
||||
render(<List planner={planner({ tours: [], t: translate(de) })} shell={shell()} />)
|
||||
expect(screen.getByText('Noch keine Touren')).toBeInTheDocument()
|
||||
expect(screen.getByText(de['mobileTrip.toursEmptyBody'] as string)).toBeInTheDocument()
|
||||
expect(screen.getByText('Geplant wird am Desktop')).toBeInTheDocument()
|
||||
expect(screen.getByText('Planned on desktop')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('narrows the list by a facet and widens it again', () => {
|
||||
const other = { ...tour, place_id: 43, name: 'Other', max_hiking_difficulty: 4 }
|
||||
render(<List planner={planner({ tours: [tour, other] })} shell={shell()} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /^T4/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^T4 \d+$/ }))
|
||||
expect(screen.getByText('Other')).toBeInTheDocument()
|
||||
expect(screen.queryByText(tour.name)).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: /^T4/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /^T4 \d+$/ }))
|
||||
expect(screen.getByText(tour.name)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,7 +35,7 @@ interface MTripSettingsSheetProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
tripId: number;
|
||||
tripTitle: string;
|
||||
tripTitle?: string;
|
||||
onMembersChanged?: () => void;
|
||||
}
|
||||
|
||||
@@ -214,6 +214,215 @@ function InviteLinkSection({ tripId }: { tripId: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
type Members = ReturnType<typeof useTripMembers>;
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
type Ask = (question: Question) => void;
|
||||
|
||||
/** What a question says and what its button does, by the kind of question. */
|
||||
function questionWords(question: Question | null, t: Translate): { text: string; action: string } {
|
||||
if (!question) return { text: '', action: '' };
|
||||
if (question.kind === 'transfer')
|
||||
return { text: t('members.confirmTransfer', { name: question.name }), action: t('members.makeOwner') };
|
||||
if (question.kind === 'guest') return { text: t('members.confirmRemoveGuest'), action: t('members.removeAccess') };
|
||||
return question.self
|
||||
? { text: t('members.confirmLeave'), action: t('members.leaveTrip') }
|
||||
: { text: t('members.confirmRemove'), action: t('members.removeAccess') };
|
||||
}
|
||||
|
||||
/** Make owner and remove (or leave) beside a member, as far as the reader may. */
|
||||
function MemberActions({ member, m, ask }: { member: ListedMember; m: Members; ask: Ask }) {
|
||||
const { t } = useTranslation();
|
||||
const self = member.id === m.user?.id;
|
||||
const canRemove = self || (m.canManageMembers && member.role !== 'owner');
|
||||
return (
|
||||
<>
|
||||
{m.isCurrentOwner && member.role !== 'owner' && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('members.makeOwner')}
|
||||
disabled={m.transferringId === member.id}
|
||||
onClick={() => ask({ kind: 'transfer', id: member.id, name: member.username })}
|
||||
className={ROUND}
|
||||
>
|
||||
<Crown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
{canRemove && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={self ? t('members.leaveTrip') : t('members.removeAccess')}
|
||||
disabled={m.removingId === member.id}
|
||||
onClick={() => ask({ kind: 'remove', id: member.id, self })}
|
||||
className={ROUND}
|
||||
>
|
||||
{self ? <LogOut size={14} strokeWidth={2} /> : <UserMinus size={14} strokeWidth={2} />}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The badges beside a member's name: you, and the owner's crown. */
|
||||
function MemberBadges({ member, m }: { member: ListedMember; m: Members }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{member.id === m.user?.id && (
|
||||
<span className={`${PILL} bg-[color:var(--m-ic)] text-m-muted`}>{t('members.you')}</span>
|
||||
)}
|
||||
{member.role === 'owner' && (
|
||||
<span className={`${PILL} bg-m-act text-m-actfg`}>
|
||||
<Crown size={9} strokeWidth={2.4} />
|
||||
{t('members.owner')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Who has access: the invite picker for those who may add people, then everyone with their badges. */
|
||||
function AccessSection({ m, ask }: { m: Members; ask: Ask }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Section title={`${t('members.access')} ${m.realMembers.length}`}>
|
||||
{m.canManageMembers && (
|
||||
<div className="flex gap-2">
|
||||
<CustomSelect
|
||||
value={m.selectedUserId}
|
||||
onChange={(value) => m.setSelectedUserId(String(value))}
|
||||
placeholder={t('members.selectUser')}
|
||||
options={[
|
||||
{ value: '', label: t('members.selectUser') },
|
||||
...m.availableUsers.map((u) => ({ value: String(u.id), label: u.username })),
|
||||
]}
|
||||
searchable
|
||||
size="sm"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void m.add()}
|
||||
disabled={m.adding || !m.selectedUserId}
|
||||
aria-label={t('members.invite')}
|
||||
className={PRIMARY}
|
||||
>
|
||||
<UserPlus size={14} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{m.canManageMembers && m.availableUsers.length === 0 && m.allUsers.length > 0 && (
|
||||
<p className="font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-faint">
|
||||
{t('members.allHaveAccess')}
|
||||
</p>
|
||||
)}
|
||||
{m.loading && m.realMembers.length === 0 ? (
|
||||
<div className="h-[44px] animate-pulse rounded-full bg-[color:var(--m-ic)]" />
|
||||
) : (
|
||||
m.realMembers.map((member) => (
|
||||
<PersonRow
|
||||
key={member.id}
|
||||
name={member.username}
|
||||
avatarUrl={member.avatar_url ?? null}
|
||||
badges={<MemberBadges member={member} m={m} />}
|
||||
>
|
||||
<MemberActions member={member} m={m} ask={ask} />
|
||||
</PersonRow>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/** A guest's row, or the field its new name is typed into. */
|
||||
function GuestRow({ guest, m, ask }: { guest: Members['guests'][number]; m: Members; ask: Ask }) {
|
||||
const { t } = useTranslation();
|
||||
if (m.renamingGuestId === guest.id) {
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
value={m.renameValue}
|
||||
maxLength={50}
|
||||
aria-label={t('common.rename')}
|
||||
onChange={(e) => m.setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void m.commitRename(guest.id);
|
||||
if (e.key === 'Escape') m.cancelRename();
|
||||
}}
|
||||
onBlur={() => void m.commitRename(guest.id)}
|
||||
className={FIELD}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PersonRow
|
||||
name={guest.username}
|
||||
avatarUrl={null}
|
||||
badges={
|
||||
<span className={`${PILL} bg-[color:var(--m-ic)] text-m-muted`}>
|
||||
<UserRound size={9} strokeWidth={2.4} />
|
||||
{t('members.guest')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{m.isCurrentOwner && (
|
||||
<>
|
||||
<button type="button" aria-label={t('common.rename')} onClick={() => m.startRename(guest)} className={ROUND}>
|
||||
<Pencil size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('members.removeAccess')}
|
||||
disabled={m.removingId === guest.id}
|
||||
onClick={() => ask({ kind: 'guest', id: guest.id })}
|
||||
className={ROUND}
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</PersonRow>
|
||||
);
|
||||
}
|
||||
|
||||
/** The guests without an account, and for the owner the field that adds one. */
|
||||
function GuestsSection({ m, ask }: { m: Members; ask: Ask }) {
|
||||
const { t } = useTranslation();
|
||||
if (!m.isCurrentOwner && m.guests.length === 0) return null;
|
||||
return (
|
||||
<Section
|
||||
title={m.guests.length > 0 ? `${t('members.guests')} ${m.guests.length}` : t('members.guests')}
|
||||
hint={t('members.guestsHint')}
|
||||
>
|
||||
{m.guests.map((guest) => (
|
||||
<GuestRow key={guest.id} guest={guest} m={m} ask={ask} />
|
||||
))}
|
||||
{m.isCurrentOwner && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={m.newGuestName}
|
||||
maxLength={50}
|
||||
placeholder={t('members.guestNamePlaceholder')}
|
||||
onChange={(e) => m.setNewGuestName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void m.addGuest();
|
||||
}}
|
||||
className={FIELD}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void m.addGuest()}
|
||||
disabled={m.addingGuest || !m.newGuestName.trim()}
|
||||
aria-label={t('members.addGuest')}
|
||||
className={PRIMARY}
|
||||
>
|
||||
<Plus size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The trip's settings on the phone (the Mehr sheet's "Trip settings"): who has
|
||||
* access, the guests without an account, the public link, the invite link and
|
||||
@@ -230,6 +439,7 @@ export default function MTripSettingsSheet({
|
||||
const { t } = useTranslation();
|
||||
const m = useTripMembers({ isOpen: open, tripId, onClose, onMembersChanged });
|
||||
const [question, setQuestion] = useState<Question | null>(null);
|
||||
const words = questionWords(question, t);
|
||||
|
||||
const answer = () => {
|
||||
if (!question) return;
|
||||
@@ -238,52 +448,6 @@ export default function MTripSettingsSheet({
|
||||
else void m.removeGuest(question.id);
|
||||
setQuestion(null);
|
||||
};
|
||||
const questionText = !question
|
||||
? ''
|
||||
: question.kind === 'transfer'
|
||||
? t('members.confirmTransfer', { name: question.name })
|
||||
: question.kind === 'guest'
|
||||
? t('members.confirmRemoveGuest')
|
||||
: question.self
|
||||
? t('members.confirmLeave')
|
||||
: t('members.confirmRemove');
|
||||
const questionAction =
|
||||
question?.kind === 'transfer'
|
||||
? t('members.makeOwner')
|
||||
: question?.kind === 'remove' && question.self
|
||||
? t('members.leaveTrip')
|
||||
: t('members.removeAccess');
|
||||
|
||||
const memberActions = (member: ListedMember) => {
|
||||
const self = member.id === m.user?.id;
|
||||
const canRemove = self || (m.canManageMembers && member.role !== 'owner');
|
||||
return (
|
||||
<>
|
||||
{m.isCurrentOwner && member.role !== 'owner' && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('members.makeOwner')}
|
||||
disabled={m.transferringId === member.id}
|
||||
onClick={() => setQuestion({ kind: 'transfer', id: member.id, name: member.username })}
|
||||
className={ROUND}
|
||||
>
|
||||
<Crown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
{canRemove && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={self ? t('members.leaveTrip') : t('members.removeAccess')}
|
||||
disabled={m.removingId === member.id}
|
||||
onClick={() => setQuestion({ kind: 'remove', id: member.id, self })}
|
||||
className={ROUND}
|
||||
>
|
||||
{self ? <LogOut size={14} strokeWidth={2} /> : <UserMinus size={14} strokeWidth={2} />}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -299,148 +463,8 @@ export default function MTripSettingsSheet({
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-[18px] pb-[18px] pt-3">
|
||||
<Section title={`${t('members.access')} ${m.realMembers.length}`}>
|
||||
{m.canManageMembers && (
|
||||
<div className="flex gap-2">
|
||||
<CustomSelect
|
||||
value={m.selectedUserId}
|
||||
onChange={(value) => m.setSelectedUserId(String(value))}
|
||||
placeholder={t('members.selectUser')}
|
||||
options={[
|
||||
{ value: '', label: t('members.selectUser') },
|
||||
...m.availableUsers.map((u) => ({ value: String(u.id), label: u.username })),
|
||||
]}
|
||||
searchable
|
||||
size="sm"
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void m.add()}
|
||||
disabled={m.adding || !m.selectedUserId}
|
||||
aria-label={t('members.invite')}
|
||||
className={PRIMARY}
|
||||
>
|
||||
<UserPlus size={14} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{m.canManageMembers && m.availableUsers.length === 0 && m.allUsers.length > 0 && (
|
||||
<p className="font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-faint">
|
||||
{t('members.allHaveAccess')}
|
||||
</p>
|
||||
)}
|
||||
{m.loading && m.realMembers.length === 0 ? (
|
||||
<div className="h-[44px] animate-pulse rounded-full bg-[color:var(--m-ic)]" />
|
||||
) : (
|
||||
m.realMembers.map((member) => (
|
||||
<PersonRow
|
||||
key={member.id}
|
||||
name={member.username}
|
||||
avatarUrl={member.avatar_url}
|
||||
badges={
|
||||
<>
|
||||
{member.id === m.user?.id && (
|
||||
<span className={`${PILL} bg-[color:var(--m-ic)] text-m-muted`}>{t('members.you')}</span>
|
||||
)}
|
||||
{member.role === 'owner' && (
|
||||
<span className={`${PILL} bg-m-act text-m-actfg`}>
|
||||
<Crown size={9} strokeWidth={2.4} />
|
||||
{t('members.owner')}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{memberActions(member)}
|
||||
</PersonRow>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{(m.isCurrentOwner || m.guests.length > 0) && (
|
||||
<Section
|
||||
title={`${t('members.guests')}${m.guests.length > 0 ? ` ${m.guests.length}` : ''}`}
|
||||
hint={t('members.guestsHint')}
|
||||
>
|
||||
{m.guests.map((guest) =>
|
||||
m.renamingGuestId === guest.id ? (
|
||||
<input
|
||||
key={guest.id}
|
||||
autoFocus
|
||||
value={m.renameValue}
|
||||
maxLength={50}
|
||||
aria-label={t('common.rename')}
|
||||
onChange={(e) => m.setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void m.commitRename(guest.id);
|
||||
if (e.key === 'Escape') m.cancelRename();
|
||||
}}
|
||||
onBlur={() => void m.commitRename(guest.id)}
|
||||
className={FIELD}
|
||||
/>
|
||||
) : (
|
||||
<PersonRow
|
||||
key={guest.id}
|
||||
name={guest.username}
|
||||
avatarUrl={null}
|
||||
badges={
|
||||
<span className={`${PILL} bg-[color:var(--m-ic)] text-m-muted`}>
|
||||
<UserRound size={9} strokeWidth={2.4} />
|
||||
{t('members.guest')}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{m.isCurrentOwner && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.rename')}
|
||||
onClick={() => m.startRename(guest)}
|
||||
className={ROUND}
|
||||
>
|
||||
<Pencil size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('members.removeAccess')}
|
||||
disabled={m.removingId === guest.id}
|
||||
onClick={() => setQuestion({ kind: 'guest', id: guest.id })}
|
||||
className={ROUND}
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</PersonRow>
|
||||
)
|
||||
)}
|
||||
{m.isCurrentOwner && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={m.newGuestName}
|
||||
maxLength={50}
|
||||
placeholder={t('members.guestNamePlaceholder')}
|
||||
onChange={(e) => m.setNewGuestName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void m.addGuest();
|
||||
}}
|
||||
className={FIELD}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void m.addGuest()}
|
||||
disabled={m.addingGuest || !m.newGuestName.trim()}
|
||||
aria-label={t('members.addGuest')}
|
||||
className={PRIMARY}
|
||||
>
|
||||
<Plus size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<AccessSection m={m} ask={setQuestion} />
|
||||
<GuestsSection m={m} ask={setQuestion} />
|
||||
{m.canManageShare && <ShareLinkSection tripId={tripId} />}
|
||||
{m.canManageShare && <InviteLinkSection tripId={tripId} />}
|
||||
<TripJournalLinkSection tripId={tripId} />
|
||||
@@ -450,9 +474,9 @@ export default function MTripSettingsSheet({
|
||||
<MConfirmSheet
|
||||
open={question != null}
|
||||
onClose={() => setQuestion(null)}
|
||||
title={questionAction}
|
||||
message={questionText}
|
||||
confirmLabel={questionAction}
|
||||
title={words.action}
|
||||
message={words.text}
|
||||
confirmLabel={words.action}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger={question?.kind !== 'transfer'}
|
||||
onConfirm={answer}
|
||||
|
||||
@@ -200,7 +200,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
{/* Trip edit + share/members, opened from the Mehr sheet. */}
|
||||
<MNewTripSheet
|
||||
open={sheet?.id === 'tripedit'}
|
||||
trip={trip ?? null}
|
||||
trip={trip}
|
||||
onClose={shell.closeSheet}
|
||||
onSave={async (data) => {
|
||||
await tripActions.updateTrip(tripId, data)
|
||||
@@ -212,7 +212,7 @@ export default function MTripSheets({ planner, shell }: MTripSheetsProps) {
|
||||
open={sheet?.id === 'members'}
|
||||
onClose={shell.closeSheet}
|
||||
tripId={tripId}
|
||||
tripTitle={trip?.title ?? ''}
|
||||
tripTitle={trip?.title}
|
||||
onMembersChanged={planner.refreshMembers}
|
||||
/>
|
||||
|
||||
|
||||
@@ -511,7 +511,7 @@ export function useJourneyDetail({ journeyId, embedded = false }: JourneyDetailO
|
||||
dayImport, dayImportDates,
|
||||
} = useJourneyDetailLayers({
|
||||
journey: current, loaded: journeyLoaded, canEditEntries, loadJourney, dawarich, dawarichByDate, dawarichTrail,
|
||||
tripDates, sidebarMapItems, photoCount: mapPhotos.length, showTripTracks, allLayers: isMobile,
|
||||
tripDates, sidebarMapItems, photoCount: mapPhotos.length, showTripTracks,
|
||||
})
|
||||
|
||||
/** Studio's margin to the window on all four sides — see `.st-root` in studio.css. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useJourneyMapLayers } from '../../components/Journey/useJourneyMapLayer
|
||||
import { useJourneyRecordingPrep } from '../../components/Journey/useJourneyRecordingPrep';
|
||||
import type { useDawarichJournalTrail } from '../../hooks/useDawarichJournalTrail';
|
||||
import type { useDawarichSuggestions } from '../../hooks/useDawarichSuggestions';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useAddonStore } from '../../store/addonStore';
|
||||
import type { JourneyDetail } from '../../store/journeyStore';
|
||||
import { useJourneyDetailDayImport } from './useJourneyDetailDayImport';
|
||||
@@ -30,7 +31,6 @@ export function useJourneyDetailLayers({
|
||||
sidebarMapItems,
|
||||
photoCount,
|
||||
showTripTracks,
|
||||
allLayers = false,
|
||||
}: {
|
||||
journey: JourneyDetail | null;
|
||||
/** Whether `journey` is the one the route names yet: the store keeps the last one while the next loads. */
|
||||
@@ -44,8 +44,6 @@ export function useJourneyDetailLayers({
|
||||
sidebarMapItems: ReadonlyArray<{ id: string; entry_date: string; dayColor: string }>;
|
||||
photoCount: number;
|
||||
showTripTracks: boolean;
|
||||
/** The phone: every map layer on, as it offers no switches (see useJourneyMapLayers). */
|
||||
allLayers?: boolean;
|
||||
}) {
|
||||
// The editor's own status, the owner's switch, and where each day of the trips stands.
|
||||
const isAddonEnabled = useAddonStore((s) => s.isEnabled);
|
||||
@@ -114,7 +112,7 @@ export function useJourneyDetailLayers({
|
||||
recordedRead,
|
||||
colorOfDay,
|
||||
dateOfEntry,
|
||||
allLayers,
|
||||
allLayers: useIsMobile(),
|
||||
});
|
||||
|
||||
// The drafts a day's take-over wrote reach the timeline, and its stays leave the pending list.
|
||||
|
||||
Reference in New Issue
Block a user