mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user