feat(mobile): journey settings in the trip settings' style

The journey settings sheet looked like a different app next to the trip
settings. It now uses the same parts: the glass card with the icon tile
header and the journey's name under the title, sections as inner cards with
a title, count badge and hint, 42 px round fields and buttons, the shared
link field, and switch rows for tracks, entry fields and the public link.
Those parts (SheetSection and the SHEET_* button and field classes) moved
into MTripSheetUi for every sheet to use.

Fields and buttons sitting directly in a column no longer shrink below
their height; name, subtitle and the archive button were squeezed flat.
This commit is contained in:
Maurice
2026-11-06 16:07:57 +01:00
parent 6e362de3da
commit ec87f280c3
4 changed files with 296 additions and 281 deletions
+1 -1
View File
@@ -190,7 +190,7 @@
"mobile/screens/journey/MJourneyCreateSheet.tsx": 10,
"mobile/screens/journey/MJourneyDetail.tsx": 6,
"mobile/screens/journey/MJourneyEntrySheet.tsx": 52,
"mobile/screens/journey/MJourneySettingsSheet.tsx": 29,
"mobile/screens/journey/MJourneySettingsSheet.tsx": 2,
"mobile/screens/journey/mobileJourneyMeta.ts": 4,
"mobile/screens/notifications/MNotificationRow.tsx": 4,
"mobile/screens/notifications/MNotifications.tsx": 3,
@@ -1,8 +1,17 @@
import { useRef, useState } from 'react'
import { X, Image, Plus, Trash2, UserPlus, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2, ListChecks, RefreshCw } from 'lucide-react'
import { useRef, useState, type ReactNode } from 'react'
import { Image, Plus, Trash2, UserPlus, UserMinus, Unlink, Settings2, Link as LinkIcon, List, Grid3x3, MapPin, Route, Archive, ArchiveRestore, Undo2, Link2, ListChecks, RefreshCw } from 'lucide-react'
import MLinkField from '../../components/MLinkField'
import MSheet from '../../components/MSheet'
import MIconBtn from '../../components/MIconBtn'
import {
Eyebrow,
SHEET_FIELD,
SHEET_PILL,
SHEET_PRIMARY,
SHEET_ROUND,
SHEET_SECONDARY,
SheetSection,
TileHeader,
} from '../trip/sheets/MTripSheetUi'
import MToggle from '../../components/MToggle'
import ConfirmDialog from '../../../components/shared/ConfirmDialog'
import { useTranslation } from '../../../i18n'
@@ -58,266 +67,239 @@ export default function MJourneySettingsSheet({
if (availableTrips.length === 0) await loadAvailableTrips()
}
const eyebrow = 'font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint'
const inputShell = 'w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[13px] py-[11px] text-m-ink outline-none placeholder:text-m-faint'
const dashedBtn = 'flex w-full items-center justify-center gap-[6px] rounded-xl border border-dashed border-[color:var(--m-rowbr)] p-[11px] text-[0.75rem] font-semibold text-m-muted'
const solidBtn = 'flex w-full items-center justify-center gap-[6px] rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[11px] text-[0.75rem] font-semibold'
const linkedTripIds = journey.trips.map(tr => tr.trip_id)
const unlinkedTrips = availableTrips.filter(tr => !linkedTripIds.includes(tr.id))
return (
<MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={t('journey.settings.title')}>
<div className="flex flex-none items-center border-b border-[color:var(--m-rowbr)] px-[18px] pb-[10px] pt-4">
<span className="flex-1 text-[1.0625rem] font-bold">{t('journey.settings.title')}</span>
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.cancel')}>
<X size={15} strokeWidth={2.2} />
</MIconBtn>
<MSheet open onClose={onClose} variant="card" material="glass" ariaLabel={t('journey.settings.title')}>
<div className="flex-none px-[18px] pt-4">
<TileHeader
icon={<Settings2 size={19} strokeWidth={1.8} />}
title={t('journey.settings.title')}
sub={journey.title}
onClose={onClose}
closeLabel={t('common.close')}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-3">
{/* Cover */}
<div className={`${eyebrow} mb-[6px]`}>{t('journey.settings.coverImage')}</div>
<input ref={coverRef} type="file" accept="image/*" className="hidden" onChange={handleCoverUpload} />
<div className="relative h-[120px] overflow-hidden rounded-2xl">
{coverSrc ? (
<img src={coverSrc} alt="" className="h-full w-full object-cover" />
) : (
<div className="h-full w-full" style={{ background: pickGradient(journey.id) }} />
)}
<button
type="button"
onClick={() => coverRef.current?.click()}
className="absolute inset-0 flex items-center justify-center gap-[6px] bg-black/30 text-[0.78125rem] font-bold text-white"
>
<Image size={14} strokeWidth={2.2} />
{coverSrc ? t('journey.settings.changeCover') : t('journey.settings.addCover')}
</button>
</div>
<div className="m-scroll-fade mt-2 flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
<SheetSection title={t('journey.settings.coverImage')}>
<input ref={coverRef} type="file" accept="image/*" className="hidden" onChange={handleCoverUpload} />
<div className="relative h-[120px] overflow-hidden rounded-[14px]">
{coverSrc ? (
<img src={coverSrc} alt="" className="h-full w-full object-cover" />
) : (
<div className="h-full w-full" style={{ background: pickGradient(journey.id) }} />
)}
<button
type="button"
onClick={() => coverRef.current?.click()}
className="absolute inset-0 flex items-center justify-center gap-[6px] bg-black/30 text-caption font-bold text-white"
>
<Image size={14} strokeWidth={2.2} />
{coverSrc ? t('journey.settings.changeCover') : t('journey.settings.addCover')}
</button>
</div>
</SheetSection>
{/* Name + subtitle */}
<div className={`${eyebrow} mb-[5px] mt-[14px]`}>{t('journey.settings.name')}</div>
<input value={title} onChange={e => setTitle(e.target.value)} className={`${inputShell} text-[0.84375rem] font-semibold`} />
<div className={`${eyebrow} mb-[5px] mt-3`}>{t('journey.settings.subtitle')}</div>
<input
value={subtitle}
onChange={e => setSubtitle(e.target.value)}
placeholder={t('journey.settings.subtitlePlaceholder')}
className={`${inputShell} text-[0.8125rem] font-medium`}
/>
<SheetSection title={t('journey.settings.name')}>
<input value={title} onChange={e => setTitle(e.target.value)} aria-label={t('journey.settings.name')} className={`${SHEET_FIELD} w-full flex-none font-semibold`} />
<Eyebrow className="mt-1 uppercase">{t('journey.settings.subtitle')}</Eyebrow>
<input
value={subtitle}
onChange={e => setSubtitle(e.target.value)}
placeholder={t('journey.settings.subtitlePlaceholder')}
aria-label={t('journey.settings.subtitle')}
className={`${SHEET_FIELD} w-full flex-none`}
/>
</SheetSection>
{/* Trip GPX tracks on the journey map (#2194) */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.settings.tracks')}</div>
<div className="flex items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
<Route size={16} strokeWidth={2} className="flex-none text-m-muted" />
<div className="min-w-0 flex-1">
<div className="text-[0.8125rem] font-bold">{t('journey.settings.showTripTracks')}</div>
<div className="font-geist text-[0.625rem] text-m-muted">{t('journey.settings.showTripTracksHint')}</div>
</div>
<MToggle
<SheetSection title={t('journey.settings.tracks')}>
<ToggleRow
icon={<Route size={16} strokeWidth={2} />}
label={t('journey.settings.showTripTracks')}
hint={t('journey.settings.showTripTracksHint')}
checked={!!journey.show_trip_tracks}
onChange={handleTracksToggle}
disabled={savingTracks}
ariaLabel={t('journey.settings.showTripTracks')}
/>
</div>
</SheetSection>
{recordingPrep && (
<>
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.settings.recordings')}</div>
<SheetSection title={t('journey.settings.recordings')}>
<MRecordingPrepRow prep={recordingPrep} enabled={!!journey.auto_prepare_recordings} />
</>
</SheetSection>
)}
{/* The three fields a journey may put away (discussion #2299) */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.settings.entryFields')}</div>
<div className="mb-[6px] font-geist text-[0.625rem] leading-[1.45] text-m-muted">{t('journey.settings.entryFieldsHint')}</div>
{([
['show_verdict', t('journey.settings.showVerdict')],
['show_mood', t('journey.settings.showMood')],
['show_weather', t('journey.settings.showWeather')],
] as const).map(([field, label]) => (
<div key={field} className="mb-[6px] flex items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
<div className="min-w-0 flex-1 text-[0.8125rem] font-bold">{label}</div>
<MToggle
<SheetSection title={t('journey.settings.entryFields')} hint={t('journey.settings.entryFieldsHint')}>
{([
['show_verdict', t('journey.settings.showVerdict')],
['show_mood', t('journey.settings.showMood')],
['show_weather', t('journey.settings.showWeather')],
] as const).map(([field, label]) => (
<ToggleRow
key={field}
label={label}
checked={journey[field] !== 0}
onChange={next => handleFieldToggle(field, next)}
disabled={savingField !== null}
ariaLabel={label}
/>
</div>
))}
{/* Only when there is something to bring back */}
{onRestoreSuggestions && (journey.dismissed_count ?? 0) > 0 && (
<button
type="button"
onClick={() => { void onRestoreSuggestions() }}
className="mb-[6px] flex w-full items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-start"
>
<Undo2 size={16} strokeWidth={2} className="flex-none text-m-muted" />
<div className="min-w-0 flex-1">
<div className="text-[0.8125rem] font-bold">{t('journey.suggestions.restore')}</div>
<div className="font-geist text-[0.625rem] text-m-muted">
{t('journey.suggestions.restoreCount', { count: journey.dismissed_count ?? 0 })}
</div>
</div>
</button>
)}
{/* Synced trips */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.detail.syncedTrips')}</div>
{journey.trips.map(trip => (
<div key={trip.trip_id} className="mb-[6px] flex items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
<span className="h-9 w-9 flex-none rounded-[10px]" style={{ background: pickGradient(trip.trip_id) }} />
<div className="min-w-0 flex-1">
<div className="truncate text-[0.8125rem] font-bold">{trip.title}</div>
<div className="font-geist text-[0.625rem] text-m-muted">
{t('mobileJourney.placesCount', { count: trip.place_count ?? 0 })}
</div>
</div>
<button
type="button"
onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}
aria-label={t('journey.trips.unlinkTrip')}
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-[rgba(214,39,59,.1)] text-[color:var(--m-st-danger)]"
>
<Trash2 size={13} strokeWidth={2} />
))}
{/* Only when there is something to bring back */}
{onRestoreSuggestions && (journey.dismissed_count ?? 0) > 0 && (
<button type="button" onClick={() => { void onRestoreSuggestions() }} className={SHEET_SECONDARY}>
<Undo2 size={14} strokeWidth={2.2} />
{t('journey.suggestions.restore')}
<span className="text-m-muted">{journey.dismissed_count ?? 0}</span>
</button>
</div>
))}
{journey.trips.length === 0 && (
<p className="mb-[6px] font-geist text-[0.6875rem] text-m-faint">{t('journey.trips.noTripsLinkedSettings')}</p>
)}
<button type="button" onClick={openAddTrip} className={`${dashedBtn} mt-2`}>
<Plus size={13} strokeWidth={2.2} />
{t('journey.trips.addTrip')}
</button>
{addTripOpen && (
<div className="mt-2 max-h-[180px] overflow-y-auto rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-2">
{unlinkedTrips.length === 0 && (
<p className="py-2 text-center font-geist text-[0.6875rem] text-m-faint">{t('journey.trips.noTripsAvailable')}</p>
)}
{unlinkedTrips.map(trip => (
<div key={trip.id} className="flex items-center gap-[10px] rounded-[10px] px-2 py-[7px]">
<span className="h-7 w-7 flex-none rounded-lg" style={{ background: pickGradient(trip.id) }} />
<span className="min-w-0 flex-1 truncate text-[0.78125rem] font-semibold">{trip.title}</span>
<button
type="button"
onClick={() => linkTrip(trip.id)}
disabled={linkingTripId === trip.id}
className="rounded-full bg-m-act px-3 py-[5px] font-geist text-[0.65625rem] font-bold text-m-actfg disabled:opacity-50"
>
{t('journey.trips.link')}
</button>
</div>
))}
</div>
)}
)}
</SheetSection>
{/* Contributors */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.detail.contributors')}</div>
{journey.contributors.map(c => (
<div key={c.user_id} className="flex items-center gap-[10px] px-[2px] py-1">
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-m-act text-[0.75rem] font-extrabold text-m-actfg">
{(c.username || '?')[0].toUpperCase()}
</span>
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-bold">{c.username}</span>
<span
className={`rounded-full px-[10px] py-[3px] font-geist text-[0.5625rem] font-extrabold ${
c.role === 'owner' ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'
}`}
>
{c.role}
</span>
{c.role !== 'owner' && (
<SheetSection title={t('journey.detail.syncedTrips')} count={journey.trips.length || undefined}>
{journey.trips.map(trip => (
<div key={trip.trip_id} className="flex items-center gap-2.5">
<span className="h-[42px] w-[42px] flex-none rounded-[13px]" style={{ background: pickGradient(trip.trip_id) }} />
<div className="min-w-0 flex-1">
<div className="truncate text-body font-semibold text-m-ink">{trip.title}</div>
<div className="font-geist text-caption text-m-muted">
{t('mobileJourney.placesCount', { count: trip.place_count ?? 0 })}
</div>
</div>
<button
type="button"
onClick={() => handleRemoveContributor(c)}
aria-label={t('journey.contributors.remove')}
className="flex h-7 w-7 flex-none items-center justify-center rounded-full text-m-faint"
onClick={() => setUnlinkTarget({ trip_id: trip.trip_id, title: trip.title })}
aria-label={t('journey.trips.unlinkTrip')}
className={SHEET_ROUND}
>
<X size={13} />
<Unlink size={15} strokeWidth={2} />
</button>
)}
</div>
))}
<button type="button" onClick={onOpenInvite} className={`${solidBtn} mt-2`}>
<UserPlus size={13} strokeWidth={2.2} />
{t('journey.contributors.invite')}
</button>
<MTripAccessList access={journey.trip_access} eyebrow={eyebrow} />
{/* Public share */}
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.share.publicShare')}</div>
{!shareLink ? (
<button type="button" onClick={createShareLink} className={solidBtn}>
<LinkIcon size={13} strokeWidth={2.2} />
{t('journey.share.createLink')}
</div>
))}
{journey.trips.length === 0 && (
<p className="font-geist text-caption text-m-faint">{t('journey.trips.noTripsLinkedSettings')}</p>
)}
<button type="button" onClick={openAddTrip} className={SHEET_SECONDARY}>
<Plus size={14} strokeWidth={2.2} />
{t('journey.trips.addTrip')}
</button>
) : (
<>
{shareUrl && (
<MLinkField
url={shareUrl}
copied={copied}
onCopy={copyShareUrl}
copyLabel={t('journey.share.copy')}
copiedLabel={t('journey.share.copied')}
shareLabel={t('common.share')}
/>
)}
{([
{ key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
{ key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid3x3 },
{ key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
]).map(({ key, label, icon: Icon }) => (
<div key={key} className="flex items-center gap-[11px] px-[2px] py-[10px]">
<Icon size={16} strokeWidth={2} className="flex-none text-m-muted" />
<span className="min-w-0 flex-1 text-[0.84375rem] font-bold">{label}</span>
<MToggle checked={shareLink[key]} onChange={() => toggleSharePerm(key)} ariaLabel={label} />
</div>
))}
<MShareMapExtras link={shareLink} onToggle={toggleSharePerm} />
<button
type="button"
onClick={deleteShareLink}
className="flex h-[38px] w-full items-center justify-center gap-1.5 rounded-full text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-[color:var(--m-st-danger)]"
>
<Trash2 size={13} strokeWidth={2.2} /> {t('journey.share.removeLink')}
</button>
</>
)}
{addTripOpen && (
<div className="max-h-[200px] overflow-y-auto rounded-[14px] bg-[color:var(--m-ic)] p-1.5">
{unlinkedTrips.length === 0 && (
<p className="py-2 text-center font-geist text-caption text-m-faint">{t('journey.trips.noTripsAvailable')}</p>
)}
{unlinkedTrips.map(trip => (
<div key={trip.id} className="flex items-center gap-2.5 rounded-[12px] px-1.5 py-1.5">
<span className="h-8 w-8 flex-none rounded-[10px]" style={{ background: pickGradient(trip.id) }} />
<span className="min-w-0 flex-1 truncate text-body font-semibold">{trip.title}</span>
<button
type="button"
onClick={() => linkTrip(trip.id)}
disabled={linkingTripId === trip.id}
className="flex h-8 flex-none items-center rounded-full bg-m-act px-3 text-caption font-semibold text-m-actfg disabled:opacity-40"
>
{t('journey.trips.link')}
</button>
</div>
))}
</div>
)}
</SheetSection>
{/* Archive */}
<button type="button" onClick={handleArchiveToggle} disabled={archiving} className={`${solidBtn} mt-[14px] disabled:opacity-40`}>
{journey.status === 'archived' ? <ArchiveRestore size={13} strokeWidth={2.2} /> : <Archive size={13} strokeWidth={2.2} />}
<SheetSection title={t('journey.detail.contributors')} count={journey.contributors.length || undefined}>
{journey.contributors.map(c => (
<div key={c.user_id} className="flex items-center gap-2.5">
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-m-act text-body font-extrabold text-m-actfg">
{(c.username || '?')[0].toUpperCase()}
</span>
<span className="min-w-0 flex-1 truncate text-body font-semibold text-m-ink">{c.username}</span>
<span className={`${SHEET_PILL} ${c.role === 'owner' ? 'bg-m-act text-m-actfg' : 'bg-[color:var(--m-ic)] text-m-muted'}`}>
{c.role}
</span>
{c.role !== 'owner' && (
<button
type="button"
onClick={() => handleRemoveContributor(c)}
aria-label={t('journey.contributors.remove')}
className={SHEET_ROUND}
>
<UserMinus size={15} strokeWidth={2} />
</button>
)}
</div>
))}
<button type="button" onClick={onOpenInvite} className={SHEET_SECONDARY}>
<UserPlus size={14} strokeWidth={2.2} />
{t('journey.contributors.invite')}
</button>
</SheetSection>
<MTripAccessList access={journey.trip_access} />
<SheetSection title={t('journey.share.publicShare')}>
{!shareLink ? (
<button type="button" onClick={createShareLink} className={`${SHEET_PRIMARY} justify-center`}>
<LinkIcon size={14} strokeWidth={2.2} />
{t('journey.share.createLink')}
</button>
) : (
<>
{shareUrl && (
<MLinkField
url={shareUrl}
copied={copied}
onCopy={copyShareUrl}
copyLabel={t('journey.share.copy')}
copiedLabel={t('journey.share.copied')}
shareLabel={t('common.share')}
/>
)}
{([
{ key: 'share_timeline' as const, label: t('journey.share.timeline'), icon: List },
{ key: 'share_gallery' as const, label: t('journey.share.gallery'), icon: Grid3x3 },
{ key: 'share_map' as const, label: t('journey.share.map'), icon: MapPin },
]).map(({ key, label, icon: Icon }) => (
<ToggleRow
key={key}
icon={<Icon size={16} strokeWidth={2} />}
label={label}
checked={shareLink[key]}
onChange={() => toggleSharePerm(key)}
/>
))}
<MShareMapExtras link={shareLink} onToggle={toggleSharePerm} />
<button
type="button"
onClick={deleteShareLink}
className="flex h-[38px] w-full items-center justify-center gap-1.5 rounded-full text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-[color:var(--m-st-danger)]"
>
<Trash2 size={13} strokeWidth={2.2} /> {t('journey.share.removeLink')}
</button>
</>
)}
</SheetSection>
<button type="button" onClick={handleArchiveToggle} disabled={archiving} className={`${SHEET_SECONDARY} flex-none`}>
{journey.status === 'archived' ? <ArchiveRestore size={14} strokeWidth={2.2} /> : <Archive size={14} strokeWidth={2.2} />}
{journey.status === 'archived' ? t('journey.settings.reopenJourney') : t('journey.settings.endJourney')}
</button>
</div>
<div className="flex flex-none items-center gap-3 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<div className="flex flex-none items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-[5px] text-[0.75rem] font-bold text-[color:var(--m-st-danger)]"
aria-label={t('journey.settings.delete')}
className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-[color:var(--m-st-danger)]"
>
<Trash2 size={13} strokeWidth={2} />
{t('journey.settings.delete')}
<Trash2 size={15} strokeWidth={2} />
</button>
<button
type="button"
onClick={onClose}
className="ms-auto rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold"
>
<button type="button" onClick={onClose} className={`${SHEET_SECONDARY} ms-auto px-4`}>
{t('common.cancel')}
</button>
<button
type="button"
onClick={handleSave}
disabled={saving || !title.trim()}
className="rounded-full bg-m-act px-[18px] py-[9px] text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-40"
>
<button type="button" onClick={handleSave} disabled={saving || !title.trim()} className={SHEET_PRIMARY}>
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
@@ -346,26 +328,45 @@ export default function MJourneySettingsSheet({
}
/** Who a trip's linked journal lets in (#2683): shown under the contributors, but changed in that trip. */
function MTripAccessList({ access, eyebrow }: { access: JourneyTripAccess[] | undefined; eyebrow: string }) {
function MTripAccessList({ access }: { access: JourneyTripAccess[] | undefined }) {
const { t } = useTranslation()
if (!access?.length) return null
return (
<>
<div className={`${eyebrow} mb-[6px] mt-[14px]`}>{t('journey.contributors.viaTrips')}</div>
<p className="m-0 mb-[6px] text-caption text-m-faint">{t('journey.contributors.viaTripsHint')}</p>
<SheetSection title={t('journey.contributors.viaTrips')} hint={t('journey.contributors.viaTripsHint')} count={access.length}>
{access.map(a => (
<div key={`${a.user_id}-${a.trip_id}`} className="flex items-center gap-[10px] px-[2px] py-1">
<Link2 size={15} className="flex-none text-m-muted" />
<div key={`${a.user_id}-${a.trip_id}`} className="flex items-center gap-2.5">
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted">
<Link2 size={15} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-caption font-bold">{a.username}</span>
<span className="block truncate text-caption text-m-faint">{t('journey.contributors.viaTrip', { trip: a.trip_title })}</span>
</span>
<span className="rounded-full bg-[color:var(--m-ic)] px-[10px] py-[3px] font-geist text-caption font-extrabold text-m-muted">
{a.role}
<span className="block truncate text-body font-semibold text-m-ink">{a.username}</span>
<span className="block truncate font-geist text-caption text-m-faint">{t('journey.contributors.viaTrip', { trip: a.trip_title })}</span>
</span>
<span className={`${SHEET_PILL} bg-[color:var(--m-ic)] text-m-muted`}>{a.role}</span>
</div>
))}
</>
</SheetSection>
)
}
/** A switch with its label, an optional icon in front and an optional line under the label. */
function ToggleRow({ icon, label, hint, checked, onChange, disabled }: {
icon?: ReactNode
label: string
hint?: string
checked: boolean
onChange: (next: boolean) => void
disabled?: boolean
}) {
return (
<div className="flex items-center gap-[11px] rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
{icon && <span className="flex-none text-m-muted">{icon}</span>}
<div className="min-w-0 flex-1">
<div className="text-body font-semibold text-m-ink">{label}</div>
{hint && <div className="font-geist text-caption text-m-muted">{hint}</div>}
</div>
<MToggle checked={checked} onChange={onChange} disabled={disabled} ariaLabel={label} />
</div>
)
}
@@ -29,7 +29,15 @@ import MChip from '../../../components/MChip';
import MLinkField from '../../../components/MLinkField';
import MSheet from '../../../components/MSheet';
import MConfirmSheet from '../../settings/MConfirmSheet';
import { Eyebrow, INNER_CLS, TileHeader } from './MTripSheetUi';
import {
SHEET_FIELD as FIELD,
SHEET_PILL as PILL,
SHEET_PRIMARY as PRIMARY,
SHEET_ROUND as ROUND,
SHEET_ROUND_PRIMARY as ROUND_PRIMARY,
SheetSection as Section,
TileHeader,
} from './MTripSheetUi';
interface MTripSettingsSheetProps {
open: boolean;
@@ -44,52 +52,6 @@ type Question =
| { kind: 'remove'; id: number; self: boolean }
| { kind: 'guest'; id: number };
const FIELD =
'h-[42px] min-w-0 flex-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] text-[length:calc(13px*var(--fs-scale-body,1))] font-medium text-m-ink outline-none placeholder:text-m-faint';
const PRIMARY =
'flex h-[42px] flex-none items-center gap-1.5 rounded-full bg-m-act px-4 text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-actfg disabled:opacity-40';
/** The round icon buttons next to a link: copy, share. */
/** The round, filled icon button next to a picker or field: invite, add. */
const ROUND_PRIMARY =
'flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg disabled:opacity-40';
const ROUND =
'flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted disabled:opacity-40';
const PILL =
'flex h-[22px] flex-none items-center gap-[3px] rounded-full px-[8px] font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.06em]';
/** A titled block of the sheet: the eyebrow, an optional line under it and the content on the inner surface. */
function Section({
title,
count,
hint,
children,
}: {
title: string;
/** How many there are, as a small badge next to the title rather than glued onto it. */
count?: number;
hint?: string;
children: ReactNode;
}) {
return (
<section className={`rounded-[18px] p-3 ${INNER_CLS}`}>
<div className="flex items-center gap-1.5">
<Eyebrow className="uppercase">{title}</Eyebrow>
{count != null && (
<span className="flex h-[16px] min-w-[16px] items-center justify-center rounded-full bg-[color:var(--m-ic)] px-[5px] font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold text-m-muted">
{count}
</span>
)}
</div>
{hint && (
<p className="mt-1 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] leading-snug text-m-muted">
{hint}
</p>
)}
<div className="mt-2.5 flex flex-col gap-2">{children}</div>
</section>
);
}
/** A person of the trip: the avatar, the name with its badges, and the round actions. */
function PersonRow({
name,
@@ -17,6 +17,58 @@ export function Eyebrow({ children, className = '' }: { children: ReactNode; cla
)
}
/** A text field of the sheets: 42 px, round, on the icon surface. */
export const SHEET_FIELD =
'h-[42px] min-w-0 flex-1 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] text-[length:calc(13px*var(--fs-scale-body,1))] font-medium text-m-ink outline-none placeholder:text-m-faint'
/** The filled action with a label. Never icon-only: its padding leaves an icon no room in a circle, use SHEET_ROUND_PRIMARY. */
export const SHEET_PRIMARY =
'flex h-[42px] flex-none items-center gap-1.5 rounded-full bg-m-act px-4 text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-actfg disabled:opacity-40'
/** The round, filled icon button next to a picker or field: invite, add. */
export const SHEET_ROUND_PRIMARY =
'flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg disabled:opacity-40'
/** The quiet action with a label: add, invite, end. Full width in a section. */
export const SHEET_SECONDARY =
'flex h-[42px] items-center justify-center gap-1.5 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-ink disabled:opacity-40'
/** The quiet round icon action of a row: remove, rename, make owner. */
export const SHEET_ROUND =
'flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-muted disabled:opacity-40'
/** A small badge inside a row (owner, you). */
export const SHEET_PILL =
'flex h-[22px] flex-none items-center gap-[3px] rounded-full px-[8px] font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.06em]'
/** A titled block of the sheet: the eyebrow, an optional line under it and the content on the inner surface. */
export function SheetSection({
title,
count,
hint,
children,
}: {
title: string
/** How many there are, as a small badge next to the title rather than glued onto it. */
count?: number
hint?: string
children: ReactNode
}) {
return (
<section className={`rounded-[18px] p-3 ${INNER_CLS}`}>
<div className="flex items-center gap-1.5">
<Eyebrow className="uppercase">{title}</Eyebrow>
{count != null && (
<span className="flex h-[16px] min-w-[16px] items-center justify-center rounded-full bg-[color:var(--m-ic)] px-[5px] font-geist text-[length:calc(9.5px*var(--fs-scale-caption,1))] font-bold text-m-muted">
{count}
</span>
)}
</div>
{hint && (
<p className="mt-1 font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] leading-snug text-m-muted">
{hint}
</p>
)}
<div className="mt-2.5 flex flex-col gap-2">{children}</div>
</section>
)
}
interface TileHeaderProps {
icon: ReactNode
title: ReactNode