diff --git a/client/scripts/theme-baseline.json b/client/scripts/theme-baseline.json index 3e7cb2314..0dd0fddee 100644 --- a/client/scripts/theme-baseline.json +++ b/client/scripts/theme-baseline.json @@ -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, diff --git a/client/src/mobile/screens/journey/MJourneySettingsSheet.tsx b/client/src/mobile/screens/journey/MJourneySettingsSheet.tsx index 4d80a9cb0..0fe218174 100644 --- a/client/src/mobile/screens/journey/MJourneySettingsSheet.tsx +++ b/client/src/mobile/screens/journey/MJourneySettingsSheet.tsx @@ -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 ( - -
- {t('journey.settings.title')} - - - + +
+ } + title={t('journey.settings.title')} + sub={journey.title} + onClose={onClose} + closeLabel={t('common.close')} + />
-
- {/* Cover */} -
{t('journey.settings.coverImage')}
- -
- {coverSrc ? ( - - ) : ( -
- )} - -
+
+ + +
+ {coverSrc ? ( + + ) : ( +
+ )} + +
+ - {/* Name + subtitle */} -
{t('journey.settings.name')}
- setTitle(e.target.value)} className={`${inputShell} text-[0.84375rem] font-semibold`} /> -
{t('journey.settings.subtitle')}
- setSubtitle(e.target.value)} - placeholder={t('journey.settings.subtitlePlaceholder')} - className={`${inputShell} text-[0.8125rem] font-medium`} - /> + + setTitle(e.target.value)} aria-label={t('journey.settings.name')} className={`${SHEET_FIELD} w-full flex-none font-semibold`} /> + {t('journey.settings.subtitle')} + setSubtitle(e.target.value)} + placeholder={t('journey.settings.subtitlePlaceholder')} + aria-label={t('journey.settings.subtitle')} + className={`${SHEET_FIELD} w-full flex-none`} + /> + {/* Trip GPX tracks on the journey map (#2194) */} -
{t('journey.settings.tracks')}
-
- -
-
{t('journey.settings.showTripTracks')}
-
{t('journey.settings.showTripTracksHint')}
-
- + } + label={t('journey.settings.showTripTracks')} + hint={t('journey.settings.showTripTracksHint')} checked={!!journey.show_trip_tracks} onChange={handleTracksToggle} disabled={savingTracks} - ariaLabel={t('journey.settings.showTripTracks')} /> -
+ + {recordingPrep && ( - <> -
{t('journey.settings.recordings')}
+ - + )} {/* The three fields a journey may put away (discussion #2299) */} -
{t('journey.settings.entryFields')}
-
{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]) => ( -
-
{label}
- + {([ + ['show_verdict', t('journey.settings.showVerdict')], + ['show_mood', t('journey.settings.showMood')], + ['show_weather', t('journey.settings.showWeather')], + ] as const).map(([field, label]) => ( + handleFieldToggle(field, next)} disabled={savingField !== null} - ariaLabel={label} /> -
- ))} - - {/* Only when there is something to bring back */} - {onRestoreSuggestions && (journey.dismissed_count ?? 0) > 0 && ( - - )} - - {/* Synced trips */} -
{t('journey.detail.syncedTrips')}
- {journey.trips.map(trip => ( -
- -
-
{trip.title}
-
- {t('mobileJourney.placesCount', { count: trip.place_count ?? 0 })} -
-
- -
- ))} - {journey.trips.length === 0 && ( -

{t('journey.trips.noTripsLinkedSettings')}

- )} - - {addTripOpen && ( -
- {unlinkedTrips.length === 0 && ( -

{t('journey.trips.noTripsAvailable')}

- )} - {unlinkedTrips.map(trip => ( -
- - {trip.title} - -
- ))} -
- )} + )} + - {/* Contributors */} -
{t('journey.detail.contributors')}
- {journey.contributors.map(c => ( -
- - {(c.username || '?')[0].toUpperCase()} - - {c.username} - - {c.role} - - {c.role !== 'owner' && ( + + {journey.trips.map(trip => ( +
+ +
+
{trip.title}
+
+ {t('mobileJourney.placesCount', { count: trip.place_count ?? 0 })} +
+
- )} -
- ))} - - - - - {/* Public share */} -
{t('journey.share.publicShare')}
- {!shareLink ? ( -
+ ))} + {journey.trips.length === 0 && ( +

{t('journey.trips.noTripsLinkedSettings')}

+ )} + - ) : ( - <> - {shareUrl && ( - - )} - {([ - { 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 }) => ( -
- - {label} - toggleSharePerm(key)} ariaLabel={label} /> -
- ))} - - - - )} + {addTripOpen && ( +
+ {unlinkedTrips.length === 0 && ( +

{t('journey.trips.noTripsAvailable')}

+ )} + {unlinkedTrips.map(trip => ( +
+ + {trip.title} + +
+ ))} +
+ )} + - {/* Archive */} - + )} +
+ ))} + +
+ + + + + {!shareLink ? ( + + ) : ( + <> + {shareUrl && ( + + )} + {([ + { 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 }) => ( + } + label={label} + checked={shareLink[key]} + onChange={() => toggleSharePerm(key)} + /> + ))} + + + + )} + + +
-
+
- -
@@ -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 ( - <> -
{t('journey.contributors.viaTrips')}
-

{t('journey.contributors.viaTripsHint')}

+ {access.map(a => ( -
- +
+ + + - {a.username} - {t('journey.contributors.viaTrip', { trip: a.trip_title })} - - - {a.role} + {a.username} + {t('journey.contributors.viaTrip', { trip: a.trip_title })} + {a.role}
))} - + + ) +} + +/** 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 ( +
+ {icon && {icon}} +
+
{label}
+ {hint &&
{hint}
} +
+ +
) } diff --git a/client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx b/client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx index 98c39b17f..59ba8b556 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSettingsSheet.tsx @@ -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 ( -
-
- {title} - {count != null && ( - - {count} - - )} -
- {hint && ( -

- {hint} -

- )} -
{children}
-
- ); -} - /** A person of the trip: the avatar, the name with its badges, and the round actions. */ function PersonRow({ name, diff --git a/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx b/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx index d74a9d9d5..01f4f83f8 100644 --- a/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx +++ b/client/src/mobile/screens/trip/sheets/MTripSheetUi.tsx @@ -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 ( +
+
+ {title} + {count != null && ( + + {count} + + )} +
+ {hint && ( +

+ {hint} +

+ )} +
{children}
+
+ ) +} + interface TileHeaderProps { icon: ReactNode title: ReactNode