mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51:00 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f81fe8cb17 | |||
| 7e8e342e29 | |||
| 445fb173fb | |||
| b317877c41 | |||
| 27c19b5ff5 | |||
| ffd848e5dc | |||
| 45361c276c | |||
| 1b24e3dcef | |||
| 69d88ffefd | |||
| 92fc8ef28f | |||
| 2aa25e3fe7 | |||
| f6bcdb8e6f | |||
| 867a52bc0d | |||
| ec87f280c3 | |||
| 6e362de3da | |||
| 654900bc50 | |||
| 75d0a6181b | |||
| 6de15a92a4 | |||
| b15dcd9730 | |||
| 821f23aed4 | |||
| 79420cd945 | |||
| e3541535e2 |
@@ -29,8 +29,20 @@ class TrekBridgeViewController: CAPBridgeViewController {
|
||||
return configuration
|
||||
}
|
||||
|
||||
/// Kept alive here: WKWebView holds its navigation delegate weakly.
|
||||
private var navigationProxy: TrekNavigationProxy?
|
||||
|
||||
override func capacitorDidLoad() {
|
||||
TrekTileStore.shared.prune()
|
||||
// Capacitor shows its error page for every failed navigation, a replaced
|
||||
// one included: the UI moving to /login while the first page still loads
|
||||
// cancels that load (-999), and a first-time user landed on "Server not
|
||||
// reachable" with a server that was there all along.
|
||||
if let webView, let inner = webView.navigationDelegate {
|
||||
let proxy = TrekNavigationProxy(inner: inner)
|
||||
navigationProxy = proxy
|
||||
webView.navigationDelegate = proxy
|
||||
}
|
||||
bridge?.registerPluginInstance(TrekShellPlugin())
|
||||
// WebKit's own swipe back slides away a screenshot of the whole page,
|
||||
// closes no sheet and takes every drag near the edge from the UI. The UI
|
||||
@@ -272,3 +284,39 @@ final class TrekTileSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
task.didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stands in front of Capacitor's navigation delegate and hands it everything
|
||||
/// but the failures of a navigation that another one replaced: a cancelled
|
||||
/// load (NSURLErrorCancelled) or an interrupted frame load (WebKit 102). Those
|
||||
/// are no fault, and Capacitor would answer them with its error page.
|
||||
final class TrekNavigationProxy: NSObject, WKNavigationDelegate {
|
||||
private let inner: WKNavigationDelegate
|
||||
|
||||
init(inner: WKNavigationDelegate) {
|
||||
self.inner = inner
|
||||
}
|
||||
|
||||
static func isReplaced(_ error: Error) -> Bool {
|
||||
let error = error as NSError
|
||||
return (error.domain == NSURLErrorDomain && error.code == NSURLErrorCancelled)
|
||||
|| (error.domain == "WebKitErrorDomain" && error.code == 102)
|
||||
}
|
||||
|
||||
override func responds(to aSelector: Selector!) -> Bool {
|
||||
super.responds(to: aSelector) || (inner as AnyObject).responds(to: aSelector)
|
||||
}
|
||||
|
||||
override func forwardingTarget(for aSelector: Selector!) -> Any? {
|
||||
(inner as AnyObject).responds(to: aSelector) ? inner : nil
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.isReplaced(error) { return }
|
||||
inner.webView?(webView, didFail: navigation, withError: error)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
if Self.isReplaced(error) { return }
|
||||
inner.webView?(webView, didFailProvisionalNavigation: navigation, withError: error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,31 +166,31 @@
|
||||
"mobile/screens/admin/MAdminUsersSection.tsx": 14,
|
||||
"mobile/screens/admin/MApiProviderBlocks.tsx": 10,
|
||||
"mobile/screens/atlas/MAtlas.tsx": 5,
|
||||
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 16,
|
||||
"mobile/screens/atlas/MAtlasBucketSheet.tsx": 14,
|
||||
"mobile/screens/atlas/MAtlasCountryPopup.tsx": 11,
|
||||
"mobile/screens/atlas/MAtlasSearch.tsx": 5,
|
||||
"mobile/screens/atlas/MAtlasStatsCard.tsx": 3,
|
||||
"mobile/screens/collections/MCollAddSheet.tsx": 7,
|
||||
"mobile/screens/collections/MCollAddSheet.tsx": 6,
|
||||
"mobile/screens/collections/MCollCategoryPicker.tsx": 2,
|
||||
"mobile/screens/collections/MCollEditSheet.tsx": 5,
|
||||
"mobile/screens/collections/MCollEditSheet.tsx": 2,
|
||||
"mobile/screens/collections/MCollLabelsSheet.tsx": 6,
|
||||
"mobile/screens/collections/MCollLinksEditor.tsx": 2,
|
||||
"mobile/screens/collections/MCollListPickerSheet.tsx": 3,
|
||||
"mobile/screens/collections/MCollPlaceRow.tsx": 3,
|
||||
"mobile/screens/collections/MCollPlaceSheet.tsx": 11,
|
||||
"mobile/screens/collections/MCollShareSheet.tsx": 14,
|
||||
"mobile/screens/collections/MCollSheetKit.tsx": 6,
|
||||
"mobile/screens/collections/MCollSheetKit.tsx": 5,
|
||||
"mobile/screens/collections/MCollTripPickerSheet.tsx": 4,
|
||||
"mobile/screens/collections/MCollections.tsx": 21,
|
||||
"mobile/screens/collections/MCollections.tsx": 18,
|
||||
"mobile/screens/dashboard/MDashWidgets.tsx": 22,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 36,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 20,
|
||||
"mobile/screens/dashboard/MDashboard.tsx": 31,
|
||||
"mobile/screens/dashboard/MNewTripSheet.tsx": 12,
|
||||
"mobile/screens/dashboard/MUserMenu.tsx": 7,
|
||||
"mobile/screens/journey/MJourney.tsx": 10,
|
||||
"mobile/screens/journey/MJourneyCreateSheet.tsx": 10,
|
||||
"mobile/screens/journey/MJourneyCreateSheet.tsx": 7,
|
||||
"mobile/screens/journey/MJourneyDetail.tsx": 6,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 52,
|
||||
"mobile/screens/journey/MJourneySettingsSheet.tsx": 32,
|
||||
"mobile/screens/journey/MJourneyEntrySheet.tsx": 48,
|
||||
"mobile/screens/journey/MJourneySettingsSheet.tsx": 2,
|
||||
"mobile/screens/journey/mobileJourneyMeta.ts": 4,
|
||||
"mobile/screens/notifications/MNotificationRow.tsx": 4,
|
||||
"mobile/screens/notifications/MNotifications.tsx": 3,
|
||||
@@ -201,13 +201,13 @@
|
||||
"mobile/screens/settings/MMobileNavCustomizer.tsx": 8,
|
||||
"mobile/screens/settings/MPhotoProvidersSection.tsx": 2,
|
||||
"mobile/screens/settings/MScopeGroupPicker.tsx": 5,
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 1,
|
||||
"mobile/screens/settings/MSettings.tsx": 2,
|
||||
"mobile/screens/settings/MSettingsAbout.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 20,
|
||||
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
|
||||
"mobile/screens/settings/MSettingsMap.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsMcp.tsx": 30,
|
||||
"mobile/screens/settings/MSettingsMcp.tsx": 26,
|
||||
"mobile/screens/settings/MSettingsNotifications.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsOffline.tsx": 18,
|
||||
"mobile/screens/settings/MSettingsPlugins.tsx": 14,
|
||||
@@ -216,9 +216,9 @@
|
||||
"mobile/screens/trip/MTripLoadingSplash.tsx": 2,
|
||||
"mobile/screens/trip/MTripShell.tsx": 9,
|
||||
"mobile/screens/trip/places/MPlacesBrowser.tsx": 10,
|
||||
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 4,
|
||||
"mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": 2,
|
||||
"mobile/screens/trip/places/MPlacesFilterControls.tsx": 1,
|
||||
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 7,
|
||||
"mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx": 6,
|
||||
"mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": 1,
|
||||
"mobile/screens/trip/plan/MPlanTimeline.tsx": 12,
|
||||
"mobile/screens/trip/plan/MPlanTimelineRows.tsx": 24,
|
||||
@@ -235,8 +235,8 @@
|
||||
"mobile/screens/trip/sheets/ImpFileStep.tsx": 12,
|
||||
"mobile/screens/trip/sheets/ImpListStep.tsx": 3,
|
||||
"mobile/screens/trip/sheets/MAccommodationSheet.tsx": 10,
|
||||
"mobile/screens/trip/sheets/MBookingFields.tsx": 6,
|
||||
"mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 5,
|
||||
"mobile/screens/trip/sheets/MBookingFields.tsx": 5,
|
||||
"mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": 3,
|
||||
"mobile/screens/trip/sheets/MCostSheet.tsx": 33,
|
||||
"mobile/screens/trip/sheets/MDaySheet.tsx": 22,
|
||||
"mobile/screens/trip/sheets/MDaysSheet.tsx": 4,
|
||||
@@ -251,11 +251,10 @@
|
||||
"mobile/screens/trip/sheets/MTransportFormSheet.tsx": 9,
|
||||
"mobile/screens/trip/sheets/MTransportSheet.tsx": 12,
|
||||
"mobile/screens/trip/sheets/MTripSheetUi.tsx": 5,
|
||||
"mobile/screens/trip/sheets/MTripSheets.tsx": 2,
|
||||
"mobile/screens/trip/sheets/PlCategoryPicker.tsx": 2,
|
||||
"mobile/screens/trip/sheets/PlFileAttach.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlPlaceSearch.tsx": 6,
|
||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 7,
|
||||
"mobile/screens/trip/sheets/PlSheetChrome.tsx": 5,
|
||||
"mobile/screens/trip/sheets/PlTimeFields.tsx": 3,
|
||||
"mobile/screens/trip/tabs/MBagsSheet.tsx": 13,
|
||||
"mobile/screens/trip/tabs/MBookingsTab.tsx": 2,
|
||||
@@ -280,10 +279,10 @@
|
||||
"mobile/screens/trip/tabs/filesModel.ts": 5,
|
||||
"mobile/screens/trip/tabs/tabChrome.tsx": 12,
|
||||
"mobile/screens/vacay/MVacay.tsx": 17,
|
||||
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
|
||||
"mobile/screens/vacay/MVacayInviteSheet.tsx": 8,
|
||||
"mobile/screens/vacay/MVacayMonth.tsx": 3,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
|
||||
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
|
||||
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 34,
|
||||
"mobile/screens/vacay/MVacayShareSheet.tsx": 10,
|
||||
"mobile/screens/vacay/vacayDayModel.ts": 1,
|
||||
"pages/AtlasPage.tsx": 32,
|
||||
"pages/DashboardPage.tsx": 1,
|
||||
|
||||
@@ -12,6 +12,8 @@ const { probeNow } = vi.hoisted(() => ({
|
||||
probeNow: vi.fn(async (): Promise<'online' | 'offline' | 'proxy-wall'> => 'offline'),
|
||||
}))
|
||||
vi.mock('../sync/connectivity', () => ({ probeNow }))
|
||||
const isNativeApp = vi.hoisted(() => vi.fn(() => false))
|
||||
vi.mock('../native/platform', () => ({ isNativeApp }))
|
||||
|
||||
const { apiClient, adminApi, mapsApi, pluginsApi, parseInDev } = await import('./client')
|
||||
|
||||
@@ -357,6 +359,24 @@ describe('client > redirect handling', () => {
|
||||
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/trips/7?tab=map#day-2'))
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-019b: in the app it waits for the first page to finish loading before it leaves', async () => {
|
||||
isNativeApp.mockReturnValue(true)
|
||||
const ready = Object.getOwnPropertyDescriptor(Document.prototype, 'readyState')
|
||||
Object.defineProperty(document, 'readyState', { value: 'loading', configurable: true })
|
||||
const loc = setLocation('/dashboard')
|
||||
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'AUTH_REQUIRED' }, { status: 401 })))
|
||||
|
||||
await captureError(() => apiClient.get('/auth/me'))
|
||||
expect(loc.href).not.toContain('/login')
|
||||
|
||||
window.dispatchEvent(new Event('load'))
|
||||
expect(loc.href).toBe('/login?redirect=' + encodeURIComponent('/dashboard'))
|
||||
|
||||
delete (document as { readyState?: unknown }).readyState
|
||||
if (ready) Object.defineProperty(Document.prototype, 'readyState', ready)
|
||||
isNativeApp.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('FE-APIWIRE-020: an MFA_REQUIRED 403 sends the user to the settings page', async () => {
|
||||
const loc = setLocation('/dashboard')
|
||||
server.use(http.get('/api/auth/me', () => HttpResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 })))
|
||||
|
||||
@@ -77,6 +77,7 @@ import { getSocketId } from './websocket'
|
||||
import { probeNow } from '../sync/connectivity'
|
||||
import { downloadBlob } from '../utils/fileDownload'
|
||||
import { translateRateLimit } from './rateLimitMessage'
|
||||
import { isNativeApp } from '../native/platform'
|
||||
|
||||
/**
|
||||
* Validate a response payload against its @trek/shared Zod schema — but only in
|
||||
@@ -150,6 +151,20 @@ export function isAuthPublicPath(pathname: string): boolean {
|
||||
return publicPaths.includes(pathname) || publicPrefixes.some((p) => pathname.startsWith(p))
|
||||
}
|
||||
|
||||
/**
|
||||
* A full page load to `url`. In the apps it waits for the first page to finish
|
||||
* loading: started earlier, it cancels that load, and the shell answers a
|
||||
* cancelled load with its "Server not reachable" page, which is where a
|
||||
* signed-out user opening the app for the first time ended up.
|
||||
*/
|
||||
function hardNavigate(url: string): void {
|
||||
const go = () => {
|
||||
window.location.href = url
|
||||
}
|
||||
if (isNativeApp() && document.readyState !== 'complete') window.addEventListener('load', go, { once: true })
|
||||
else go()
|
||||
}
|
||||
|
||||
// Unregisters the SW before reloading so the navigation reaches the network.
|
||||
// Without this, WorkBox's NavigationRoute serves the cached SPA shell and the
|
||||
// upstream proxy (CF Access / Pangolin) never gets to challenge the user.
|
||||
@@ -218,7 +233,7 @@ apiClient.interceptors.response.use(
|
||||
const { pathname } = window.location
|
||||
if (!isAuthPublicPath(pathname)) {
|
||||
const currentPath = pathname + window.location.search + window.location.hash
|
||||
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath)
|
||||
hardNavigate('/login?redirect=' + encodeURIComponent(currentPath))
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -226,7 +241,7 @@ apiClient.interceptors.response.use(
|
||||
(error.response?.data as { code?: string } | undefined)?.code === 'MFA_REQUIRED' &&
|
||||
!window.location.pathname.startsWith('/settings')
|
||||
) {
|
||||
window.location.href = '/settings?mfa=required'
|
||||
hardNavigate('/settings?mfa=required')
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const translated = translateRateLimit()
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function MSaveToCollectionSheet() {
|
||||
{/* Header — mirrors the place detail sheet: icon + title + target name + close */}
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-[14px] bg-[color:var(--m-ic)] text-m-muted">
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Bookmark size={18} strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
|
||||
@@ -10,6 +10,7 @@ import { tripsApi } from '../../api/client';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import MSheet from '../../mobile/components/MSheet';
|
||||
import { useSharedImportStore } from '../../native/sharedFiles';
|
||||
import { TileHeader } from '../../mobile/screens/trip/sheets/MTripSheetUi'
|
||||
|
||||
interface TripRow {
|
||||
id: number;
|
||||
@@ -52,16 +53,16 @@ export default function NativeImportSheet(): React.ReactElement | null {
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={() => setFile(null)} variant="bottom" ariaLabel={t('native.share.title')}>
|
||||
<div className="px-4 pb-4 pt-6">
|
||||
<div className="mb-1 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold text-m-ink">
|
||||
{t('native.share.title')}
|
||||
<div className="px-[18px] pb-4 pt-4">
|
||||
<div className="mb-3">
|
||||
<TileHeader
|
||||
icon={<FileText size={19} strokeWidth={1.8} />}
|
||||
title={t('native.share.title')}
|
||||
sub={file?.name}
|
||||
onClose={() => setFile(null)}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
{file && (
|
||||
<div className="mb-3 flex items-center gap-2 px-1 font-geist text-[length:calc(11px*var(--fs-scale-body,1))] text-m-muted">
|
||||
<FileText size={13} aria-hidden="true" />
|
||||
<span className="truncate">{file.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex max-h-[50vh] flex-col gap-2 overflow-y-auto">
|
||||
{trips.map((trip) => (
|
||||
<button
|
||||
|
||||
@@ -475,9 +475,8 @@ describe('PlaceDetailsColumn — hours and rating', () => {
|
||||
* FE-PDC-027 — nothing came back for this place.
|
||||
*
|
||||
* It used to be a line of grey text plus, when no Google key was set, a panel
|
||||
* telling the reader to go and ask an administrator for one. The mascot says the
|
||||
* same thing in the shape every other empty state in TREK uses, and whose key is
|
||||
* missing is not this panel's business.
|
||||
* telling the reader to go and ask an administrator for one. One quiet line says
|
||||
* that nothing came back, and whose key is missing is not this panel's business.
|
||||
*/
|
||||
describe('PlaceDetailsColumn — nothing found', () => {
|
||||
it('FE-PDC-027: shows the empty state once, and no advice about API keys', async () => {
|
||||
|
||||
@@ -26,7 +26,6 @@ import { mapsApi } from '../../api/client'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex } from './placeOpenState'
|
||||
import { convertHoursLine, isUnknownHoursLine, splitHoursLine } from './placeHoursFormat'
|
||||
import { safeHttpUrl } from '../../utils/safeUrl'
|
||||
import EmptyState from '../shared/EmptyState'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
import { LABEL } from '../shared/dialogParts'
|
||||
@@ -66,8 +65,8 @@ interface PlaceDetailsColumnProps {
|
||||
fluid?: boolean
|
||||
/**
|
||||
* 'dialog' is the desktop add place dialog: a grey panel of white cards under
|
||||
* small eyebrows, and a quiet hint card where the sheet shows the mascot. The
|
||||
* default is the phone sheet's look, which this prop leaves exactly as it was.
|
||||
* small eyebrows. The default is the phone sheet's look: a card in the mobile
|
||||
* tokens with the same one-line hints.
|
||||
*/
|
||||
variant?: 'sheet' | 'dialog'
|
||||
/** False on an instance with no Google key, which is most of them. */
|
||||
@@ -76,13 +75,24 @@ interface PlaceDetailsColumnProps {
|
||||
|
||||
type LoadState = 'idle' | 'loading' | 'ready' | 'error'
|
||||
|
||||
/** The phone sheet's quiet one-line state: an icon and a sentence, no mascot taking half the screen. */
|
||||
function SheetHint({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="flex items-center gap-2 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-2.5 text-caption text-m-muted">
|
||||
<span className="flex-none">{icon}</span>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Overline label, the section heading Vacay and the dashboard widgets share.
|
||||
* Uppercase with wide tracking, muted, no rule underneath.
|
||||
*/
|
||||
function Overline({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<p className="text-caption font-semibold uppercase tracking-[0.14em] text-content-faint">{children}</p>
|
||||
// The phone form's eyebrow (mobile Eyebrow), so these labels match Name, Notes and the rest below.
|
||||
<p className="font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] font-bold uppercase tracking-[.09em] text-m-faint">{children}</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,7 +103,7 @@ function Overline({ children }: { children: React.ReactNode }): React.ReactEleme
|
||||
function Block({ label, dialog, children }: { label: React.ReactNode; dialog: boolean; children: React.ReactNode }): React.ReactElement {
|
||||
if (!dialog) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-[5px]">
|
||||
<Overline>{label}</Overline>
|
||||
{children}
|
||||
</div>
|
||||
@@ -126,6 +136,9 @@ export default function PlaceDetailsColumn({
|
||||
const adoptBlockedId = useId()
|
||||
const [data, setData] = useState<MapsPlaceEnrichmentResult | null>(null)
|
||||
const [state, setState] = useState<LoadState>('idle')
|
||||
// The phone sheet folds the card away to give the form's own fields the room.
|
||||
const [open, setOpen] = useState(true)
|
||||
const bodyId = useId()
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const selectionKey = selection ? cacheKeyFor(selection, language) : null
|
||||
@@ -225,17 +238,36 @@ export default function PlaceDetailsColumn({
|
||||
// tiles, which is too small to tell a facade from a foyer. The column is
|
||||
// stretched to the form's height by the row it sits in, so the extra room
|
||||
// costs nothing that was being used.
|
||||
<aside className={dialog ? `${SIDE_COLUMN} ${fluid ? '' : 'sm:w-80'}` : `w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-xl border border-edge bg-surface-secondary overflow-hidden self-stretch`}>
|
||||
<aside className={dialog ? `${SIDE_COLUMN} ${fluid ? '' : 'sm:w-80'}` : `w-full ${fluid ? '' : 'sm:w-80'} shrink-0 flex flex-col rounded-[18px] border border-[color:var(--m-cbr)] bg-[color:var(--m-card)] overflow-hidden self-stretch`}>
|
||||
{dialog ? (
|
||||
<ColumnHead icon={<Landmark size={13} strokeWidth={2.2} />}>{t('places.details.title')}</ColumnHead>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-edge shrink-0">
|
||||
<Landmark size={15} className="text-accent" />
|
||||
<span className="text-body font-semibold text-content">{t('places.details.title')}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={bodyId}
|
||||
onClick={() => setOpen(o => !o)}
|
||||
className={`flex w-full shrink-0 items-center gap-2.5 px-3 py-2.5 text-start ${open ? 'border-b border-[color:var(--m-rowbr)]' : ''}`}
|
||||
>
|
||||
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-full bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Landmark size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-body font-semibold text-m-ink">{t('places.details.title')}</span>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
strokeWidth={2.2}
|
||||
className={`flex-none text-m-muted transition-transform duration-200 ${open ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : 'flex-1 min-h-0 overflow-y-auto p-3 space-y-3.5'}>
|
||||
{/* Folding on the phone: the grid row eases between its height and none. */}
|
||||
<div
|
||||
id={bodyId}
|
||||
className={dialog ? 'contents' : `grid transition-[grid-template-rows] duration-300 ease-out ${open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'}`}
|
||||
>
|
||||
<div className={dialog ? 'flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-3 pb-3' : `min-h-0 overflow-hidden ${open ? '' : 'invisible'}`}>
|
||||
<div className={dialog ? 'contents' : 'p-3 space-y-3'}>
|
||||
{/* The place's picture first: its own ones are there whatever the sources answer. */}
|
||||
<PictureBlock
|
||||
photos={state === 'ready' && !data?.disabled ? (data?.photos ?? []) : []}
|
||||
@@ -246,19 +278,13 @@ export default function PlaceDetailsColumn({
|
||||
t={t}
|
||||
/>
|
||||
|
||||
{/* Nothing picked yet, which is a state rather than a fault: the mascot
|
||||
waits with an idle look instead of a sad one. The dialog says the
|
||||
same in a quiet card, and the column keeps its width either way. */}
|
||||
{/* Nothing picked yet, which is a state rather than a fault: one quiet
|
||||
line, in the dialog and on the phone alike, so the form's own fields
|
||||
stay in view instead of sitting under a mascot half a screen high. */}
|
||||
{!selection && (dialog ? (
|
||||
<HintCard icon={<ImageIcon size={15} strokeWidth={2} />}>{t('places.details.empty')}</HintCard>
|
||||
) : (
|
||||
<EmptyState
|
||||
scene="idle"
|
||||
title={t('places.details.empty')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
<SheetHint icon={<ImageIcon size={14} strokeWidth={2} />}>{t('places.details.empty')}</SheetHint>
|
||||
))}
|
||||
|
||||
{selection && state === 'loading' && (dialog ? (
|
||||
@@ -278,22 +304,13 @@ export default function PlaceDetailsColumn({
|
||||
? <HintCard icon={<Landmark size={15} strokeWidth={2} />}>{t('places.details.disabled')}</HintCard>
|
||||
: <p className="text-caption text-content-muted">{t('places.details.disabled')}</p>)}
|
||||
|
||||
{/* The mascot rather than a line of grey text and, under it, a paragraph
|
||||
telling the reader to go and ask an administrator for a Google key.
|
||||
Nothing came back for this place, which is worth saying once and
|
||||
{/* Nothing came back for this place, which is worth saying once and
|
||||
plainly; whose key is missing is not this panel's business, and on a
|
||||
place the free sources describe fine it read as an advert. */}
|
||||
{isEmpty && (dialog ? (
|
||||
<HintCard icon={<SearchX size={15} strokeWidth={2} />}>{t('places.details.nothing')}</HintCard>
|
||||
) : (
|
||||
<EmptyState
|
||||
scene="search"
|
||||
mood="sad"
|
||||
title={t('places.details.nothing')}
|
||||
size={92}
|
||||
fill
|
||||
surface="var(--bg-secondary)"
|
||||
/>
|
||||
<SheetHint icon={<SearchX size={14} strokeWidth={2} />}>{t('places.details.nothing')}</SheetHint>
|
||||
))}
|
||||
|
||||
{selection && state === 'ready' && !data?.disabled && !isEmpty && (
|
||||
@@ -319,6 +336,8 @@ export default function PlaceDetailsColumn({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -642,7 +661,7 @@ function DescriptionBlock({
|
||||
|
||||
return (
|
||||
<Block label={aboutBrand ? t('places.details.aboutBrand') : t('places.details.description')} dialog={dialog}>
|
||||
<div className={dialog ? `${COLUMN_CARD} p-3 space-y-2` : 'rounded-lg border border-edge bg-surface p-2.5 space-y-2'}>
|
||||
<div className={dialog ? `${COLUMN_CARD} p-3 space-y-2` : 'rounded-[14px] bg-[color:var(--m-ic)] p-3 space-y-2'}>
|
||||
{aboutBrand && (
|
||||
<p className="flex items-start gap-1.5 text-caption leading-tight text-content-faint">
|
||||
<Building2 className="mt-px h-3 w-3 shrink-0" />
|
||||
@@ -674,7 +693,7 @@ function DescriptionBlock({
|
||||
onClick={onAdopt}
|
||||
disabled={adoptBlocked}
|
||||
aria-describedby={adoptBlocked ? adoptBlockedId : undefined}
|
||||
className={dialog ? WHITE_BUTTON : 'w-full inline-flex items-center justify-center gap-1.5 rounded-lg px-3 py-2 text-body font-medium bg-accent-subtle text-accent-on hover:bg-accent hover:text-accent-text disabled:opacity-50 disabled:hover:bg-accent-subtle disabled:hover:text-accent-on transition-colors'}
|
||||
className={dialog ? WHITE_BUTTON : 'inline-flex h-8 items-center justify-center gap-1.5 rounded-full bg-m-act px-3.5 text-caption font-semibold text-m-actfg transition-transform active:scale-[.97] disabled:opacity-40'}
|
||||
style={dialog ? fs(13, 'body') : undefined}
|
||||
>
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
|
||||
@@ -44,6 +44,7 @@ export function SuggestionInputBox({
|
||||
}: SuggestionInputBoxProps) {
|
||||
return (
|
||||
<div
|
||||
data-field-trigger={floating ? undefined : 'pad'}
|
||||
className={floating ? 'focus-within:ring-2 focus-within:ring-accent' : 'bg-surface-tertiary'}
|
||||
style={
|
||||
floating
|
||||
|
||||
@@ -20,7 +20,7 @@ import { formatMoney, formatTime } from '../../../utils/formatters'
|
||||
import { copyText } from '../../../utils/clipboard'
|
||||
import { fmtTransitDuration } from '../transitDisplay'
|
||||
import { parseMeta, displayTitle } from './bookingsModel'
|
||||
import { formatDay, type BookingFacts } from './bookingFacts'
|
||||
import { stayStats, type BookingFacts } from './bookingFacts'
|
||||
import { AirTrailPill, BOX, Eyebrow, Field, FileRows, ReviewPill, TravelerChips, fs, toneOf } from './bookingParts'
|
||||
import { TransitLegs, transitSummary } from './transitParts'
|
||||
import { BookingDialogHeader, StatusPill, TypePill } from './BookingDialogShell'
|
||||
@@ -79,10 +79,7 @@ export default function BookingDetailDialog(p: BookingDetailDialogProps) {
|
||||
|
||||
const stats: { value: string; label: string }[] = []
|
||||
if (facts.isHotel) {
|
||||
if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') })
|
||||
if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') })
|
||||
const nights = nightsBetween(facts.startDate, facts.endDate)
|
||||
if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) })
|
||||
stats.push(...stayStats(facts, meta, t, locale))
|
||||
} else {
|
||||
const from = facts.endpoints[0]
|
||||
const to = facts.endpoints[facts.endpoints.length - 1]
|
||||
@@ -318,8 +315,3 @@ function DetailField({ label, children }: { label: string; children: ReactNode }
|
||||
)
|
||||
}
|
||||
|
||||
function nightsBetween(a: string | null, b: string | null): number {
|
||||
if (!a || !b) return 0
|
||||
const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5)
|
||||
return n > 0 ? n : 0
|
||||
}
|
||||
|
||||
@@ -133,3 +133,28 @@ export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts {
|
||||
isHotel,
|
||||
}
|
||||
}
|
||||
|
||||
export function nightsBetween(a: string | null, b: string | null): number {
|
||||
if (!a || !b) return 0
|
||||
const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5)
|
||||
return n > 0 ? n : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* A stay's three tiles: check-in and check-out with their dates and times, and
|
||||
* the nights between. The desktop booking dialog and the phone's booking sheet
|
||||
* show the same ones.
|
||||
*/
|
||||
export function stayStats(
|
||||
facts: BookingFacts,
|
||||
meta: { check_in_time?: string | null; check_out_time?: string | null },
|
||||
t: FactsContext['t'],
|
||||
locale: string,
|
||||
): { value: string; label: string }[] {
|
||||
const stats: { value: string; label: string }[] = []
|
||||
if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') })
|
||||
if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') })
|
||||
const nights = nightsBetween(facts.startDate, facts.endDate)
|
||||
if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) })
|
||||
return stats
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
Ruler,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { createElement, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createElement, useEffect, useMemo, useRef, useState, type ComponentType } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
@@ -38,7 +38,7 @@ import { inheritedTrackColor, resolveTrackColor } from '../Map/trackColors';
|
||||
import { BOX } from '../Planner/bookings/bookingParts';
|
||||
import { getNavigationTargets, navigationTargetLabel, openNavigationTarget } from '../Planner/placeNavigation';
|
||||
import { SoftPill, tintOf } from '../Planner/planParts';
|
||||
import DetailShell from '../shared/DetailShell';
|
||||
import DetailShell, { type DetailShellProps } from '../shared/DetailShell';
|
||||
import { DeleteButton, DialogButton, DialogSection, FooterSpacer, fs } from '../shared/DialogShell';
|
||||
import ElevationProfile from '../shared/ElevationProfile';
|
||||
import { NavigationMenu } from '../shared/NavigationMenu';
|
||||
@@ -108,6 +108,8 @@ interface TourDetailDialogProps extends TourPermissionProps {
|
||||
readOnly?: boolean;
|
||||
/** Desktop-only opener; the map detail stays non-modal and returns focus when closed. */
|
||||
desktopFocusReturnTarget?: HTMLElement | null;
|
||||
/** The frame it renders into: the desktop's card over the map, or the phone's sheet. */
|
||||
shell?: ComponentType<DetailShellProps>;
|
||||
}
|
||||
|
||||
/** The pills of the head band: white like a place's, one fact each. */
|
||||
@@ -518,6 +520,7 @@ export default function TourDetailDialog({
|
||||
desktopNonModal = false,
|
||||
readOnly = false,
|
||||
desktopFocusReturnTarget = null,
|
||||
shell: Shell = DetailShell,
|
||||
canEdit: editPermission,
|
||||
canAssign: assignPermission,
|
||||
}: TourDetailDialogProps) {
|
||||
@@ -701,7 +704,7 @@ export default function TourDetailDialog({
|
||||
);
|
||||
|
||||
return (
|
||||
<DetailShell
|
||||
<Shell
|
||||
testId="tour-detail"
|
||||
header={header}
|
||||
footer={footer}
|
||||
@@ -797,7 +800,7 @@ export default function TourDetailDialog({
|
||||
|
||||
<TourProvenanceBlock tripId={place.trip_id} tour={tour} canCheck={canEdit} onReimported={onSourceReimported} />
|
||||
<TourFilesSection placeId={place.id} files={placeFiles} onFileUpload={canEdit ? onFileUpload : undefined} />
|
||||
</DetailShell>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AlertTriangle, HelpCircle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { DialogTile, NEUTRAL_TINT, fs } from './DialogShell'
|
||||
import { useLastOpen, usePresence } from './usePresence'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
@@ -28,7 +29,14 @@ function runConfirm(onConfirm: () => void): void {
|
||||
}
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const { mounted, closing } = usePresence(props.isOpen)
|
||||
// The question stays readable while it fades, even when its owner clears the subject on close.
|
||||
const shown = useLastOpen(props, props.isOpen)
|
||||
return mounted ? <ConfirmDialogFrame {...shown} isOpen={props.isOpen} closing={closing} /> : null
|
||||
}
|
||||
|
||||
function ConfirmDialogFrame({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
@@ -38,7 +46,8 @@ export default function ConfirmDialog({
|
||||
cancelLabel,
|
||||
danger = true,
|
||||
children,
|
||||
}: ConfirmDialogProps) {
|
||||
closing = false,
|
||||
}: ConfirmDialogProps & { closing?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const isPhone = useIsPhone()
|
||||
|
||||
@@ -57,14 +66,14 @@ export default function ConfirmDialog({
|
||||
return () => document.removeEventListener('keydown', handleEsc, true)
|
||||
}, [isOpen, handleEsc])
|
||||
|
||||
if (!isOpen) return null
|
||||
if (!isOpen && !closing) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
// Backdrop only — Escape (above) and the Cancel button below are the
|
||||
// keyboard routes out of the dialog.
|
||||
role="presentation"
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)]"
|
||||
className={`fixed inset-0 z-[10000] flex items-center justify-center px-4 trek-backdrop-enter bg-[rgba(15,23,42,0.5)] ${closing ? 'trek-backdrop-exit pointer-events-none' : ''}`}
|
||||
style={{ paddingBottom: 'var(--bottom-nav-h)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
@@ -74,7 +83,7 @@ export default function ConfirmDialog({
|
||||
own, and the two buttons below it always stay in reach. */}
|
||||
<div
|
||||
role="presentation"
|
||||
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
|
||||
className={`trek-modal-enter ${closing ? 'trek-modal-exit' : ''} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] flex-col rounded-2xl shadow-2xl w-full ${children ? 'max-w-md' : 'max-w-sm'} p-6 bg-surface-card`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex min-h-0 items-start gap-4 overflow-y-auto overscroll-contain">
|
||||
@@ -117,7 +126,7 @@ export default function ConfirmDialog({
|
||||
// with its tile, the message below, the answers in a bar at the foot.
|
||||
<div
|
||||
role="presentation"
|
||||
className={`trek-modal-enter flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${children ? 'max-w-md' : 'max-w-sm'}`}
|
||||
className={`trek-modal-enter ${closing ? 'trek-modal-exit' : ''} flex max-h-[calc(100dvh-2rem-var(--bottom-nav-h,0px))] w-full flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl ${children ? 'max-w-md' : 'max-w-sm'}`}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className={`flex flex-none items-center gap-3.5 px-6 pb-4 pt-5 ${danger ? 'bg-danger-soft' : ''}`} style={danger ? undefined : { background: NEUTRAL_TINT }}>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useLayoutEffect, useRef, type ComponentType, type CSSProperties } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
import { useLastOpen, usePresence } from './usePresence'
|
||||
|
||||
interface MenuItem {
|
||||
label?: string
|
||||
@@ -50,6 +51,13 @@ interface ContextMenuProps {
|
||||
}
|
||||
|
||||
export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
const { mounted, closing } = usePresence(menu != null, 130)
|
||||
// The items stay on screen while the menu fades, after its owner set it to null.
|
||||
const shown = useLastOpen(menu, menu != null)
|
||||
return mounted && shown ? <ContextMenuFrame menu={shown} onClose={onClose} closing={closing} /> : null
|
||||
}
|
||||
|
||||
function ContextMenuFrame({ menu, onClose, closing }: { menu: MenuState; onClose: () => void; closing: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -120,10 +128,8 @@ export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
}
|
||||
}, [menu])
|
||||
|
||||
if (!menu) return null
|
||||
|
||||
return createPortal(
|
||||
<div ref={ref} role="menu" tabIndex={-1} onKeyDown={moveFocus} className="trek-popover-enter" style={{
|
||||
<div ref={ref} role="menu" tabIndex={-1} onKeyDown={moveFocus} className={`trek-popover-enter ${closing ? 'trek-popover-exit pointer-events-none' : ''}`} style={{
|
||||
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
|
||||
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
|
||||
border: '1px solid var(--border-primary)',
|
||||
|
||||
@@ -298,6 +298,7 @@ export function CustomDatePicker({
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'stretch' }}>
|
||||
{/* Calendar trigger */}
|
||||
<button
|
||||
data-field-trigger={compact || borderless ? undefined : ''}
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
disabled={disabled}
|
||||
@@ -345,6 +346,7 @@ export function CustomDatePicker({
|
||||
{/* Keyboard / text-input trigger (non-compact only; compact gets it in the popup footer) */}
|
||||
{!compact && !borderless && (
|
||||
<button
|
||||
data-field-trigger=""
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTextInput(inputValue || '');
|
||||
|
||||
@@ -186,6 +186,7 @@ export default function CustomSelect({
|
||||
<button
|
||||
ref={triggerRef}
|
||||
id={id}
|
||||
data-field-trigger=""
|
||||
type="button"
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
|
||||
@@ -129,7 +129,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||
<div style={{
|
||||
<div data-field-trigger="" style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', gap: 0,
|
||||
borderRadius: 10, border: '1px solid var(--border-primary)',
|
||||
background: 'var(--bg-input)', overflow: 'hidden',
|
||||
|
||||
@@ -4,7 +4,7 @@ import { NEUTRAL_TINT } from './DialogShell'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { centredMapCard } from '../Map/centredMapCard'
|
||||
|
||||
interface DetailShellProps {
|
||||
export interface DetailShellProps {
|
||||
header: ReactNode
|
||||
children: ReactNode
|
||||
footer?: ReactNode
|
||||
|
||||
@@ -5,6 +5,7 @@ import { lockBodyScroll } from '../../utils/bodyScrollLock'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from './Tooltip'
|
||||
import { focusDialog, trapTab } from './dialogFocus'
|
||||
import { useLastOpen, usePresence } from './usePresence'
|
||||
|
||||
const sizeClasses: Record<string, string> = {
|
||||
sm: 'max-w-sm',
|
||||
@@ -36,7 +37,11 @@ interface ModalProps {
|
||||
}
|
||||
|
||||
export default function Modal({ isOpen, ...frame }: ModalProps) {
|
||||
return isOpen ? <ModalFrame {...frame} /> : null
|
||||
const { mounted, closing } = usePresence(isOpen)
|
||||
// While it leaves, the dialog shows what it showed last: its owner often
|
||||
// clears the item in the same step that closes it.
|
||||
const shown = useLastOpen(frame, isOpen)
|
||||
return mounted ? <ModalFrame {...shown} closing={closing} /> : null
|
||||
}
|
||||
|
||||
function ModalFrame({
|
||||
@@ -47,7 +52,8 @@ function ModalFrame({
|
||||
footer,
|
||||
hideCloseButton = false,
|
||||
align = 'center',
|
||||
}: Omit<ModalProps, 'isOpen'>) {
|
||||
closing = false,
|
||||
}: Omit<ModalProps, 'isOpen'> & { closing?: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
@@ -55,8 +61,8 @@ function ModalFrame({
|
||||
const [focusedBefore] = useState(() => document.activeElement)
|
||||
|
||||
const handleEsc = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
if (e.key === 'Escape' && !closing) onClose()
|
||||
}, [onClose, closing])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
@@ -85,7 +91,7 @@ function ModalFrame({
|
||||
// click-away, the panel only keeps that click from reaching it. Escape
|
||||
// and the header's close button are the keyboard route out.
|
||||
role="presentation"
|
||||
className={`fixed inset-0 z-[10000] flex items-start ${align === 'top' ? '' : 'sm:items-center'} justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)]`}
|
||||
className={`fixed inset-0 z-[10000] flex items-start ${align === 'top' ? '' : 'sm:items-center'} justify-center px-4 trek-modal-backdrop trek-backdrop-enter bg-[rgba(15,23,42,0.5)] ${closing ? 'trek-backdrop-exit pointer-events-none' : ''}`}
|
||||
style={{ paddingTop: 70, paddingBottom: 'calc(20px + var(--bottom-nav-h))', overflow: 'hidden' }}
|
||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
||||
onClick={e => {
|
||||
@@ -99,7 +105,7 @@ function ModalFrame({
|
||||
tabIndex={-1}
|
||||
onKeyDown={e => { if (panelRef.current) trapTab(e, panelRef.current) }}
|
||||
className={`
|
||||
trek-modal-enter outline-none
|
||||
trek-modal-enter ${closing ? 'trek-modal-exit' : ''} outline-none
|
||||
rounded-2xl overflow-hidden shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
|
||||
flex flex-col
|
||||
max-h-[calc(100dvh-var(--bottom-nav-h)-90px)] sm:max-h-[calc(100dvh-90px)]
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/** The hook outside the test mode, where it really lingers for the exit animation. */
|
||||
async function loadAnimated() {
|
||||
vi.resetModules();
|
||||
vi.stubEnv('MODE', 'production');
|
||||
return import('./usePresence');
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('usePresence', () => {
|
||||
it('stays mounted and closing for the exit, then unmounts', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { usePresence } = await loadAnimated();
|
||||
const hook = renderHook(({ open }) => usePresence(open, 170), { initialProps: { open: true } });
|
||||
expect(hook.result.current).toEqual({ mounted: true, closing: false });
|
||||
|
||||
hook.rerender({ open: false });
|
||||
expect(hook.result.current).toEqual({ mounted: true, closing: true });
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(170);
|
||||
});
|
||||
expect(hook.result.current).toEqual({ mounted: false, closing: false });
|
||||
});
|
||||
|
||||
it('never mounts something that was never open, and reopens straight from closing', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { usePresence } = await loadAnimated();
|
||||
const hook = renderHook(({ open }) => usePresence(open, 170), { initialProps: { open: false } });
|
||||
expect(hook.result.current.mounted).toBe(false);
|
||||
|
||||
hook.rerender({ open: true });
|
||||
hook.rerender({ open: false });
|
||||
hook.rerender({ open: true });
|
||||
expect(hook.result.current).toEqual({ mounted: true, closing: false });
|
||||
});
|
||||
|
||||
it('closes at once under the unit tests', async () => {
|
||||
vi.resetModules();
|
||||
const { usePresence } = await import('./usePresence');
|
||||
const hook = renderHook(({ open }) => usePresence(open), { initialProps: { open: true } });
|
||||
hook.rerender({ open: false });
|
||||
expect(hook.result.current.mounted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLastOpen', () => {
|
||||
it('keeps the last value seen while open', async () => {
|
||||
const { useLastOpen } = await import('./usePresence');
|
||||
const hook = renderHook(({ value, open }) => useLastOpen(value, open), {
|
||||
initialProps: { value: 'Delete Paris?' as string | null, open: true },
|
||||
});
|
||||
hook.rerender({ value: null, open: false });
|
||||
expect(hook.result.current).toBe('Delete Paris?');
|
||||
hook.rerender({ value: 'Delete Rome?', open: true });
|
||||
expect(hook.result.current).toBe('Delete Rome?');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* How long a closing dialog, confirm or menu stays for its exit animation
|
||||
* (index.css). None under the unit tests: hundreds of them close a confirm and
|
||||
* check at once that it is gone, which is what the user sees once the fade ends.
|
||||
*/
|
||||
export const EXIT_MS = import.meta.env.MODE === 'test' ? 0 : 170;
|
||||
|
||||
/**
|
||||
* Keeps something on screen for its exit animation after it was told to close:
|
||||
* `mounted` stays true for EXIT_MS once `open` turns false, with `closing` set
|
||||
* meanwhile so the caller can add the exit class and stop taking clicks.
|
||||
*/
|
||||
export function usePresence(open: boolean, ms: number = EXIT_MS): { mounted: boolean; closing: boolean } {
|
||||
const animated = ms > 0 && EXIT_MS > 0;
|
||||
const [lingering, setLingering] = useState(false);
|
||||
const wasOpen = useRef(open);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
wasOpen.current = true;
|
||||
setLingering(false);
|
||||
return;
|
||||
}
|
||||
if (!wasOpen.current) return;
|
||||
wasOpen.current = false;
|
||||
if (!animated) return;
|
||||
setLingering(true);
|
||||
const timer = window.setTimeout(() => setLingering(false), ms);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [open, ms, animated]);
|
||||
|
||||
// The first render after closing already counts as closing, before the effect runs.
|
||||
const closing = animated && !open && (lingering || wasOpen.current);
|
||||
return { mounted: open || closing, closing };
|
||||
}
|
||||
|
||||
/**
|
||||
* The last value seen while open: what a closing popup shows during its exit,
|
||||
* since its owner often clears the data in the same step that closes it.
|
||||
*/
|
||||
export function useLastOpen<T>(value: T, open: boolean): T {
|
||||
const last = useRef(value);
|
||||
if (open) last.current = value;
|
||||
return open ? value : last.current;
|
||||
}
|
||||
@@ -568,6 +568,42 @@ input[type="number"], input[type="time"], input[type="date"], input[type="dateti
|
||||
.trek-backdrop-enter {
|
||||
animation: trek-backdrop-enter 180ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
}
|
||||
|
||||
/* Closing: the mirror of each entrance, shorter and easing in, so a dialog,
|
||||
confirm or menu leaves as gently as it came (components/shared/usePresence.ts
|
||||
keeps it mounted for EXIT_MS). Added next to the entrance class rather than
|
||||
instead of it, because on phones the entrance class also docks the dialog. */
|
||||
@keyframes trek-modal-exit {
|
||||
from { opacity: 1; transform: scale(1); }
|
||||
to { opacity: 0; transform: scale(0.97); }
|
||||
}
|
||||
@keyframes trek-drawer-exit {
|
||||
from { opacity: 1; transform: translateY(0); }
|
||||
to { opacity: 0; transform: translateY(40%); }
|
||||
}
|
||||
@keyframes trek-backdrop-exit {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
.trek-modal-enter.trek-modal-exit,
|
||||
.trek-popover-enter.trek-popover-exit {
|
||||
animation: trek-modal-exit 160ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
.trek-backdrop-enter.trek-backdrop-exit {
|
||||
animation: trek-backdrop-exit 170ms ease-in forwards;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.trek-modal-enter.trek-modal-exit {
|
||||
animation: trek-drawer-exit 200ms cubic-bezier(0.4, 0, 1, 1) forwards;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.trek-modal-enter.trek-modal-exit,
|
||||
.trek-popover-enter.trek-popover-exit,
|
||||
.trek-backdrop-enter.trek-backdrop-exit {
|
||||
animation: trek-backdrop-exit 120ms ease-out forwards;
|
||||
}
|
||||
}
|
||||
.trek-toast-enter {
|
||||
animation: trek-toast-enter 260ms cubic-bezier(0.23, 1, 0.32, 1);
|
||||
will-change: transform, opacity;
|
||||
|
||||
@@ -82,6 +82,22 @@ describe('MDayPager', () => {
|
||||
expect(screen.getByRole('status')).toHaveTextContent('day 3 of 4');
|
||||
});
|
||||
|
||||
it('previews the day a swipe heads for while the strip still moves, and takes it back', () => {
|
||||
const onPreview = vi.fn();
|
||||
const { strip, onSelectDay } = setup(2, { onPreview });
|
||||
|
||||
place(strip, 3, true);
|
||||
fireEvent.scroll(strip);
|
||||
fireEvent.scroll(strip);
|
||||
expect(onPreview).toHaveBeenCalledTimes(1);
|
||||
expect(onPreview).toHaveBeenLastCalledWith(3);
|
||||
expect(onSelectDay).not.toHaveBeenCalled();
|
||||
|
||||
place(strip, 2, true);
|
||||
fireEvent.scroll(strip);
|
||||
expect(onPreview).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it('asks once, however many events report the same landing', () => {
|
||||
const { strip, onSelectDay } = setup(2);
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ export interface MDayPagerProps {
|
||||
* (Vacay's month editor and year grid) rather than over the whole screen.
|
||||
*/
|
||||
inFlow?: boolean;
|
||||
/**
|
||||
* The day a swipe is heading for, as soon as its page is the nearer one, and
|
||||
* null when the swipe falls back: lets a day rail follow the finger before
|
||||
* the snap lands and onSelectDay fires.
|
||||
*/
|
||||
onPreview?: (dayId: number | null) => void;
|
||||
}
|
||||
|
||||
interface Page {
|
||||
@@ -84,6 +90,7 @@ export default function MDayPager({
|
||||
locked = false,
|
||||
describeDay,
|
||||
inFlow = false,
|
||||
onPreview,
|
||||
}: MDayPagerProps) {
|
||||
const stripRef = useRef<HTMLDivElement>(null);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
@@ -91,8 +98,8 @@ export default function MDayPager({
|
||||
const windowKey = pages.map((p) => p.key).join(',');
|
||||
|
||||
// The handlers below are attached once, so they read the props through here.
|
||||
const latest = useRef({ days, onSelectDay, describeDay, selectedDayId });
|
||||
latest.current = { days, onSelectDay, describeDay, selectedDayId };
|
||||
const latest = useRef({ days, onSelectDay, describeDay, selectedDayId, onPreview });
|
||||
latest.current = { days, onSelectDay, describeDay, selectedDayId, onPreview };
|
||||
/** The day a landed swipe asked for, until the selection catches up. Stops a
|
||||
* late scroll event from selecting it twice. */
|
||||
const requested = useRef<number | null>(null);
|
||||
@@ -126,6 +133,31 @@ export default function MDayPager({
|
||||
if (!strip) return;
|
||||
let timer = 0;
|
||||
let touching = false;
|
||||
let previewed: number | null = null;
|
||||
|
||||
// The nearer page while the strip moves: a page past the middle is where the
|
||||
// swipe will land unless the finger turns back.
|
||||
const preview = () => {
|
||||
const notify = latest.current.onPreview;
|
||||
if (!notify) return;
|
||||
const left = strip.getBoundingClientRect().left;
|
||||
let nearest: HTMLElement | null = null;
|
||||
let distance = Infinity;
|
||||
for (const child of Array.from(strip.children) as HTMLElement[]) {
|
||||
const d = Math.abs(child.getBoundingClientRect().left - left);
|
||||
if (d < distance) {
|
||||
distance = d;
|
||||
nearest = child;
|
||||
}
|
||||
}
|
||||
if (!nearest) return;
|
||||
const raw = nearest.hasAttribute('data-page-active') ? '' : nearest.dataset.dayId;
|
||||
const dayId = raw ? Number(raw) : null;
|
||||
const next = dayId != null && Number.isFinite(dayId) ? dayId : null;
|
||||
if (next === previewed) return;
|
||||
previewed = next;
|
||||
notify(next);
|
||||
};
|
||||
|
||||
const settle = () => {
|
||||
if (touching) return;
|
||||
@@ -140,6 +172,7 @@ export default function MDayPager({
|
||||
const dayId = raw ? Number(raw) : NaN;
|
||||
if (!Number.isFinite(dayId) || requested.current === dayId) return;
|
||||
requested.current = dayId;
|
||||
previewed = null;
|
||||
const s = latest.current;
|
||||
tickHaptic();
|
||||
s.onSelectDay(dayId);
|
||||
@@ -162,14 +195,18 @@ export default function MDayPager({
|
||||
later();
|
||||
};
|
||||
|
||||
strip.addEventListener('scroll', later, { passive: true });
|
||||
const onScroll = () => {
|
||||
preview();
|
||||
later();
|
||||
};
|
||||
strip.addEventListener('scroll', onScroll, { passive: true });
|
||||
strip.addEventListener('scrollend', onScrollEnd);
|
||||
strip.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
strip.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
strip.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
strip.removeEventListener('scroll', later);
|
||||
strip.removeEventListener('scroll', onScroll);
|
||||
strip.removeEventListener('scrollend', onScrollEnd);
|
||||
strip.removeEventListener('touchstart', onTouchStart);
|
||||
strip.removeEventListener('touchend', onTouchEnd);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Check, Copy, Share as ShareIcon } from 'lucide-react';
|
||||
import { canShareLink, shareLink } from '../../native/shareLink';
|
||||
|
||||
/** The round icon buttons next to the link: copy and share, the same size. */
|
||||
const ACTION = 'flex h-[34px] w-[34px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg';
|
||||
|
||||
/**
|
||||
* A link to hand on, read-only, with copy and (where the system has a share
|
||||
* sheet) share next to it: the trip's share and invite links and a journey's
|
||||
* public link all look the same.
|
||||
*/
|
||||
export default function MLinkField({
|
||||
url,
|
||||
copied,
|
||||
onCopy,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
shareLabel,
|
||||
}: {
|
||||
url: string;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
shareLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-1 pe-1 ps-[14px]">
|
||||
<input
|
||||
readOnly
|
||||
value={url}
|
||||
aria-label={url}
|
||||
className="min-w-0 flex-1 border-none bg-transparent font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-muted outline-none"
|
||||
/>
|
||||
<button type="button" aria-label={copied ? copiedLabel : copyLabel} onClick={onCopy} className={ACTION}>
|
||||
{copied ? <Check size={15} strokeWidth={2.4} /> : <Copy size={15} strokeWidth={2.2} />}
|
||||
</button>
|
||||
{canShareLink() && (
|
||||
<button type="button" aria-label={shareLabel} onClick={() => void shareLink(url)} className={ACTION}>
|
||||
<ShareIcon size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -170,7 +170,8 @@ export default function MSheet({
|
||||
const exitClass = EXIT[variant]
|
||||
const animation = open ? (entered ? '' : enterClass) : flung ? '' : exitClass
|
||||
|
||||
const resolvedMaterial = material ?? (variant === 'bottom' ? 'bar-glass' : 'glass')
|
||||
// One material for every sheet, docked or floating, so they all read the same.
|
||||
const resolvedMaterial = material ?? 'glass'
|
||||
|
||||
return createPortal(
|
||||
// m-root on the overlay so the --m-* tokens resolve even when the portal
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* The day a swipe is heading for, before the pager has settled on it. The day
|
||||
* rail in the trip header lights that chip at once, the way a native tab strip
|
||||
* follows the finger, while the plan itself switches only when the snap lands
|
||||
* (MDayPager's onSelectDay). Null when no swipe is under way.
|
||||
*/
|
||||
interface DayPreviewState {
|
||||
dayId: number | null;
|
||||
setDayId: (dayId: number | null) => void;
|
||||
}
|
||||
|
||||
export const useDayPreviewStore = create<DayPreviewState>((set) => ({
|
||||
dayId: null,
|
||||
setDayId: (dayId) => set((s) => (s.dayId === dayId ? s : { dayId })),
|
||||
}));
|
||||
@@ -478,3 +478,24 @@
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* A scroll box under a fixed header: content fades out over the padding at its
|
||||
edges instead of being cut off in a hard line right under the header. */
|
||||
.m-scroll-fade {
|
||||
-webkit-mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
|
||||
mask-image: linear-gradient(to bottom, transparent 0, black 14px, black calc(100% - 14px), transparent 100%);
|
||||
}
|
||||
|
||||
/* The shared pickers (select, date, time) inside the phone UI look like the
|
||||
sheets' own fields: a 42 px round field on the field surface. They style
|
||||
themselves inline for the desktop, hence the !important. */
|
||||
.m-root [data-field-trigger] {
|
||||
min-height: 42px;
|
||||
border-radius: 9999px !important;
|
||||
border: 1px solid var(--m-rowbr) !important;
|
||||
background: var(--m-ic) !important;
|
||||
}
|
||||
.m-root button[data-field-trigger],
|
||||
.m-root [data-field-trigger='pad'] {
|
||||
padding-inline: 14px !important;
|
||||
}
|
||||
|
||||
@@ -44,13 +44,19 @@ function canAnimate(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves once no route fallback is on screen, or after SETTLE_MS. */
|
||||
/**
|
||||
* Resolves once no route fallback is on screen, or after SETTLE_MS. Polled with
|
||||
* a timer, never requestAnimationFrame: while a view transition waits for its
|
||||
* update, the browser renders no frames, so a frame callback never came and the
|
||||
* transition hung until the browser gave up after four seconds, on every
|
||||
* navigation.
|
||||
*/
|
||||
function settled(): Promise<void> {
|
||||
const began = performance.now();
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (!document.querySelector('[data-route-fallback]') || performance.now() - began > SETTLE_MS) resolve();
|
||||
else requestAnimationFrame(check);
|
||||
else window.setTimeout(check, 16);
|
||||
};
|
||||
check();
|
||||
});
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useFormatDate } from '../../../hooks/useDateFormat'
|
||||
import MPluginVersionDate from './MPluginVersionDate'
|
||||
import {
|
||||
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
|
||||
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
|
||||
SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database,
|
||||
Globe, Info, History, PauseCircle, Puzzle, Bot,
|
||||
} from 'lucide-react'
|
||||
import { Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud, ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert, SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, Database, Globe, Info, History, PauseCircle, Puzzle, Bot, ListChecks } from 'lucide-react'
|
||||
import PluginIcon from '../../../components/shared/PluginIcon'
|
||||
import { type RangeWarning } from '../../../components/Admin/useRangeBypass'
|
||||
import { deriveCaps } from '../../../components/Admin/pluginCaps'
|
||||
@@ -353,9 +348,10 @@ export default function MAdminPluginsPanel() {
|
||||
|
||||
{/* Error-log sheet */}
|
||||
{errorsFor && (
|
||||
<MSheet open onClose={() => setErrorsFor(null)} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.errorLog')}>
|
||||
<MSheet open onClose={() => setErrorsFor(null)} variant="bottom" material="glass" ariaLabel={t('admin.plugins.errorLog')}>
|
||||
<MAdminSheetFrame
|
||||
title={<span className="flex items-center gap-2"><Bug size={15} /> {errorsFor.id} — {t('admin.plugins.errorLog')}</span>}
|
||||
icon={<Bug size={19} strokeWidth={1.8} />}
|
||||
title={<>{errorsFor.id} — {t('admin.plugins.errorLog')}</>}
|
||||
onClose={() => setErrorsFor(null)}
|
||||
>
|
||||
<div className="font-mono text-[0.6875rem]">
|
||||
@@ -374,9 +370,10 @@ export default function MAdminPluginsPanel() {
|
||||
|
||||
{/* Operator-supplied egress hosts */}
|
||||
{egressFor && (
|
||||
<MSheet open onClose={() => setEgressFor(null)} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.allowedHosts')}>
|
||||
<MSheet open onClose={() => setEgressFor(null)} variant="bottom" material="glass" ariaLabel={t('admin.plugins.allowedHosts')}>
|
||||
<MAdminSheetFrame
|
||||
title={<span className="flex items-center gap-2"><Globe size={15} /> {egressFor.id} — {t('admin.plugins.allowedHosts')}</span>}
|
||||
icon={<Globe size={19} strokeWidth={1.8} />}
|
||||
title={<>{egressFor.id} — {t('admin.plugins.allowedHosts')}</>}
|
||||
onClose={() => setEgressFor(null)}
|
||||
>
|
||||
{!egressFor.supported ? (
|
||||
@@ -421,9 +418,10 @@ export default function MAdminPluginsPanel() {
|
||||
|
||||
{/* Instance-wide settings (the admin-owned scope:'instance' fields) */}
|
||||
{settings.form && (
|
||||
<MSheet open onClose={settings.close} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.instanceSettings')}>
|
||||
<MSheet open onClose={settings.close} variant="bottom" material="glass" ariaLabel={t('admin.plugins.instanceSettings')}>
|
||||
<MAdminSheetFrame
|
||||
title={<span className="flex items-center gap-2"><SlidersHorizontal size={15} /> {settings.form.id} — {t('admin.plugins.instanceSettings')}</span>}
|
||||
icon={<SlidersHorizontal size={19} strokeWidth={1.8} />}
|
||||
title={<>{settings.form.id} — {t('admin.plugins.instanceSettings')}</>}
|
||||
onClose={settings.close}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@@ -602,8 +600,8 @@ function PickerSheet({ open, onClose, title, options, value, onPick }: {
|
||||
options: Array<[string, string]>; value: string; onPick: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="bottom" material="opaque" ariaLabel={title}>
|
||||
<MAdminSheetFrame title={title} onClose={onClose}>
|
||||
<MSheet open={open} onClose={onClose} variant="bottom" material="glass" ariaLabel={title}>
|
||||
<MAdminSheetFrame icon={<ListChecks size={19} strokeWidth={1.8} />} title={title} onClose={onClose}>
|
||||
<div className="space-y-1">
|
||||
{options.map(([v, lbl]) => (
|
||||
<button key={v} type="button" onClick={() => { onPick(v); onClose() }}
|
||||
@@ -760,8 +758,8 @@ function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onSetti
|
||||
const linkable = p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link'
|
||||
const rowClass = 'flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-start text-[0.8125rem] font-semibold text-m-ink'
|
||||
return (
|
||||
<MSheet open onClose={onClose} variant="bottom" material="opaque" ariaLabel={p.name}>
|
||||
<MAdminSheetFrame title={p.name} onClose={onClose}>
|
||||
<MSheet open onClose={onClose} variant="bottom" material="glass" ariaLabel={p.name}>
|
||||
<MAdminSheetFrame icon={<Puzzle size={19} strokeWidth={1.8} />} title={p.name} onClose={onClose}>
|
||||
<div className="space-y-1">
|
||||
{p.enabled === 1 && (
|
||||
<button type="button" className={rowClass} onClick={onRestart}><RotateCw size={16} /> {t('admin.plugins.restart')}</button>
|
||||
@@ -813,8 +811,8 @@ function VersionPickerSheet({ plugin, versions, failed, busy, t, onPick, onClose
|
||||
t: T; onPick: (version: string) => void; onClose: () => void
|
||||
}) {
|
||||
return (
|
||||
<MSheet open onClose={onClose} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.versionPickerTitle', { name: plugin.name })}>
|
||||
<MAdminSheetFrame title={t('admin.plugins.versionPickerTitle', { name: plugin.name })} onClose={onClose}>
|
||||
<MSheet open onClose={onClose} variant="bottom" material="glass" ariaLabel={t('admin.plugins.versionPickerTitle', { name: plugin.name })}>
|
||||
<MAdminSheetFrame icon={<History size={19} strokeWidth={1.8} />} title={t('admin.plugins.versionPickerTitle', { name: plugin.name })} onClose={onClose}>
|
||||
<div className="space-y-0.5">
|
||||
{failed && <p className="px-2.5 py-2 text-xs text-[color:var(--m-st-danger)]">{t('admin.plugins.detailError')}</p>}
|
||||
{!failed && !versions && <p className="px-2.5 py-2 text-xs text-m-faint">{t('common.loading')}</p>}
|
||||
@@ -973,7 +971,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
|
||||
const sectionH = 'text-[11px] font-semibold uppercase tracking-wider text-m-muted'
|
||||
|
||||
return (
|
||||
<MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={item.name}>
|
||||
<MSheet open onClose={onClose} variant="card" material="glass" ariaLabel={item.name}>
|
||||
<div className="relative flex-none">
|
||||
<Screenshot url={item.screenshotUrl} className="aspect-[16/9] w-full" iconSize={36} />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
|
||||
@@ -1195,7 +1193,7 @@ function SignatureBlockSheet({ data, entry, busy, t, onRetrust, onClose }: {
|
||||
const offerRetrust = retrust !== null
|
||||
|
||||
return (
|
||||
<MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={t('admin.plugins.sig.title', { name: data.subject.name })}>
|
||||
<MSheet open onClose={onClose} variant="card" material="glass" ariaLabel={t('admin.plugins.sig.title', { name: data.subject.name })}>
|
||||
<div className="flex-none px-[18px] pt-[18px]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><ShieldAlert size={18} /></div>
|
||||
@@ -1248,7 +1246,7 @@ function UpdateConsentSheet({ data, unsigned, t, onApprove, onLater }: {
|
||||
t: T; onApprove: () => void; onLater: () => void
|
||||
}) {
|
||||
return (
|
||||
<MSheet open onClose={onLater} variant="card" material="opaque" ariaLabel={t('admin.plugins.updateConsentTitle')}>
|
||||
<MSheet open onClose={onLater} variant="card" material="glass" ariaLabel={t('admin.plugins.updateConsentTitle')}>
|
||||
<div className="flex-none px-[18px] pt-[18px]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><ShieldCheck size={18} /></div>
|
||||
@@ -1305,7 +1303,7 @@ function DependencyResolveSheet({ data, t, busy, installedIds, onDownload, onClo
|
||||
}) {
|
||||
const rows = dependencyRows(data)
|
||||
return (
|
||||
<MSheet open onClose={onClose} variant="card" material="opaque" ariaLabel={t('admin.plugins.dep.resolveTitle')}>
|
||||
<MSheet open onClose={onClose} variant="card" material="glass" ariaLabel={t('admin.plugins.dep.resolveTitle')}>
|
||||
<div className="flex-none px-[18px] pt-[18px]">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${CHIP_PENDING}`}><Puzzle size={18} /></div>
|
||||
|
||||
@@ -32,7 +32,8 @@ export default function MAdminResetMfaSheet({ open, onClose, user, onReset, t }:
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} ariaLabel={t('admin.mfa.reset')}>
|
||||
<MAdminSheetFrame
|
||||
title={t('admin.mfa.reset')}
|
||||
icon={<ShieldOff size={19} strokeWidth={1.8} />}
|
||||
title={t('admin.mfa.reset')}
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<>
|
||||
|
||||
@@ -41,9 +41,10 @@ function AddCountrySheet({
|
||||
onClose();
|
||||
};
|
||||
return (
|
||||
<MSheet open={open} onClose={close} variant="card" material="opaque" ariaLabel={t('schoolCatalog.addCountry')}>
|
||||
<MSheet open={open} onClose={close} variant="card" material="glass" ariaLabel={t('schoolCatalog.addCountry')}>
|
||||
<MAdminSheetFrame
|
||||
title={t('schoolCatalog.addCountry')}
|
||||
icon={<GraduationCap size={19} strokeWidth={1.8} />}
|
||||
title={t('schoolCatalog.addCountry')}
|
||||
onClose={close}
|
||||
footer={
|
||||
<>
|
||||
@@ -113,12 +114,13 @@ function RegionSheet({ region, busy, offline, error, onClose, onSave }: EditorPr
|
||||
if (!busy) onClose();
|
||||
}}
|
||||
variant="card"
|
||||
material="opaque"
|
||||
material="glass"
|
||||
ariaLabel={name.trim() || t('schoolCatalog.region')}
|
||||
discardGuard={body}
|
||||
>
|
||||
<MAdminSheetFrame
|
||||
title={name.trim() || t('schoolCatalog.region')}
|
||||
icon={<GraduationCap size={19} strokeWidth={1.8} />}
|
||||
title={name.trim() || t('schoolCatalog.region')}
|
||||
onClose={() => {
|
||||
if (!busy) onClose();
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { AlertTriangle, CheckCircle, ExternalLink, Fingerprint, ShieldOff, Trash2 } from 'lucide-react'
|
||||
import { AlertTriangle, CheckCircle, ExternalLink, Fingerprint, ShieldOff, Trash2, UserPlus, UserCog, Download, KeyRound, RotateCcw } from 'lucide-react'
|
||||
import { useAdminUserActions } from '../../../components/Admin/useAdminUserActions'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
import type { useAdmin } from '../../../pages/admin/useAdmin'
|
||||
@@ -63,6 +63,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
|
||||
{/* Create user */}
|
||||
<MSheet open={showCreateUser} onClose={() => setShowCreateUser(false)} ariaLabel={t('admin.createUser')}>
|
||||
<MAdminSheetFrame
|
||||
icon={<UserPlus size={19} strokeWidth={1.8} />}
|
||||
title={t('admin.createUser')}
|
||||
onClose={() => setShowCreateUser(false)}
|
||||
footer={
|
||||
@@ -115,6 +116,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
|
||||
{/* Edit user */}
|
||||
<MSheet open={!!editingUser} onClose={() => setEditingUser(null)} ariaLabel={t('admin.editUser')}>
|
||||
<MAdminSheetFrame
|
||||
icon={<UserCog size={19} strokeWidth={1.8} />}
|
||||
title={t('admin.editUser')}
|
||||
onClose={() => setEditingUser(null)}
|
||||
footer={
|
||||
@@ -205,6 +207,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
|
||||
{/* How to update */}
|
||||
<MSheet open={showUpdateModal} onClose={() => setShowUpdateModal(false)} ariaLabel={t('admin.update.howTo')}>
|
||||
<MAdminSheetFrame
|
||||
icon={<Download size={19} strokeWidth={1.8} />}
|
||||
title={t('admin.update.howTo')}
|
||||
onClose={() => setShowUpdateModal(false)}
|
||||
footer={<MAdminButton onClick={() => setShowUpdateModal(false)}>{t('common.close')}</MAdminButton>}
|
||||
@@ -256,6 +259,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
|
||||
{/* Reset passkeys confirm */}
|
||||
<MSheet open={showResetPasskeys} onClose={() => setShowResetPasskeys(false)} ariaLabel={t('admin.passkey.reset')}>
|
||||
<MAdminSheetFrame
|
||||
icon={<KeyRound size={19} strokeWidth={1.8} />}
|
||||
title={t('admin.passkey.reset')}
|
||||
onClose={() => setShowResetPasskeys(false)}
|
||||
footer={
|
||||
@@ -288,6 +292,7 @@ export default function MAdminSheets({ admin, t }: MAdminSheetsProps) {
|
||||
{/* Rotate JWT confirm */}
|
||||
<MSheet open={showRotateJwtModal} onClose={() => setShowRotateJwtModal(false)} ariaLabel={t('admin.rotateJwt.title')}>
|
||||
<MAdminSheetFrame
|
||||
icon={<RotateCcw size={19} strokeWidth={1.8} />}
|
||||
title={t('admin.rotateJwt.title')}
|
||||
onClose={() => setShowRotateJwtModal(false)}
|
||||
footer={
|
||||
|
||||
@@ -83,7 +83,7 @@ export function MAdminClampedText({ children, className = '' }: { children: stri
|
||||
export function MAdminField({ label, hint, children }: { label: ReactNode; hint?: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-[6px] text-[0.75rem] font-semibold text-m-ink">{label}</div>
|
||||
<div className="mb-[5px] font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">{label}</div>
|
||||
{children}
|
||||
{hint && <div className="mt-[5px] font-geist text-[0.625rem] leading-relaxed text-m-muted">{hint}</div>}
|
||||
</div>
|
||||
@@ -94,7 +94,7 @@ export function MAdminInput({ className = '', ...props }: InputHTMLAttributes<HT
|
||||
return (
|
||||
<input
|
||||
{...props}
|
||||
className={`h-[42px] w-full rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)] disabled:cursor-not-allowed disabled:opacity-60 ${className}`}
|
||||
className={`h-[42px] w-full rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] text-[0.84375rem] text-m-ink outline-none placeholder:text-m-faint focus:border-[color:var(--m-faint)] disabled:cursor-not-allowed disabled:opacity-60 ${className}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -161,11 +161,14 @@ export function MAdminButton({
|
||||
/** Sheet body frame: title row + close, scrollable content, optional footer. */
|
||||
export function MAdminSheetFrame({
|
||||
title,
|
||||
icon,
|
||||
onClose,
|
||||
footer,
|
||||
children,
|
||||
}: {
|
||||
title: ReactNode
|
||||
/** The icon tile in front of the title, as every phone sheet has. */
|
||||
icon?: ReactNode
|
||||
onClose: () => void
|
||||
footer?: ReactNode
|
||||
children: ReactNode
|
||||
@@ -173,15 +176,22 @@ export function MAdminSheetFrame({
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-none items-center gap-2 px-4 pb-2 pt-4">
|
||||
<div className="min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">{title}</div>
|
||||
{/* TileHeader's shape: the icon tile, the title and the round close. */}
|
||||
<div className="flex flex-none items-center gap-3 px-[18px] pb-1 pt-4">
|
||||
{icon && (
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold leading-tight text-m-ink">{title}</div>
|
||||
<MIconBtn variant="neutral" size={34} ariaLabel={t('common.close')} onClick={onClose}>
|
||||
<X size={16} strokeWidth={2.2} />
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 pb-4">{children}</div>
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">{children}</div>
|
||||
{footer && (
|
||||
<div className="flex flex-none items-center justify-end gap-2 border-t border-[color:var(--m-rowbr)] px-4 py-3">
|
||||
// The footer's buttons take the sheets' 42 px size; elsewhere MAdminButton stays a small pill.
|
||||
<div className="flex flex-none items-center justify-end gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3 [&>button]:h-[42px] [&>button]:px-4 [&>button]:text-[length:calc(13px*var(--fs-scale-body,1))] [&>button]:font-semibold">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Copy, Link2, Trash2, UserPlus } from 'lucide-react'
|
||||
import { Copy, Link2, Trash2, UserPlus, Ticket } from 'lucide-react'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
import type { useAdmin } from '../../../pages/admin/useAdmin'
|
||||
import { inviteMeta, inviteStatus } from '../../../pages/admin/adminModel'
|
||||
@@ -167,6 +167,7 @@ export default function MAdminUsersSection({ admin, t, locale }: MAdminUsersSect
|
||||
{/* Create invite sheet */}
|
||||
<MSheet open={showCreateInvite} onClose={() => setShowCreateInvite(false)} ariaLabel={t('admin.invite.create')}>
|
||||
<MAdminSheetFrame
|
||||
icon={<Ticket size={19} strokeWidth={1.8} />}
|
||||
title={t('admin.invite.create')}
|
||||
onClose={() => setShowCreateInvite(false)}
|
||||
footer={
|
||||
|
||||
@@ -6,6 +6,7 @@ import { A2_TO_A3 } from '../../../pages/atlas/atlasModel'
|
||||
import type { BucketItem } from '../../../pages/atlas/atlasModel'
|
||||
import type { AtlasController } from './atlasController'
|
||||
import { getIntlLanguage } from '@trek/shared'
|
||||
import { TileHeader } from '../trip/sheets/MTripSheetUi'
|
||||
|
||||
interface MAtlasBucketSheetProps {
|
||||
atlas: AtlasController
|
||||
@@ -87,18 +88,15 @@ export default function MAtlasBucketSheet({ atlas, open, onClose }: MAtlasBucket
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={close} variant="bottom" material="glass" ariaLabel={t('atlas.bucketTab')}>
|
||||
<div className="flex min-h-0 flex-col p-4">
|
||||
<div className="flex items-center gap-[10px] px-1 pb-3">
|
||||
<Star size={17} strokeWidth={2.2} className="flex-none text-[color:var(--m-st-pending)]" />
|
||||
<div className="min-w-0 flex-1 truncate text-[0.9375rem] font-extrabold text-m-ink">
|
||||
{t('atlas.bucketTab')}
|
||||
{bucketList.length > 0 && (
|
||||
<span className="ms-[6px] font-geist text-[0.75rem] font-semibold tabular-nums text-m-faint">{bucketList.length}</span>
|
||||
)}
|
||||
</div>
|
||||
<MIconBtn variant="neutral" size={34} onClick={close} ariaLabel={t('common.close')}>
|
||||
<X size={16} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
<div className="flex min-h-0 flex-col px-[18px] pb-4 pt-4">
|
||||
<div className="mb-3">
|
||||
<TileHeader
|
||||
icon={<Star size={19} strokeWidth={1.8} className="text-[color:var(--m-st-pending)]" />}
|
||||
title={t('atlas.bucketTab')}
|
||||
sub={bucketList.length > 0 ? String(bucketList.length) : undefined}
|
||||
onClose={close}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Check, Loader2, MapPin, Search, X } from 'lucide-react'
|
||||
import { Check, Loader2, MapPin, Search, X, MapPinPlus } from 'lucide-react'
|
||||
import type { Category, TranslationFn } from '../../../types'
|
||||
import { STATUS_ORDER } from '../../../pages/collections/collectionsModel'
|
||||
import MSheet from '../../components/MSheet'
|
||||
@@ -32,9 +32,10 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
} = useAddPlaceToCollection({ variant: 'sheet', open, collectionId, collectionName, lists, t, onClose, onAdded })
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} material="opaque" ariaLabel={t('collections.addPlace')}>
|
||||
<SheetHeader title={t('collections.addPlace')} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-[14px]">
|
||||
<MSheet open={open} onClose={onClose} material="glass" ariaLabel={t('collections.addPlace')}>
|
||||
<SheetHeader
|
||||
icon={<MapPinPlus size={19} strokeWidth={1.8} />} title={t('collections.addPlace')} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<div className="m-scroll-fade min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{/* List picker — only when opened without a fixed target list */}
|
||||
{collectionId == null && (
|
||||
<div className="mb-[14px]">
|
||||
@@ -65,20 +66,20 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
</div>
|
||||
)}
|
||||
{/* Search — picking a result fills the location */}
|
||||
<div className="flex items-center gap-2 rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] py-1 ps-[13px] pe-[6px]">
|
||||
<div className="flex h-[42px] items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] pe-1 ps-[14px]">
|
||||
<Search size={15} strokeWidth={2.2} className="flex-none text-m-muted" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void search() } }}
|
||||
placeholder={t('collections.addPlaceSearch')}
|
||||
className="min-w-0 flex-1 bg-transparent py-2 font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint"
|
||||
className="min-w-0 flex-1 bg-transparent font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={search}
|
||||
disabled={!query.trim() || searching}
|
||||
className="flex h-8 flex-none items-center gap-[5px] rounded-[10px] bg-m-act px-[14px] text-[0.75rem] font-bold text-m-actfg disabled:opacity-40"
|
||||
className="flex h-[34px] flex-none items-center gap-[5px] rounded-full bg-m-act px-[14px] text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold text-m-actfg disabled:opacity-40"
|
||||
>
|
||||
{searching ? <Loader2 size={13} className="animate-spin" /> : null}
|
||||
{t('common.search')}
|
||||
@@ -129,7 +130,7 @@ export default function MCollAddSheet({ open, collectionId, collectionName, list
|
||||
type="button"
|
||||
onClick={() => setStatus(s)}
|
||||
aria-pressed={on}
|
||||
className={`flex flex-1 items-center justify-center gap-[5px] rounded-full py-2 text-[0.71875rem] font-bold ${
|
||||
className={`flex h-[42px] min-w-0 flex-1 items-center justify-center gap-[5px] rounded-full text-[0.71875rem] font-bold ${
|
||||
on ? 'bg-m-act text-m-actfg' : 'border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-ink'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -32,10 +32,10 @@ export default function MCollCategoryPicker({ categories, value, onChange, t }:
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
className="flex w-full items-center gap-[9px] rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] text-start"
|
||||
className="flex h-[42px] w-full items-center gap-[9px] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] pe-[14px] ps-[8px] text-start"
|
||||
>
|
||||
<span
|
||||
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-lg"
|
||||
className="flex h-[28px] w-[28px] flex-none items-center justify-center rounded-full"
|
||||
style={{ background: tint(selectedMeta.color, '1f'), color: selectedMeta.color }}
|
||||
>
|
||||
<SelectedIcon size={14} strokeWidth={2.2} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Image, Loader2, Search, Trash2 } from 'lucide-react'
|
||||
import { Image, Loader2, Search, Trash2, Bookmark } from 'lucide-react'
|
||||
import type { Collection } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
import { useListEditor } from '../../../components/Collections/useListEditor'
|
||||
@@ -6,7 +6,7 @@ import { normalizeLinkUrl } from '../../../pages/collections/collectionsModel'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MCollLinksEditor from './MCollLinksEditor'
|
||||
import { listCoverGradient, SWATCH_COLORS } from './collectionsMobileModel'
|
||||
import { CancelPill, PrimaryPill, SheetFooter, SheetHeader, TEXTAREA_CLS } from './MCollSheetKit'
|
||||
import { CancelPill, INPUT_CLS, PrimaryPill, SheetFooter, SheetHeader, TEXTAREA_CLS } from './MCollSheetKit'
|
||||
|
||||
// Scrims over the cover photo / Unsplash thumbs — fixed dark overlays in both themes.
|
||||
const COVER_OVERLAY =
|
||||
@@ -34,22 +34,24 @@ export default function MCollEditSheet({ target, onClose, onCreated, onRequestDe
|
||||
coverPreview, coverQuery, setCoverQuery, coverResults, searchingCover, saving, close, pickCover, searchCover, pickUnsplash, save,
|
||||
} = useListEditor({ target, onClose, onCreated, t, defaultColor: SWATCH_COLORS[0], holdLastTarget: true, normalizeLinkUrl })
|
||||
|
||||
const label = 'mb-[5px] font-geist text-[0.6875rem] font-bold text-m-muted'
|
||||
// The sheets' eyebrow (Eyebrow), uppercase over each field.
|
||||
const label = 'mb-[5px] font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint'
|
||||
const canDeleteList = editing != null && editing.is_owner !== false
|
||||
|
||||
return (
|
||||
<MSheet
|
||||
open={target != null}
|
||||
onClose={close}
|
||||
material="opaque"
|
||||
material="glass"
|
||||
ariaLabel={editing ? t('collections.editListTitle') : t('collections.newList')}
|
||||
>
|
||||
<SheetHeader
|
||||
icon={<Bookmark size={19} strokeWidth={1.8} />}
|
||||
title={editing ? t('collections.editListTitle') : t('collections.newList')}
|
||||
onClose={close}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-[14px]">
|
||||
<div className="m-scroll-fade min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
<div className={label}>{t('collections.coverImage')}</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -75,13 +77,13 @@ export default function MCollEditSheet({ target, onClose, onCreated, onRequestDe
|
||||
onChange={e => setCoverQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void searchCover() } }}
|
||||
placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
||||
className="min-w-0 flex-1 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 py-[10px] font-geist text-[0.71875rem] text-m-ink outline-none placeholder:text-m-faint"
|
||||
className={`${INPUT_CLS} min-w-0 flex-1`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={searchCover}
|
||||
disabled={searchingCover || (!coverQuery.trim() && !name.trim())}
|
||||
className="flex flex-none items-center gap-[5px] rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-3 py-[10px] text-[0.71875rem] font-semibold text-m-ink disabled:opacity-40"
|
||||
className="flex h-[42px] flex-none items-center gap-[5px] 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"
|
||||
>
|
||||
{searchingCover ? <Loader2 size={12} className="animate-spin" /> : <Search size={12} strokeWidth={2} />} Unsplash
|
||||
</button>
|
||||
@@ -113,7 +115,7 @@ export default function MCollEditSheet({ target, onClose, onCreated, onRequestDe
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder={t('collections.listNamePlaceholder')}
|
||||
className="w-full box-border rounded-[12px] border-[1.5px] border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] font-[inherit] text-[0.84375rem] font-semibold text-m-ink outline-none placeholder:text-m-faint"
|
||||
className={`${INPUT_CLS} font-semibold`}
|
||||
/>
|
||||
|
||||
<div className={`${label} mt-[14px] mb-[6px]`}>{t('collections.listColor')}</div>
|
||||
|
||||
@@ -92,8 +92,9 @@ export default function MCollLabelsSheet({
|
||||
const title = mode === 'assign' ? t('collections.labels.assignN', { count: selectedCount }) : t('collections.labels.manage')
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} material="opaque" ariaLabel={title}>
|
||||
<SheetHeader title={title} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<MSheet open={open} onClose={onClose} material="glass" ariaLabel={title}>
|
||||
<SheetHeader
|
||||
icon={<Tag size={19} strokeWidth={1.8} />} title={title} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-4">
|
||||
{mode === 'manage' && (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { Loader2, ListPlus } from 'lucide-react'
|
||||
import type { Collection } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
import MSheet from '../../components/MSheet'
|
||||
@@ -40,8 +40,9 @@ export default function MCollListPickerSheet({ mode, lists, count, onPick, onClo
|
||||
}
|
||||
|
||||
return (
|
||||
<MSheet open={mode != null} onClose={onClose} material="opaque" ariaLabel={title}>
|
||||
<SheetHeader title={title} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<MSheet open={mode != null} onClose={onClose} material="glass" ariaLabel={title}>
|
||||
<SheetHeader
|
||||
icon={<ListPlus size={19} strokeWidth={1.8} />} title={title} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{lists.length === 0 ? (
|
||||
<div className="py-8 text-center font-geist text-[0.78125rem] text-m-faint">{t('collections.noOtherLists')}</div>
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function MCollPlaceSheet({
|
||||
'flex flex-1 items-center justify-center gap-[6px] rounded-[13px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-2 py-[11px] text-[0.78125rem] font-semibold text-m-ink'
|
||||
|
||||
return (
|
||||
<MSheet open={place != null} onClose={onClose} material="opaque" className="!rounded-[24px]" ariaLabel={held?.name}>
|
||||
<MSheet open={place != null} onClose={onClose} material="glass" className="!rounded-[24px]" ariaLabel={held?.name}>
|
||||
{held && (
|
||||
<>
|
||||
{/* Hero: auto cover (photo when available, the design gradient otherwise) */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Check, ChevronDown, Clock, Crown, Loader2, LogOut, UserPlus, X } from 'lucide-react'
|
||||
import { Check, ChevronDown, Clock, Crown, Loader2, LogOut, UserPlus, X, Share2 } from 'lucide-react'
|
||||
import type { CollectionMember } from '@trek/shared'
|
||||
import { COLLECTION_ROLES } from '@trek/shared'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
@@ -54,8 +54,9 @@ export default function MCollShareSheet({
|
||||
const badge = 'inline-flex flex-none items-center gap-[3px] font-geist text-[0.625rem] font-extrabold'
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} material="opaque" ariaLabel={t('collections.share.title')}>
|
||||
<SheetHeader title={t('collections.share.titleNamed', { name: collectionName })} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<MSheet open={open} onClose={onClose} material="glass" ariaLabel={t('collections.share.title')}>
|
||||
<SheetHeader
|
||||
icon={<Share2 size={19} strokeWidth={1.8} />} title={t('collections.share.titleNamed', { name: collectionName })} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
<div className="mb-2 flex items-center gap-[7px]">
|
||||
<Eyebrow>{t('collections.share.members').toUpperCase()}</Eyebrow>
|
||||
@@ -140,7 +141,7 @@ export default function MCollShareSheet({
|
||||
onClick={handleInvite}
|
||||
disabled={selectedUserId == null || inviting}
|
||||
aria-label={t('collections.share.sendInvite')}
|
||||
className="flex w-11 flex-none items-center justify-center rounded-[12px] bg-m-act text-m-actfg disabled:opacity-40"
|
||||
className="flex h-[42px] w-[42px] flex-none items-center justify-center self-center rounded-full bg-m-act text-m-actfg disabled:opacity-40"
|
||||
>
|
||||
{inviting ? <Loader2 size={16} className="animate-spin" /> : <UserPlus size={16} strokeWidth={2.2} />}
|
||||
</button>
|
||||
|
||||
@@ -4,22 +4,33 @@ import { X } from 'lucide-react'
|
||||
/** Shared sheet scaffolding of the collections sheets (opaque floating cards). */
|
||||
|
||||
export const INPUT_CLS =
|
||||
'w-full box-border rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint'
|
||||
'w-full box-border rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-[10px] font-[inherit] text-[0.8125rem] text-m-ink outline-none placeholder:text-m-faint'
|
||||
|
||||
export const TEXTAREA_CLS =
|
||||
'w-full box-border resize-none rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px] py-[11px] font-geist text-[0.78125rem] leading-[1.5] text-m-ink outline-none placeholder:text-m-faint'
|
||||
'w-full box-border resize-none rounded-[18px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-[10px] font-geist text-[0.78125rem] leading-[1.5] text-m-ink outline-none placeholder:text-m-faint'
|
||||
|
||||
interface SheetHeaderProps {
|
||||
title: ReactNode
|
||||
/** The icon tile in front of the title, as every phone sheet has. */
|
||||
icon?: ReactNode
|
||||
sub?: ReactNode
|
||||
onClose: () => void
|
||||
closeLabel: string
|
||||
}
|
||||
|
||||
/** Sheet header: 17px/700 title + 34px round close, hairline below. */
|
||||
export function SheetHeader({ title, onClose, closeLabel }: SheetHeaderProps) {
|
||||
/** Sheet header: the icon tile, the 17px/700 title (with an optional line under it) and the 34px round close, as TileHeader. */
|
||||
export function SheetHeader({ title, icon, sub, onClose, closeLabel }: SheetHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-none items-center gap-[11px] border-b border-[color:var(--m-rowbr)] px-[18px] pb-[10px] pt-4">
|
||||
<div className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold text-m-ink">{title}</div>
|
||||
<div className="flex flex-none items-center gap-3 px-[18px] pb-1 pt-4">
|
||||
{icon && (
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[1.0625rem] font-bold leading-tight text-m-ink">{title}</div>
|
||||
{sub && <div className="truncate font-geist text-[0.71875rem] text-m-muted">{sub}</div>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -55,7 +66,7 @@ export function CancelPill({ onClick, disabled, className = '', children }: Pill
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold text-m-ink disabled:opacity-40 ${className}`}
|
||||
className={`flex h-[42px] items-center justify-center 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 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -69,7 +80,7 @@ export function PrimaryPill({ onClick, disabled, className = '', children }: Pil
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center gap-[6px] rounded-full bg-m-act px-[18px] py-[9px] text-[0.78125rem] font-bold text-m-actfg disabled:opacity-40 ${className}`}
|
||||
className={`flex h-[42px] items-center justify-center gap-[6px] rounded-full bg-m-act px-4 text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold text-m-actfg disabled:opacity-40 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
@@ -211,8 +211,9 @@ function MCollTourSheet({
|
||||
const tour = takeOver.tour;
|
||||
const title = tour?.name ?? t('tours.collection.add');
|
||||
return (
|
||||
<MSheet open={takeOver.open} onClose={takeOver.close} material="opaque" ariaLabel={title}>
|
||||
<SheetHeader title={title} onClose={takeOver.close} closeLabel={t('common.close')} />
|
||||
<MSheet open={takeOver.open} onClose={takeOver.close} material="glass" ariaLabel={title}>
|
||||
<SheetHeader
|
||||
icon={<Route size={19} strokeWidth={1.8} />} title={title} onClose={takeOver.close} closeLabel={t('common.close')} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{tour && (
|
||||
<p className="mb-3 mt-0 font-geist text-m-muted" style={fs(11)}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CalendarDays, Copy, Loader2, MapPin, Search } from 'lucide-react'
|
||||
import { CalendarDays, Copy, Loader2, MapPin, Search, Luggage } from 'lucide-react'
|
||||
import type { TranslationFn } from '../../../types'
|
||||
import { useTripCopyPicker } from '../../../components/Collections/useTripCopyPicker'
|
||||
import MSheet from '../../components/MSheet'
|
||||
@@ -23,8 +23,9 @@ export default function MCollTripPickerSheet({ open, count, onCopy, onClose, t }
|
||||
const title = t('collections.copyN', { count })
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} material="opaque" ariaLabel={title}>
|
||||
<SheetHeader title={title} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<MSheet open={open} onClose={onClose} material="glass" ariaLabel={title}>
|
||||
<SheetHeader
|
||||
icon={<Luggage size={19} strokeWidth={1.8} />} title={title} onClose={onClose} closeLabel={t('common.close')} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
<div className="mb-2 flex h-9 items-center gap-2 rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-sheet)] px-[13px]">
|
||||
<Search size={15} strokeWidth={2.2} className="flex-none text-m-muted" />
|
||||
|
||||
@@ -12,7 +12,6 @@ import { STATUS_ORDER } from '../../../pages/collections/collectionsModel'
|
||||
import type { StatusFilter } from '../../../store/collectionStore'
|
||||
import { ALL_SAVED } from '../../../store/collectionStore'
|
||||
import CollectionMap from '../../../components/Collections/CollectionMap'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MCollPlaceRow from './MCollPlaceRow'
|
||||
import MCollPlaceSheet from './MCollPlaceSheet'
|
||||
import MCollAddSheet from './MCollAddSheet'
|
||||
@@ -24,7 +23,7 @@ import MCollListPickerSheet from './MCollListPickerSheet'
|
||||
import MCollTours from './MCollTours'
|
||||
import MCollInvites from './MCollInvites'
|
||||
import { STATUS_SPEC } from './collectionsMobileModel'
|
||||
import { CancelPill, PrimaryPill } from './MCollSheetKit'
|
||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
|
||||
const HEADER_CIRCLE =
|
||||
'flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border border-[color:var(--m-gbr)] bg-[color:var(--m-sheet)] text-m-ink shadow-[0_5px_12px_-8px_rgba(0,0,0,.18)]'
|
||||
@@ -73,16 +72,25 @@ export default function MCollections() {
|
||||
// The bottom-nav "+" hands off via the ?create= contract.
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const createParam = searchParams.get('create')
|
||||
const { activeId, canEdit, setShowAddPlace, ownedLists } = c
|
||||
const { activeId, canEdit, setShowAddPlace, ownedLists, setEditorTarget } = c
|
||||
const listCount = c.collections.length
|
||||
const listsLoading = c.loading
|
||||
useEffect(() => {
|
||||
if (createParam !== 'place' && createParam !== '1') return
|
||||
// Wait for the lists: whether there is one decides what "+" means.
|
||||
if (listsLoading) return
|
||||
const next = new URLSearchParams(searchParams)
|
||||
next.delete('create')
|
||||
setSearchParams(next, { replace: true })
|
||||
// Without a list yet, "+" starts the first one; places come after.
|
||||
if (listCount === 0) {
|
||||
setEditorTarget('new')
|
||||
return
|
||||
}
|
||||
// Open whenever the user can add somewhere: an editable active list, or at
|
||||
// least one owned list to pick as the target in the sheet.
|
||||
if ((typeof activeId === 'number' && canEdit) || ownedLists.length > 0) setShowAddPlace(true)
|
||||
}, [createParam, searchParams, setSearchParams, activeId, canEdit, ownedLists.length, setShowAddPlace])
|
||||
}, [createParam, searchParams, setSearchParams, activeId, canEdit, ownedLists.length, setShowAddPlace, listsLoading, listCount, setEditorTarget])
|
||||
|
||||
// Close transient popovers when the active list changes under them.
|
||||
useEffect(() => {
|
||||
@@ -219,12 +227,8 @@ export default function MCollections() {
|
||||
</div>
|
||||
) : noLists ? (
|
||||
<div className="mt-6">
|
||||
{/* The dock's "+" starts the first list (see the ?create= handoff above). */}
|
||||
<EmptyNote scene="collections" title={t('collections.empty.firstTitle')} />
|
||||
<div className="mt-4 flex justify-center">
|
||||
<PrimaryPill onClick={openNewList}>
|
||||
<Bookmark size={14} strokeWidth={2.2} /> {t('collections.newList')}
|
||||
</PrimaryPill>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -541,23 +545,16 @@ export default function MCollections() {
|
||||
/>
|
||||
|
||||
{/* Delete-list confirm */}
|
||||
<MSheet
|
||||
<MConfirmSheet
|
||||
open={c.confirmDeleteList != null}
|
||||
onClose={() => c.setConfirmDeleteList(null)}
|
||||
material="opaque"
|
||||
ariaLabel={t('collections.deleteList')}
|
||||
>
|
||||
<div className="px-[18px] pb-[18px] pt-4">
|
||||
<div className="text-[1.0625rem] font-bold text-m-ink">{t('collections.deleteList')}</div>
|
||||
<div className="mt-2 font-geist text-[0.78125rem] leading-[1.5] text-m-muted">{t('collections.deleteListConfirm')}</div>
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<CancelPill className="ms-auto" onClick={() => c.setConfirmDeleteList(null)}>{t('common.cancel')}</CancelPill>
|
||||
<PrimaryPill onClick={c.handleDeleteList} className="!bg-[color:var(--m-st-danger)] !text-white">
|
||||
<Trash2 size={14} strokeWidth={2.2} /> {t('common.delete')}
|
||||
</PrimaryPill>
|
||||
</div>
|
||||
</div>
|
||||
</MSheet>
|
||||
title={t('collections.deleteList')}
|
||||
message={t('collections.deleteListConfirm')}
|
||||
confirmLabel={t('common.delete')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
onConfirm={c.handleDeleteList}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,13 +21,13 @@ import { entityGradient } from '../../../utils/gradients'
|
||||
import MGlassBar from '../../components/MGlassBar'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MSegmented from '../../components/MSegmented'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MUserMenu from './MUserMenu'
|
||||
import { useMobileDashOrder, useMobileDashVisibility, MobileDashWidget } from './MDashWidgets'
|
||||
import MNewTripSheet from './MNewTripSheet'
|
||||
import type { MobileDashToken } from '@trek/shared'
|
||||
import { usePullToRefresh } from '../../usePullToRefresh'
|
||||
import MPullIndicator from '../../components/MPullIndicator'
|
||||
import SharedConfirmSheet from '../settings/MConfirmSheet'
|
||||
|
||||
type TripFilter = 'planned' | 'archive' | 'completed'
|
||||
|
||||
@@ -581,36 +581,11 @@ function ListStat({ value, label }: { value: number; label: string }): React.Rea
|
||||
)
|
||||
}
|
||||
|
||||
// ── Confirm sheet (delete / copy) ────────────────────────────────────────────
|
||||
function MConfirmSheet({ open, title, message, confirmLabel, danger = false, onConfirm, onClose }: {
|
||||
// ── Confirm sheet (delete / copy): the shared one, with the common cancel ─────
|
||||
function MConfirmSheet(props: {
|
||||
open: boolean; title: string; message: string; confirmLabel: string
|
||||
danger?: boolean; onConfirm: () => void; onClose: () => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="card" ariaLabel={title}>
|
||||
<div className="p-5">
|
||||
<div className="text-[1.0625rem] font-bold">{title}</div>
|
||||
<div className="mt-2 text-[0.8125rem] leading-relaxed text-m-muted">{message}</div>
|
||||
<div className="mt-5 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-full bg-[color:var(--m-ic)] py-[10px] text-[0.8125rem] font-semibold text-m-ink"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className={`flex-1 rounded-full py-[10px] text-[0.8125rem] font-semibold ${
|
||||
danger ? 'bg-[color:var(--m-st-danger)] text-white' : 'bg-m-act text-m-actfg'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
return <SharedConfirmSheet {...props} cancelLabel={t('common.cancel')} />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Archive, ArchiveRestore, Bell, Camera, Search, X } from 'lucide-react'
|
||||
import { Archive, ArchiveRestore, Bell, Camera, Luggage, Search, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useCanDo } from '../../../store/permissionsStore'
|
||||
import { useSettingsStore } from '../../../store/settingsStore'
|
||||
@@ -11,8 +11,8 @@ import { currenciesWith, SYMBOLS } from '../../../components/Budget/BudgetPanel.
|
||||
import type { DashboardTrip } from '../../../pages/dashboard/dashboardModel'
|
||||
import { MAX_TRIP_DAYS, type Trip, type TripCreateRequest } from '@trek/shared'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MListRow from '../../components/MListRow'
|
||||
import { FIELD_AREA_CLS } from '../trip/sheets/PlSheetChrome'
|
||||
import { Eyebrow, SHEET_FIELD, SHEET_PRIMARY, SHEET_ROUND_PRIMARY, SHEET_SECONDARY, SheetSection, TileHeader } from '../trip/sheets/MTripSheetUi'
|
||||
import MDayImpactList from '../../components/MDayImpactList'
|
||||
import MConfirmSheet from '../settings/MConfirmSheet'
|
||||
import { useTripRangeGuard, type RangeRemoval } from '../../../hooks/useTripRangeGuard'
|
||||
@@ -31,17 +31,12 @@ 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'
|
||||
/** A date picker in the sheets' round field. */
|
||||
const DATE_FIELD = 'flex h-[42px] items-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px]'
|
||||
|
||||
/** The reminder presets the desktop form offers; anything else is a custom number of days. */
|
||||
const REMINDER_PRESETS = [0, 1, 3, 9]
|
||||
|
||||
function FieldLabel({ children }: { children: React.ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<div className="font-geist text-[0.5625rem] font-bold uppercase tracking-[.1em] text-m-faint">{children}</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create/edit trip sheet — the mobile counterpart of TripFormModal's core flow:
|
||||
@@ -151,79 +146,78 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
|
||||
}
|
||||
}
|
||||
|
||||
const boxCls = BOX_CLS
|
||||
const inputCls = INPUT_CLS
|
||||
|
||||
const removalList = pendingRemoval && pendingRemoval.removal !== 'unknown' ? pendingRemoval.removal : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="opaque" ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
|
||||
<div className="flex items-center gap-[11px] p-[16px_16px_0]">
|
||||
<div className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold">
|
||||
{isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
|
||||
</div>
|
||||
<MIconBtn ariaLabel={t('common.cancel')} variant="neutral" size={34} onClick={onClose}>
|
||||
<X size={16} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<Luggage size={19} strokeWidth={1.8} />}
|
||||
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
|
||||
sub={isEditing ? trip?.title : undefined}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4">
|
||||
<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]">
|
||||
{error && (
|
||||
<div className="mb-3 rounded-[14px] bg-[color:var(--m-ic)] p-[11px_12px] text-[0.75rem] font-semibold text-[color:var(--m-st-danger)]">
|
||||
<div className="flex-none rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px] text-caption font-semibold text-[color:var(--m-st-danger)]">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={boxCls}>
|
||||
<FieldLabel>{t('dashboard.tripTitle')}</FieldLabel>
|
||||
<SheetSection title={t('dashboard.tripTitle')}>
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => { if (canEditTrip) { setError(''); setTitle(e.target.value) } }}
|
||||
readOnly={!canEditTrip}
|
||||
placeholder={t('dashboard.tripTitlePlaceholder')}
|
||||
className={inputCls}
|
||||
aria-label={t('dashboard.tripTitle')}
|
||||
className={`${SHEET_FIELD} w-full flex-none font-semibold`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${boxCls} mt-2`}>
|
||||
<FieldLabel>{t('dashboard.tripDescription')}</FieldLabel>
|
||||
<Eyebrow className="mt-1 uppercase">{t('dashboard.tripDescription')}</Eyebrow>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => { if (canEditTrip) { setError(''); setDescription(e.target.value) } }}
|
||||
readOnly={!canEditTrip}
|
||||
placeholder={t('dashboard.tripDescriptionPlaceholder')}
|
||||
aria-label={t('dashboard.tripDescription')}
|
||||
rows={2}
|
||||
className={`${inputCls} resize-none text-[0.8125rem] font-medium`}
|
||||
className={FIELD_AREA_CLS}
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
<div className={`${boxCls} min-w-0 flex-1`}>
|
||||
<FieldLabel>{t('dashboard.startDate')}</FieldLabel>
|
||||
<CustomDatePicker
|
||||
value={startDate}
|
||||
onChange={v => { if (canEditTrip) changeStart(v) }}
|
||||
placeholder={t('dashboard.startDate')}
|
||||
borderless
|
||||
style={{ marginTop: 3 }}
|
||||
/>
|
||||
<SheetSection>
|
||||
<div className="flex gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('dashboard.startDate')}</Eyebrow>
|
||||
<div className={DATE_FIELD}>
|
||||
<CustomDatePicker
|
||||
value={startDate}
|
||||
onChange={v => { if (canEditTrip) changeStart(v) }}
|
||||
placeholder={t('dashboard.startDate')}
|
||||
borderless
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('dashboard.endDate')}</Eyebrow>
|
||||
<div className={DATE_FIELD}>
|
||||
<CustomDatePicker
|
||||
value={endDate}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
|
||||
placeholder={t('dashboard.endDate')}
|
||||
borderless
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${boxCls} min-w-0 flex-1`}>
|
||||
<FieldLabel>{t('dashboard.endDate')}</FieldLabel>
|
||||
<CustomDatePicker
|
||||
value={endDate}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setEndDate(v) } }}
|
||||
placeholder={t('dashboard.endDate')}
|
||||
borderless
|
||||
style={{ marginTop: 3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!startDate && !endDate && (
|
||||
<>
|
||||
<div className={`${boxCls} mt-2`}>
|
||||
<FieldLabel>{t('dashboard.dayCount')}</FieldLabel>
|
||||
{!startDate && !endDate && (
|
||||
<>
|
||||
<Eyebrow className="mt-1 uppercase">{t('dashboard.dayCount')}</Eyebrow>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
@@ -237,15 +231,15 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
|
||||
setDayCount(raw === '' ? '' : Math.min(MAX_TRIP_DAYS, Math.max(1, Math.floor(Number(raw)) || 1)))
|
||||
}}
|
||||
readOnly={!canEditTrip}
|
||||
className={inputCls}
|
||||
aria-label={t('dashboard.dayCount')}
|
||||
className={`${SHEET_FIELD} w-full flex-none`}
|
||||
/>
|
||||
</div>
|
||||
{!isEditing && <div className="mt-[6px] px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</div>}
|
||||
</>
|
||||
)}
|
||||
{!isEditing && <p className="px-1 font-geist text-[length:calc(10px*var(--fs-scale-caption,1))] text-m-faint">{t('dashboard.noDateHint')}</p>}
|
||||
</>
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
<div className="mt-2">
|
||||
<FieldLabel>{t('dashboard.currency')}</FieldLabel>
|
||||
<SheetSection title={t('dashboard.currency')}>
|
||||
<CustomSelect
|
||||
value={currency}
|
||||
onChange={v => { if (canEditTrip) { setError(''); setCurrency(String(v)) } }}
|
||||
@@ -253,48 +247,46 @@ export default function MNewTripSheet({ open, trip: tripProp, onClose, onSave, o
|
||||
searchable
|
||||
size="sm"
|
||||
options={currenciesWith(currency).map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
|
||||
style={{ width: '100%', marginTop: 5 }}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
{showReminder && (
|
||||
<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}
|
||||
/>
|
||||
<SheetSection>
|
||||
<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}
|
||||
/>
|
||||
</SheetSection>
|
||||
)}
|
||||
|
||||
{canUploadCover && <CoverField cover={cover} title={title} />}
|
||||
{canUploadCover && (
|
||||
<SheetSection>
|
||||
<CoverField cover={cover} title={title} />
|
||||
</SheetSection>
|
||||
)}
|
||||
|
||||
{isEditing && onArchive && (
|
||||
<div className="mt-2 rounded-[14px] bg-[color:var(--m-ic)]">
|
||||
<MListRow
|
||||
icon={trip?.is_archived ? ArchiveRestore : Archive}
|
||||
label={trip?.is_archived ? t('dashboard.restore') : t('dashboard.archive')}
|
||||
onClick={() => { onArchive(); onClose() }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onArchive(); onClose() }}
|
||||
className={`${SHEET_SECONDARY} flex-none`}
|
||||
>
|
||||
{trip?.is_archived ? <ArchiveRestore size={14} strokeWidth={2.2} /> : <Archive size={14} strokeWidth={2.2} />}
|
||||
{trip?.is_archived ? t('dashboard.restore') : t('dashboard.archive')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 p-[0_16px_16px]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-full bg-[color:var(--m-ic)] py-[10px] text-[0.8125rem] font-semibold text-m-ink"
|
||||
>
|
||||
<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={onClose} className={`${SHEET_SECONDARY} ms-auto`}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={busy}
|
||||
className="flex-1 rounded-full bg-m-act py-[10px] text-[0.8125rem] font-semibold text-m-actfg disabled:opacity-50"
|
||||
>
|
||||
<button type="button" onClick={handleSave} disabled={busy} className={SHEET_PRIMARY}>
|
||||
{busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -340,10 +332,10 @@ function ReminderField({ enabled, days, custom, onPreset, onCustom, onDays }: {
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={`${BOX_CLS} mt-2`}>
|
||||
<FieldLabel>
|
||||
<div>
|
||||
<Eyebrow className="uppercase">
|
||||
<span className="inline-flex items-center gap-1"><Bell size={10} strokeWidth={2.4} />{t('trips.reminder')}</span>
|
||||
</FieldLabel>
|
||||
</Eyebrow>
|
||||
{!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>
|
||||
) : (
|
||||
@@ -429,21 +421,20 @@ function CoverField({ cover, title }: { cover: ReturnType<typeof useTripCoverPic
|
||||
)}
|
||||
|
||||
<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>
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void cover.search() } }}
|
||||
placeholder={t('dashboard.unsplashSearchPlaceholder')}
|
||||
aria-label={t('dashboard.unsplashSearchPlaceholder')}
|
||||
className={SHEET_FIELD}
|
||||
/>
|
||||
<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"
|
||||
className={SHEET_ROUND_PRIMARY}
|
||||
>
|
||||
<Search size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Feather, Calendar, MapPin, Check, X } from 'lucide-react'
|
||||
import { Feather, Calendar, MapPin, Check, NotebookPen } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import { SHEET_PRIMARY, SHEET_SECONDARY, TileHeader } from '../trip/sheets/MTripSheetUi'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { pickGradient } from '../../../pages/journeyDetail/JourneyDetailPage.helpers'
|
||||
|
||||
@@ -38,24 +38,27 @@ export default function MJourneyCreateSheet({
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="opaque" ariaLabel={t('journey.frontpage.createJourney')}>
|
||||
<div className="flex flex-none items-center border-b border-[color:var(--m-rowbr)] px-[18px] pb-[10px] pt-4">
|
||||
<div className="flex-1 text-[1.0625rem] font-bold">{t('journey.frontpage.createJourney')}</div>
|
||||
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.cancel')}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('journey.frontpage.createJourney')}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<NotebookPen size={19} strokeWidth={1.8} />}
|
||||
title={t('journey.frontpage.createJourney')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.cancel')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-3">
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
<div className="mb-[6px] font-geist text-[0.625rem] font-bold uppercase tracking-[.09em] text-m-faint">
|
||||
{t('journey.frontpage.journeyName')}
|
||||
</div>
|
||||
<div className="flex items-center gap-[10px] rounded-[14px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-3">
|
||||
<div className="flex h-[42px] items-center gap-[10px] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px]">
|
||||
<Feather size={16} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
<input
|
||||
value={title}
|
||||
onChange={e => onTitleChange(e.target.value)}
|
||||
placeholder={t('journey.frontpage.namePlaceholder')}
|
||||
aria-label={t('journey.frontpage.journeyName')}
|
||||
className="min-w-0 flex-1 bg-transparent text-[0.875rem] font-semibold text-m-ink outline-none placeholder:text-m-faint"
|
||||
/>
|
||||
</div>
|
||||
@@ -120,19 +123,10 @@ export default function MJourneyCreateSheet({
|
||||
</div>
|
||||
|
||||
<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={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`}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreate}
|
||||
disabled={!title.trim()}
|
||||
className="flex-1 rounded-full bg-m-act px-[18px] py-[9px] text-center text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-40"
|
||||
>
|
||||
<button type="button" onClick={onCreate} disabled={!title.trim()} className={SHEET_PRIMARY}>
|
||||
{t('journey.create')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -528,7 +528,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
{/* Opaque, not the glass default: these two sit over a full-screen map whose
|
||||
card carousel is bright and busy, and the frosted material let the titles
|
||||
behind read straight through the menu. */}
|
||||
<MSheet open={showUploadMenu} onClose={() => setShowUploadMenu(false)} variant="bottom" material="opaque" ariaLabel={t('common.upload')}>
|
||||
<MSheet open={showUploadMenu} onClose={() => setShowUploadMenu(false)} variant="bottom" material="glass" ariaLabel={t('common.upload')}>
|
||||
<div className="flex flex-col gap-2 p-[10px]">
|
||||
<MListRow
|
||||
icon={Camera}
|
||||
@@ -547,7 +547,7 @@ export default function MJourneyDetail({ journeyId, embedded = false }: JourneyD
|
||||
</MSheet>
|
||||
|
||||
{/* Journey actions: book export, suggestions switch, settings */}
|
||||
<MSheet open={showActionMenu} onClose={() => setShowActionMenu(false)} variant="bottom" material="opaque" ariaLabel={t('files.menu')}>
|
||||
<MSheet open={showActionMenu} onClose={() => setShowActionMenu(false)} variant="bottom" material="glass" ariaLabel={t('files.menu')}>
|
||||
<div className="flex flex-col gap-1 p-[10px]">
|
||||
<div className="flex items-center gap-[11px] px-[10px] py-[11px]">
|
||||
<EyeOff size={16} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useRef, useState, type CSSProperties } from 'react'
|
||||
import { Briefcase, Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff, Play } from 'lucide-react'
|
||||
import { Briefcase, Camera, Plus, Image, Images, X, MapPin, Locate, Trash2, CheckCircle2, MinusCircle, ChevronUp, ChevronDown, EyeOff, Play, NotebookPen } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MToggle from '../../components/MToggle'
|
||||
import { SHEET_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import CustomTimePicker from '../../../components/shared/CustomTimePicker'
|
||||
import { CustomDatePicker } from '../../../components/shared/CustomDateTimePicker'
|
||||
@@ -124,11 +125,15 @@ export default function MJourneyEntrySheet({
|
||||
open
|
||||
onClose={handleClose}
|
||||
variant="card"
|
||||
material="opaque"
|
||||
material="glass"
|
||||
ariaLabel={entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
|
||||
>
|
||||
<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">
|
||||
{/* TileHeader's shape, with the move buttons between the title and the close. */}
|
||||
<div className="flex flex-none items-center gap-3 px-[18px] pb-1 pt-4">
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<NotebookPen size={19} strokeWidth={1.8} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[1.0625rem] font-bold leading-tight">
|
||||
{entry.id === 0 ? t('journey.detail.newEntry') : t('journey.detail.editEntry')}
|
||||
</span>
|
||||
{(onMoveEarlier || onMoveLater) && (
|
||||
@@ -146,7 +151,7 @@ export default function MJourneyEntrySheet({
|
||||
</MIconBtn>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] py-3">
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{/* Quick capture had no title field at all: it asked for a note and nothing
|
||||
else, so every entry caught on the move arrived nameless and the day's
|
||||
list read as a column of identical placeholders (discussion #2299). The
|
||||
@@ -713,17 +718,17 @@ export default function MJourneyEntrySheet({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="flex items-center gap-[5px] text-[0.75rem] font-bold text-[color:var(--m-st-danger)]"
|
||||
aria-label={t('common.delete')}
|
||||
className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-[color:var(--m-st-danger)]"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={2} />
|
||||
{t('common.delete')}
|
||||
<Trash2 size={15} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
{!readOnly && captureOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCaptureOnly(false)}
|
||||
className="whitespace-nowrap rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-4 py-[9px] text-[0.78125rem] font-semibold"
|
||||
className={`${SHEET_SECONDARY} whitespace-nowrap`}
|
||||
>
|
||||
{t('journey.editor.addDetails')}
|
||||
</button>
|
||||
@@ -731,7 +736,7 @@ export default function MJourneyEntrySheet({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
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"
|
||||
className={`${SHEET_SECONDARY} ms-auto`}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
@@ -740,7 +745,7 @@ export default function MJourneyEntrySheet({
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-full bg-m-act px-[18px] py-[9px] text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-50"
|
||||
className={SHEET_PRIMARY}
|
||||
>
|
||||
{saving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
|
||||
@@ -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, Share as ShareIcon } from 'lucide-react'
|
||||
import { canShareLink, shareLink } from '../../../native/shareLink'
|
||||
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,277 +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>
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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' && (
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2 rounded-[14px] bg-[color:var(--m-ic)] px-3 py-[10px]">
|
||||
<LinkIcon size={13} className="flex-none text-m-faint" />
|
||||
<span className="min-w-0 flex-1 truncate font-geist text-[0.6875rem] text-m-muted">{shareUrl}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyShareUrl}
|
||||
className="flex-none rounded-full bg-m-act px-3 py-[5px] font-geist text-[0.65625rem] font-bold text-m-actfg"
|
||||
>
|
||||
{copied ? t('journey.share.copied') : t('journey.share.copy')}
|
||||
</button>
|
||||
{canShareLink() && shareUrl && (
|
||||
{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>
|
||||
|
||||
<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"
|
||||
aria-label={t('common.share')}
|
||||
onClick={() => void shareLink(shareUrl)}
|
||||
className="flex h-[26px] w-[26px] flex-none items-center justify-center rounded-full bg-m-act text-m-actfg"
|
||||
onClick={() => handleRemoveContributor(c)}
|
||||
aria-label={t('journey.contributors.remove')}
|
||||
className={SHEET_ROUND}
|
||||
>
|
||||
<ShareIcon size={12} strokeWidth={2.4} />
|
||||
<UserMinus size={15} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{([
|
||||
{ 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="mt-1 font-geist text-[0.6875rem] font-semibold text-[color:var(--m-st-danger)]"
|
||||
>
|
||||
{t('journey.share.removeLink')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
))}
|
||||
<button type="button" onClick={onOpenInvite} className={SHEET_SECONDARY}>
|
||||
<UserPlus size={14} strokeWidth={2.2} />
|
||||
{t('journey.contributors.invite')}
|
||||
</button>
|
||||
</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} />}
|
||||
<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>
|
||||
@@ -357,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -424,11 +414,12 @@ function MRecordingPrepRow({ prep, enabled }: { prep: JourneyRecordingPrepState;
|
||||
function MShareMapExtras({ link, onToggle }: { link: JourneyShareLink; onToggle: (key: JourneyShareMapExtra) => unknown }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<>
|
||||
// Hangs under the map row: a line from below its pin, smaller labels.
|
||||
<div className="mb-1 ms-[9px] border-s-2 border-[color:var(--m-rowbr)] ps-[16px]">
|
||||
{SHARE_MAP_EXTRAS.map(({ key, labelKey, hintKey }) => (
|
||||
<div key={key} className={`flex items-center gap-[11px] py-[8px] ps-[29px] pe-[2px] ${link.share_map ? '' : 'opacity-50'}`}>
|
||||
<div key={key} className={`flex items-center gap-[11px] py-[7px] pe-[2px] ${link.share_map ? '' : 'opacity-50'}`}>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="text-body font-bold">{t(labelKey)}</span>
|
||||
<span className="text-[length:calc(13px*var(--fs-scale-body,1))] font-semibold">{t(labelKey)}</span>
|
||||
<span className="font-geist text-caption text-m-muted">
|
||||
{link.share_map ? t(hintKey) : t('journey.share.needsMap')}
|
||||
</span>
|
||||
@@ -441,6 +432,6 @@ function MShareMapExtras({ link, onToggle }: { link: JourneyShareLink; onToggle:
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { AlertTriangle, CircleHelp, Info } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import { MSetButton } from './MSettingsUi'
|
||||
import { SHEET_DANGER, SHEET_PRIMARY, SHEET_SECONDARY } from '../trip/sheets/MTripSheetUi'
|
||||
|
||||
interface MConfirmSheetProps {
|
||||
open: boolean
|
||||
@@ -17,9 +18,11 @@ interface MConfirmSheetProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Small confirm dialog as a centred floating card. Without onConfirm it is a
|
||||
* plain notice. The card stops at the screen height: a long message or list
|
||||
* scrolls between the title and the buttons, which always stay in reach.
|
||||
* Small confirm dialog as a centred floating card, in the shape of every phone
|
||||
* sheet: the icon tile and title, the message, the sheets' 42 px buttons.
|
||||
* Without onConfirm it is a plain notice. The card stops at the screen height:
|
||||
* a long message or list scrolls between the title and the buttons, which
|
||||
* always stay in reach.
|
||||
*/
|
||||
export default function MConfirmSheet({
|
||||
open,
|
||||
@@ -33,25 +36,42 @@ export default function MConfirmSheet({
|
||||
onConfirm,
|
||||
children,
|
||||
}: MConfirmSheetProps) {
|
||||
const Icon = danger ? AlertTriangle : onConfirm ? CircleHelp : Info
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="opaque" ariaLabel={title}>
|
||||
<div className="flex min-h-0 flex-col p-[18px]">
|
||||
<div className="flex-none text-[0.9375rem] font-extrabold text-m-ink">{title}</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
||||
<p className="mt-2 text-[0.78125rem] leading-relaxed text-m-muted text-pretty">{message}</p>
|
||||
{children}
|
||||
</div>
|
||||
{/* A long label moves its button to a row of its own instead of wrapping inside it. */}
|
||||
<div className="mt-4 flex flex-none flex-wrap justify-end gap-2">
|
||||
<MSetButton variant="ghost" onClick={onClose} className="whitespace-nowrap">
|
||||
{cancelLabel}
|
||||
</MSetButton>
|
||||
{onConfirm && confirmLabel && (
|
||||
<MSetButton variant={danger ? 'danger' : 'primary'} onClick={onConfirm} disabled={busy} className="whitespace-nowrap">
|
||||
{confirmLabel}
|
||||
</MSetButton>
|
||||
)}
|
||||
</div>
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={title}>
|
||||
<div className="flex flex-none items-center gap-3 px-[18px] pt-4">
|
||||
<span
|
||||
className={`flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] ${
|
||||
danger ? 'text-[color:var(--m-st-danger)]' : 'text-m-ink'
|
||||
}`}
|
||||
>
|
||||
<Icon size={19} strokeWidth={1.8} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 text-[1.0625rem] font-bold leading-tight text-m-ink text-pretty">{title}</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-[18px] pt-3">
|
||||
<p className="text-[0.8125rem] leading-relaxed text-m-muted text-pretty">{message}</p>
|
||||
{children}
|
||||
</div>
|
||||
{/* A long label moves its button to a row of its own instead of wrapping inside it. */}
|
||||
<div className="flex flex-none flex-wrap justify-end gap-2 px-[18px] pb-[18px] pt-4">
|
||||
<button type="button" onClick={onClose} className={`${SHEET_SECONDARY} whitespace-nowrap`}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
{onConfirm && confirmLabel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
className={
|
||||
danger
|
||||
? `${SHEET_DANGER} whitespace-nowrap`
|
||||
: `${SHEET_PRIMARY} whitespace-nowrap`
|
||||
}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</MSheet>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import { TileHeader } from '../trip/sheets/MTripSheetUi'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
|
||||
export interface MSetPickerOption {
|
||||
value: string
|
||||
@@ -21,10 +23,14 @@ interface MSetPickerSheetProps {
|
||||
* presets). Selecting closes the sheet.
|
||||
*/
|
||||
export default function MSetPickerSheet({ open, onClose, title, options, value, onSelect }: MSetPickerSheetProps) {
|
||||
const { t } = useTranslation()
|
||||
const closeLabel = t('common.close')
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="bottom" material="opaque" ariaLabel={title}>
|
||||
<div className="px-[14px] pb-2 pt-4 text-[0.875rem] font-extrabold text-m-ink">{title}</div>
|
||||
<div className="min-h-0 overflow-y-auto px-2 pb-3">
|
||||
<MSheet open={open} onClose={onClose} variant="bottom" material="glass" ariaLabel={title}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader title={title} onClose={onClose} closeLabel={closeLabel} />
|
||||
</div>
|
||||
<div className="m-scroll-fade mt-1 min-h-0 overflow-y-auto px-2 pb-3 pt-2">
|
||||
{options.map((opt) => {
|
||||
const active = opt.value === value
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Check, ChevronDown, ChevronRight, Copy, KeyRound, Plus, RefreshCw, Terminal, Trash2 } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Check, ChevronDown, ChevronRight, Copy, KeyRound, Plus, RefreshCw, Terminal, Trash2, Key, CircleCheck, RotateCcw } from 'lucide-react'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useFormatDate } from '../../../hooks/useDateFormat'
|
||||
import { OAUTH_PRESETS, useMcpIntegration } from '../../../components/Settings/useMcpIntegration'
|
||||
@@ -300,13 +301,13 @@ export default function MSettingsMcp() {
|
||||
if (!createdClient) setCreateOpen(false)
|
||||
}}
|
||||
variant="card"
|
||||
material="opaque"
|
||||
material="glass"
|
||||
ariaLabel={t('settings.oauth.modal.createTitle')}
|
||||
>
|
||||
<div className="min-h-0 overflow-y-auto p-[18px]">
|
||||
{!createdClient ? (
|
||||
<>
|
||||
<div className="text-[0.9375rem] font-extrabold text-m-ink">{t('settings.oauth.modal.createTitle')}</div>
|
||||
<SheetTitle icon={<KeyRound size={19} strokeWidth={1.8} />}>{t('settings.oauth.modal.createTitle')}</SheetTitle>
|
||||
|
||||
<MSetEyebrow className="mb-[6px] mt-3">{t('settings.oauth.modal.presets')}</MSetEyebrow>
|
||||
<div className="flex flex-wrap gap-[6px]">
|
||||
@@ -361,7 +362,7 @@ export default function MSettingsMcp() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[0.9375rem] font-extrabold text-m-ink">{t('settings.oauth.modal.createdTitle')}</div>
|
||||
<SheetTitle icon={<CircleCheck size={19} strokeWidth={1.8} />}>{t('settings.oauth.modal.createdTitle')}</SheetTitle>
|
||||
<p className="mt-2 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3 font-geist text-[0.625rem] leading-relaxed text-[color:var(--m-st-pending)]">
|
||||
{t('settings.oauth.modal.createdWarning')}
|
||||
</p>
|
||||
@@ -409,13 +410,13 @@ export default function MSettingsMcp() {
|
||||
if (!createdToken) setTokenModalOpen(false)
|
||||
}}
|
||||
variant="card"
|
||||
material="opaque"
|
||||
material="glass"
|
||||
ariaLabel={t('settings.mcp.modal.createTitle')}
|
||||
>
|
||||
<div className="p-[18px]">
|
||||
{!createdToken ? (
|
||||
<>
|
||||
<div className="text-[0.9375rem] font-extrabold text-m-ink">{t('settings.mcp.modal.createTitle')}</div>
|
||||
<SheetTitle icon={<Key size={19} strokeWidth={1.8} />}>{t('settings.mcp.modal.createTitle')}</SheetTitle>
|
||||
<MSetEyebrow className="mb-[5px] mt-3">{t('settings.mcp.modal.tokenName')}</MSetEyebrow>
|
||||
<MSetInput
|
||||
autoFocus
|
||||
@@ -435,7 +436,7 @@ export default function MSettingsMcp() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-[0.9375rem] font-extrabold text-m-ink">{t('settings.mcp.modal.createdTitle')}</div>
|
||||
<SheetTitle icon={<CircleCheck size={19} strokeWidth={1.8} />}>{t('settings.mcp.modal.createdTitle')}</SheetTitle>
|
||||
<p className="mt-2 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3 font-geist text-[0.625rem] leading-relaxed text-[color:var(--m-st-pending)]">
|
||||
{t('settings.mcp.modal.createdWarning')}
|
||||
</p>
|
||||
@@ -461,9 +462,9 @@ export default function MSettingsMcp() {
|
||||
</MSheet>
|
||||
|
||||
{/* Rotated secret display */}
|
||||
<MSheet open={rotatedSecret !== null} onClose={() => setRotatedSecret(null)} variant="card" material="opaque" ariaLabel={t('settings.oauth.rotateSecretDoneTitle')}>
|
||||
<MSheet open={rotatedSecret !== null} onClose={() => setRotatedSecret(null)} variant="card" material="glass" ariaLabel={t('settings.oauth.rotateSecretDoneTitle')}>
|
||||
<div className="p-[18px]">
|
||||
<div className="text-[0.9375rem] font-extrabold text-m-ink">{t('settings.oauth.rotateSecretDoneTitle')}</div>
|
||||
<SheetTitle icon={<RotateCcw size={19} strokeWidth={1.8} />}>{t('settings.oauth.rotateSecretDoneTitle')}</SheetTitle>
|
||||
<p className="mt-2 rounded-xl border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-3 font-geist text-[0.625rem] leading-relaxed text-[color:var(--m-st-pending)]">
|
||||
{t('settings.oauth.rotateSecretDoneWarning')}
|
||||
</p>
|
||||
@@ -527,3 +528,13 @@ export default function MSettingsMcp() {
|
||||
</DemoLock>
|
||||
)
|
||||
}
|
||||
|
||||
/** The sheets' title row: the icon tile and the title, as TileHeader without the close (these close by their buttons). */
|
||||
function SheetTitle({ icon, children }: { icon: ReactNode; children: ReactNode }) {
|
||||
return (
|
||||
<div className="mb-1 flex items-center gap-3">
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">{icon}</span>
|
||||
<div className="min-w-0 flex-1 text-[1.0625rem] font-bold leading-tight text-m-ink">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { canManageDocSync } from '../../../components/Files/docsync/useDocSync'
|
||||
import { useDocSyncOffered } from '../../../components/Files/docsync/useDocSyncOffered'
|
||||
import type { Day, Trip } from '../../../types'
|
||||
import { getIntlLanguage } from '@trek/shared'
|
||||
import { useDayPreviewStore } from '../../dayPreviewStore'
|
||||
|
||||
/**
|
||||
* Mobile trip screen frame. Owns the chrome the design shares across every
|
||||
@@ -271,11 +272,16 @@ export default function MTripShell({
|
||||
// The opening day is chosen while the splash still covers the shell (#2880),
|
||||
// so the rail itself is a dependency: the chip is lined up when the rail
|
||||
// appears, in place rather than gliding across a rail nobody has seen yet.
|
||||
// The rail lights the day a swipe is heading for before the plan switches (dayPreviewStore).
|
||||
const previewDayId = useDayPreviewStore(s => s.dayId)
|
||||
const clearDayPreview = useDayPreviewStore(s => s.setDayId)
|
||||
useEffect(() => { clearDayPreview(null) }, [planner.selectedDayId, clearDayPreview])
|
||||
const railDayId = previewDayId ?? planner.selectedDayId
|
||||
const chipRefs = useRef(new Map<number, HTMLButtonElement>())
|
||||
const [dayRail, setDayRail] = useState<HTMLDivElement | null>(null)
|
||||
const alignedRailRef = useRef<HTMLDivElement | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
const chip = planner.selectedDayId != null ? chipRefs.current.get(planner.selectedDayId) : undefined
|
||||
const chip = railDayId != null ? chipRefs.current.get(railDayId) : undefined
|
||||
if (!chip || !dayRail) return
|
||||
const firstView = alignedRailRef.current !== dayRail
|
||||
alignedRailRef.current = dayRail
|
||||
@@ -290,7 +296,7 @@ export default function MTripShell({
|
||||
inline: 'center',
|
||||
block: 'nearest',
|
||||
})
|
||||
}, [planner.selectedDayId, days, dayRail])
|
||||
}, [railDayId, days, dayRail])
|
||||
// The active day's pill glides to the next chip rather than jumping there.
|
||||
// A booking document shared to TREK, and this trip picked for it: open the import.
|
||||
const sharedTripId = useSharedImportStore(s => s.tripId)
|
||||
@@ -300,7 +306,7 @@ export default function MTripShell({
|
||||
planner.setShowBookingImport(true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sharedTripId, tripId])
|
||||
const dayPill = useActivePill(dayRail, '[aria-current="true"]', [planner.selectedDayId, days, dayRail, language])
|
||||
const dayPill = useActivePill(dayRail, '[aria-current="true"]', [railDayId, days, dayRail, language])
|
||||
|
||||
const trTab = planner.activeTab
|
||||
|
||||
@@ -515,7 +521,7 @@ export default function MTripShell({
|
||||
/>
|
||||
)}
|
||||
{days.map((day, idx) => {
|
||||
const active = day.id === planner.selectedDayId
|
||||
const active = day.id === railDayId
|
||||
const isToday = day.id === todayDayId
|
||||
const tint = dayTints[day.id]
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MapPin, X } from 'lucide-react'
|
||||
import { MapPin, Tag } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import MIconBtn from '../../../components/MIconBtn'
|
||||
import { TileHeader } from '../sheets/MTripSheetUi'
|
||||
import { getCategoryIcon } from '../../../../components/shared/categoryIcons'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import type { Category } from '../../../../types'
|
||||
@@ -21,17 +21,17 @@ interface MPlacesBulkCategorySheetProps {
|
||||
export default function MPlacesBulkCategorySheet({ open, count, categories, onPick, onClose }: MPlacesBulkCategorySheetProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="card" ariaLabel={t('places.changeCategory')}>
|
||||
<div className="flex flex-none items-center border-b border-[color:var(--m-rowbr)] px-[18px] pb-[11px] pt-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[1.03125rem] font-bold text-m-ink">{t('places.changeCategory')}</div>
|
||||
<div className="mt-[2px] font-geist text-[0.6875rem] text-m-muted">{t('places.selectionCount', { count })}</div>
|
||||
</div>
|
||||
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.close')}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('places.changeCategory')}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<Tag size={19} strokeWidth={1.8} />}
|
||||
title={t('places.changeCategory')}
|
||||
sub={t('places.selectionCount', { count })}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[10px] pb-3 pt-[6px]">
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[10px] pb-3 pt-[10px]">
|
||||
{categories.map(c => {
|
||||
const CatIcon = getCategoryIcon(c.icon)
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ArrowRight, Bookmark, Loader2, Plus, X } from 'lucide-react'
|
||||
import { ArrowRight, Bookmark, Loader2, Plus } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import MIconBtn from '../../../components/MIconBtn'
|
||||
import { TileHeader } from '../sheets/MTripSheetUi'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
import { useSaveTripPlacesToList } from '../../../../components/Collections/useSaveTripPlacesToList'
|
||||
|
||||
@@ -25,14 +25,14 @@ export default function MPlacesSaveToCollectionSheet({ open, tripId, placeIds, o
|
||||
})
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="card" ariaLabel={t('collections.saveNToList', { count: placeIds.length })}>
|
||||
<div className="flex flex-none items-center border-b border-[color:var(--m-rowbr)] px-[18px] pb-[11px] pt-4">
|
||||
<div className="min-w-0 flex-1 text-[1.03125rem] font-bold text-m-ink">
|
||||
{t('collections.saveNToList', { count: placeIds.length })}
|
||||
</div>
|
||||
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.close')}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('collections.saveNToList', { count: placeIds.length })}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<Bookmark size={19} strokeWidth={1.8} />}
|
||||
title={t('collections.saveNToList', { count: placeIds.length })}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[14px] pb-[14px] pt-1">
|
||||
{lists.length > 5 && (
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { ReactNode } from 'react'
|
||||
import { useSettingsStore } from '../../../../store/settingsStore'
|
||||
import MRouteAppsSheet, { openRouteTarget } from './MRouteAppsSheet'
|
||||
import MDayPager from '../../../components/MDayPager'
|
||||
import { useDayPreviewStore } from '../../../dayPreviewStore'
|
||||
|
||||
/**
|
||||
* Plan-tab timeline of the mobile trip screen: the UP-NEXT card in go mode,
|
||||
@@ -60,6 +61,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
const isTouch = useIsTouch()
|
||||
useTouchDragBridge(editing && isTouch)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const setDayPreview = useDayPreviewStore(s => s.setDayId)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0">
|
||||
@@ -72,6 +74,7 @@ export default function MPlanTimeline({ planner, shell }: MPlanTimelineProps) {
|
||||
// skipFit, exactly like the chip tap in plan view: the map underneath stays
|
||||
// mounted, and re-framing it here would move it somewhere nobody asked for.
|
||||
onSelectDay={dayId => planner.handleSelectDay(dayId, true)}
|
||||
onPreview={setDayPreview}
|
||||
locked={dragging || legMenu.menu != null}
|
||||
describeDay={(i, n) => t('mobileTrip.dayAnnounce', { current: i + 1, total: n })}
|
||||
renderDay={(dayId, active) => (
|
||||
@@ -121,14 +124,17 @@ function PlanDayPage({ planner, shell, dayId: pageDayId, active, editing, legMen
|
||||
}
|
||||
const day = tl.day
|
||||
const dayId = day?.id
|
||||
// A stay chip opens the stay, the same way the stay card in the day sheet
|
||||
// does: the editor for members who may edit days, otherwise the hotel's
|
||||
// place. A stay with neither still leads to the day sheet, so the chip
|
||||
// never goes dead (#2210).
|
||||
// A stay chip shows the stay before anything can be changed, as on the
|
||||
// desktop: its booking's overview (dates, nights, status, Edit) where one is
|
||||
// booked, else the hotel's place. Only a stay with neither opens the editor
|
||||
// for members who may edit days, or the day sheet, so the chip never goes
|
||||
// dead (#2210).
|
||||
const openStay = (chip: HotelChip) => {
|
||||
if (!day) return
|
||||
if (canEdit) shell.openSheet('accommodation', { dayId: day.id, accId: chip.accId, from: 'timeline' })
|
||||
const booking = planner.reservations.find(r => r.accommodation_id != null && String(r.accommodation_id) === String(chip.accId))
|
||||
if (booking) shell.openSheet('transport', { reservationId: booking.id })
|
||||
else if (chip.placeId != null) planner.handlePlaceClick(chip.placeId)
|
||||
else if (canEdit) shell.openSheet('accommodation', { dayId: day.id, accId: chip.accId, from: 'timeline' })
|
||||
else shell.openSheet('day', { dayId: day.id })
|
||||
}
|
||||
// The icon alone tells check-out from check-in; the accessible name says it.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ChevronRight, MapPinned } from 'lucide-react';
|
||||
import type { DayRouteTarget } from '../../../../components/Planner/dayRouteApps';
|
||||
import type { TranslationFn } from '../../../../types';
|
||||
import MSheet from '../../../components/MSheet';
|
||||
import { TileHeader } from '../sheets/MTripSheetUi'
|
||||
|
||||
/** Opens a target: a web link in a new tab (the app takes it over), geo: in place. */
|
||||
export function openRouteTarget(target: DayRouteTarget): void {
|
||||
@@ -27,10 +28,14 @@ export default function MRouteAppsSheet({
|
||||
}) {
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="bottom" ariaLabel={t('mobileTrip.openRouteIn')}>
|
||||
<div className="px-4 pb-4 pt-6">
|
||||
<div className="mb-3 flex items-center gap-2 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold text-m-ink">
|
||||
<MapPinned size={16} strokeWidth={2.2} className="text-m-muted" aria-hidden="true" />
|
||||
{t('mobileTrip.openRouteIn')}
|
||||
<div className="px-[18px] pb-4 pt-4">
|
||||
<div className="mb-3">
|
||||
<TileHeader
|
||||
icon={<MapPinned size={19} strokeWidth={1.8} />}
|
||||
title={t('mobileTrip.openRouteIn')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{targets.map((target) => (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildPlanRows } from './planTimelineModel'
|
||||
import { buildPlanRows, hotelChipsForDay } from './planTimelineModel'
|
||||
import type { MergedItem } from '../../../../utils/dayMerge'
|
||||
import type { Place, Reservation, RouteSegment } from '../../../../types'
|
||||
import type { Accommodation, Day, Place, Reservation, RouteSegment } from '../../../../types'
|
||||
|
||||
// FE-MOBILE-PLANROWS-001 to FE-MOBILE-PLANROWS-003
|
||||
|
||||
@@ -84,3 +84,12 @@ describe('buildPlanRows', () => {
|
||||
expect(rows.some(row => row.kind === 'place' && row.invalidTour)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hotelChipsForDay', () => {
|
||||
it('shows a stay with two linked bookings once, though the list repeats it', () => {
|
||||
const days = [{ id: 1, day_number: 1 }, { id: 2, day_number: 2 }] as unknown as Day[]
|
||||
const stay = { id: 7, start_day_id: 1, end_day_id: 2, place_name: 'Liège', check_in: '15:00', place_id: null } as unknown as Accommodation
|
||||
const chips = hotelChipsForDay(days[0], days, [stay, { ...stay }])
|
||||
expect(chips.map(c => c.key)).toEqual(['in-7'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -199,7 +199,12 @@ const accommodationName = (a: Accommodation): string => a.place_name || a.reserv
|
||||
export function hotelChipsForDay(day: Day, days: Day[], accommodations: Accommodation[]): HotelChip[] {
|
||||
const inRange = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
|
||||
const chips: HotelChip[] = []
|
||||
// The list repeats a stay once per linked booking (the server's join keeps
|
||||
// one row per booking); a stay with two bookings is still one stay.
|
||||
const seen = new Set<number>()
|
||||
for (const a of inRange) {
|
||||
if (seen.has(a.id)) continue
|
||||
seen.add(a.id)
|
||||
const stay = { name: accommodationName(a), accId: a.id, placeId: a.place_id ?? null }
|
||||
if (a.end_day_id === day.id) {
|
||||
chips.push({ ...stay, key: `out-${a.id}`, variant: 'checkout', time: a.check_out || null })
|
||||
|
||||
@@ -24,6 +24,7 @@ import { bookendBooking } from '../../../../components/Roadtrip/nightBookend'
|
||||
import { getDayOrder } from '../../../../utils/dayOrder'
|
||||
import { undatedRides } from '@trek/shared/roadtrip'
|
||||
import type { Reservation, TranslationFn } from '../../../../types'
|
||||
import { useDayPreviewStore } from '../../../dayPreviewStore'
|
||||
|
||||
/**
|
||||
* The road trip tab: one day of the drive, as a chain or on the map.
|
||||
@@ -39,6 +40,7 @@ import type { Reservation, TranslationFn } from '../../../../types'
|
||||
* scroll, the day tints, the label format and the second tap that opens the day sheet.
|
||||
*/
|
||||
export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
const setDayPreview = useDayPreviewStore(s => s.setDayId)
|
||||
const { t, days } = planner
|
||||
const rt = useMRoadtrip(planner)
|
||||
const corridor = useMRtCorridor(planner, shell)
|
||||
@@ -107,6 +109,7 @@ export default function MRoadtripTab({ planner, shell }: MTripTabPanelProps) {
|
||||
// skipFit: the map underneath stays mounted, and the stage's own focus points
|
||||
// frame it as soon as the selection moves.
|
||||
onSelectDay={dayId => planner.handleSelectDay(dayId, true)}
|
||||
onPreview={setDayPreview}
|
||||
locked={!!shell.sheet}
|
||||
describeDay={(i, n) => t('mobileTrip.dayAnnounce', { current: i + 1, total: n })}
|
||||
renderDay={dayId => (
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function MRtCorridorSheet({ planner, shell }: MTripSheetsProps) {
|
||||
open={open}
|
||||
onClose={shell.closeSheet}
|
||||
variant="bottom"
|
||||
material="opaque"
|
||||
material="glass"
|
||||
ariaLabel={t('roadtrip.poi.title')}
|
||||
>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function MRtDraftSheet({ planner }: MRtDraftSheetProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<MSheet open={stopDraft != null} onClose={discard} variant="bottom" material="opaque" ariaLabel={draft.poi.name}>
|
||||
<MSheet open={stopDraft != null} onClose={discard} variant="bottom" material="glass" ariaLabel={draft.poi.name}>
|
||||
<FormSheetHeader
|
||||
icon={kind?.Icon ?? ParkingSquare}
|
||||
title={draft.poi.name}
|
||||
|
||||
@@ -172,7 +172,7 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
|
||||
]
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={shell.closeSheet} variant="bottom" material="opaque" ariaLabel={t('mobileTrip.rtInfoTitle')}>
|
||||
<MSheet open={open} onClose={shell.closeSheet} variant="bottom" material="glass" ariaLabel={t('mobileTrip.rtInfoTitle')}>
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<VehicleIcon size={19} strokeWidth={1.8} />}
|
||||
@@ -182,7 +182,7 @@ export default function MRtInfoSheet({ planner, shell }: MTripSheetsProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{/* ── Vehicle: how far it goes, drawn as the tank it leaves the factory with ── */}
|
||||
<div className="mt-[14px]">
|
||||
<InfoCard label={t('roadtrip.limit.sectionVehicle')}>
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function MRtKindSheet({ planner, shell }: MTripSheetsProps) {
|
||||
open={open && !!stop}
|
||||
onClose={shell.closeSheet}
|
||||
variant="bottom"
|
||||
material="opaque"
|
||||
material="glass"
|
||||
ariaLabel={t('roadtrip.stop.kind')}
|
||||
>
|
||||
{stop && (
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function MRtStaySheet({ planner, shell }: MTripSheetsProps) {
|
||||
open={open && !!stop}
|
||||
onClose={shell.closeSheet}
|
||||
variant="bottom"
|
||||
material="opaque"
|
||||
material="glass"
|
||||
// Named for what it holds: a stop left at a set time has no stay to add.
|
||||
ariaLabel={leave.until ? t('roadtrip.stop.stay') : t('roadtrip.stay.add')}
|
||||
>
|
||||
|
||||
@@ -290,7 +290,7 @@ export default function MRtStopSheet({ planner, shell }: MTripSheetsProps) {
|
||||
const tiles = (navTargets.length > 0 ? 1 : 0) + 1
|
||||
|
||||
return (
|
||||
<MSheet open={open && !!live} onClose={shell.closeSheet} variant="bottom" material="opaque" ariaLabel={stop.name}>
|
||||
<MSheet open={open && !!live} onClose={shell.closeSheet} variant="bottom" material="glass" ariaLabel={stop.name}>
|
||||
{/* ── Header: the disc the chain draws, the name, the way to the editor and the way out ── */}
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
{/* Centred, not top-aligned: one line of the name is shorter than the 34px disc, so
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function MAccommodationSheet({ planner, shell }: MTripSheetsProps
|
||||
{/* Header — mirrors the place detail sheet's chrome */}
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex h-[42px] w-[42px] flex-none items-center justify-center rounded-[14px] bg-[color:var(--m-ic)] text-m-muted">
|
||||
<span className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)] text-m-ink">
|
||||
<Hotel size={18} strokeWidth={1.9} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 text-[1rem] font-bold">
|
||||
@@ -182,7 +182,7 @@ export default function MAccommodationSheet({ planner, shell }: MTripSheetsProps
|
||||
value={form.confirmation}
|
||||
onChange={e => setForm(f => ({ ...f, confirmation: e.target.value }))}
|
||||
placeholder="ABC-12345"
|
||||
className="box-border w-full rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[10px] text-[0.8125rem] font-medium text-m-ink outline-none placeholder:text-m-faint"
|
||||
className="box-border w-full rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-[10px] text-[0.8125rem] font-medium text-m-ink outline-none placeholder:text-m-faint"
|
||||
/>
|
||||
|
||||
{/* Category filter */}
|
||||
|
||||
@@ -35,15 +35,15 @@ describe('MBookingCodeStatus', () => {
|
||||
fireEvent.change(input, { target: { value: 'XYZ9' } });
|
||||
expect(onCodeChange).toHaveBeenCalledWith('XYZ9');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'reservations.confirmed' }));
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'reservations.confirmed' }));
|
||||
expect(onStatusChange).toHaveBeenCalledWith('confirmed');
|
||||
});
|
||||
|
||||
it('FE-COMP-MBOOKINGFIELDS-002: marks the current status as the active segment', () => {
|
||||
render(<MBookingCodeStatus t={t} code="" onCodeChange={vi.fn()} status="confirmed" onStatusChange={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'reservations.confirmed' }).className).toContain('bg-m-act');
|
||||
expect(screen.getByRole('button', { name: 'reservations.pending' }).className).toContain('text-m-muted');
|
||||
expect(screen.getByRole('radio', { name: 'reservations.confirmed' }).className).toContain('bg-m-act');
|
||||
expect(screen.getByRole('radio', { name: 'reservations.pending' }).className).toContain('text-m-muted');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -32,11 +32,15 @@ interface MBookingCodeStatusProps {
|
||||
onStatusChange: (status: BookingStatus) => void;
|
||||
}
|
||||
|
||||
/** Booking code and the pending / confirmed toggle, side by side. */
|
||||
/**
|
||||
* Booking code and the pending / confirmed toggle, one under the other: side
|
||||
* by side the two words did not fit half a phone's width once each carried its
|
||||
* status dot.
|
||||
*/
|
||||
export function MBookingCodeStatus({ t, code, onCodeChange, status, onStatusChange }: MBookingCodeStatusProps) {
|
||||
return (
|
||||
<div className="mt-3 flex gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<div className="min-w-0">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('reservations.confirmationCode')}</Eyebrow>
|
||||
<BookingCodeInput
|
||||
value={code}
|
||||
@@ -45,19 +49,31 @@ export function MBookingCodeStatus({ t, code, onCodeChange, status, onStatusChan
|
||||
className={FIELD_CLS}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0">
|
||||
<Eyebrow className="mb-[5px] uppercase">{t('reservations.status')}</Eyebrow>
|
||||
<div className="flex rounded-full bg-[color:var(--m-ic)] p-[3px]">
|
||||
{/* As tall as the code field beside it, the status colour in each option's dot. */}
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={t('reservations.status')}
|
||||
className="flex h-[42px] rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] p-[3px]"
|
||||
>
|
||||
{(['pending', 'confirmed'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={status === s}
|
||||
onClick={() => onStatusChange(s)}
|
||||
className={`flex-1 rounded-full py-[7px] text-[0.71875rem] font-semibold ${
|
||||
className={`flex min-w-0 flex-1 items-center justify-center gap-1.5 rounded-full px-2 text-[length:calc(12px*var(--fs-scale-body,1))] font-semibold transition-colors ${
|
||||
status === s ? 'bg-m-act text-m-actfg' : 'text-m-muted'
|
||||
}`}
|
||||
>
|
||||
{t(`reservations.${s}`)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="h-[7px] w-[7px] flex-none rounded-full"
|
||||
style={{ background: s === 'confirmed' ? 'var(--m-st-confirmed)' : 'var(--m-st-pending)' }}
|
||||
/>
|
||||
<span className="truncate">{t(`reservations.${s}`)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Bookmark, Calendar, ChevronDown, ChevronUp, Eye, Pencil, Trash2 } from 'lucide-react'
|
||||
import { Bookmark, Calendar, ChevronDown, ChevronUp, Eye, Pencil, Trash2, MapPin } from 'lucide-react'
|
||||
import MSheet from '../../../components/MSheet'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
import { useTranslation } from '../../../../i18n'
|
||||
@@ -9,6 +9,7 @@ import { placeToSaveTarget } from '../../../../components/Collections/saveTarget
|
||||
import { useTourDayAssignment } from '../../../../components/Tours/useTourDayAssignment'
|
||||
import { stageKeysOfTour } from '../../../../components/Tours/stages/stageSpread'
|
||||
import type { Place } from '../../../../types'
|
||||
import { TileHeader } from './MTripSheetUi'
|
||||
|
||||
interface BrowseActionsPayload {
|
||||
placeId?: number
|
||||
@@ -82,16 +83,17 @@ export default function MBrowseActionsSheet({ planner, shell }: MTripSheetsProps
|
||||
|
||||
return (
|
||||
<MSheet open={open && !!livePlace} onClose={shell.closeSheet} variant="card" material="glass" ariaLabel={place.name}>
|
||||
<div className="flex-none border-b border-[color:var(--m-rowbr)] px-[18px] pb-[11px] pt-4">
|
||||
<div className="truncate text-[1.03125rem] font-bold">{place.name}</div>
|
||||
{(place.address || place.description) && (
|
||||
<div className="mt-[2px] truncate font-geist text-[0.6875rem] text-m-muted">
|
||||
{place.address || place.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<MapPin size={19} strokeWidth={1.8} />}
|
||||
title={place.name}
|
||||
sub={place.address || place.description || undefined}
|
||||
onClose={shell.closeSheet}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[10px] pb-3 pt-[6px]">
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[10px] pb-3 pt-[10px]">
|
||||
<button type="button" onClick={viewDetails} className={rowCls}>
|
||||
<Eye size={16} strokeWidth={2} className="flex-none text-m-muted" />
|
||||
{t('mobileTrip.viewDetails')}
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
<MSheet
|
||||
open={open}
|
||||
onClose={requestClose}
|
||||
material="opaque"
|
||||
material="glass"
|
||||
ariaLabel={editing ? t('costs.editExpense') : t('costs.addExpense')}
|
||||
>
|
||||
<FormSheetHeader
|
||||
|
||||
@@ -270,7 +270,7 @@ export default function MDaySheet({ planner, shell }: MTripSheetsProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{/* ── Weather (16-day forecast, climate fallback) ── */}
|
||||
{day.date && lat != null && lng != null && (
|
||||
weatherLoading ? (
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function MDaysSheet({ planner, shell }: MTripSheetsProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[14px] pt-[14px]">
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[14px] pt-[14px]">
|
||||
<div className="flex flex-col gap-[6px]">
|
||||
{ordered.map((day, i) => (
|
||||
<div key={day.id} className={`flex items-center gap-[10px] rounded-[13px] px-[11px] py-[7px] ${INNER_CLS}`}>
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function MExportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-3">
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
<div className="flex flex-col gap-2">
|
||||
<ExportRow
|
||||
icon={FileDown}
|
||||
|
||||
@@ -233,7 +233,7 @@ export default function MPlaceEditSheet({ planner, onOpenExpense }: MPlaceEditSh
|
||||
<MSheet
|
||||
open={showPlaceForm}
|
||||
onClose={handleClose}
|
||||
material="opaque"
|
||||
material="glass"
|
||||
ariaLabel={sheetPlace ? t('places.editPlace') : t('places.addPlace')}
|
||||
discardGuard={showPlaceForm ? { form, files: pendingFiles.length } : undefined}
|
||||
>
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
|
||||
<MSheet
|
||||
open={showReservationModal}
|
||||
onClose={handleClose}
|
||||
material="opaque"
|
||||
material="glass"
|
||||
ariaLabel={res ? t('reservations.editTitle') : t('reservations.newTitle')}
|
||||
discardGuard={showReservationModal ? { form, files: pendingFiles.length, travelers: [...travelerIds] } : undefined}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { X } from 'lucide-react';
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { DetailShellProps } from '../../../../components/shared/DetailShell';
|
||||
import MIconBtn from '../../../components/MIconBtn';
|
||||
import MSheet from '../../../components/MSheet';
|
||||
|
||||
/**
|
||||
* Whether the tour sheet is open (false while it slides away with the tour it
|
||||
* showed last) and the tour's name, which names the dialog.
|
||||
*/
|
||||
export const TourSheetOpen = createContext<{ open: boolean; label: string }>({ open: true, label: '' });
|
||||
|
||||
/**
|
||||
* The phone's frame for the tour detail: the desktop dialog's head, sections
|
||||
* and actions in a sheet like every other one (TourDetailDialog renders into
|
||||
* the frame it is given). It slides in and out, drags closed, closes on the
|
||||
* back gesture and keeps the tour's whole content and every action.
|
||||
*/
|
||||
export default function MTourDetailShell({ header, children, footer, onClose, closeLabel, testId }: DetailShellProps) {
|
||||
const { open, label } = useContext(TourSheetOpen);
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={label || closeLabel}>
|
||||
<div className="flex flex-none items-start gap-3 px-[18px] pt-4">
|
||||
{header}
|
||||
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={closeLabel}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
</div>
|
||||
<div
|
||||
data-testid={testId}
|
||||
className="m-scroll-fade mt-2 flex min-h-0 flex-1 flex-col gap-3.5 overflow-y-auto px-[18px] pb-[18px] pt-[14px]"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{footer && (
|
||||
<div className="flex flex-none flex-wrap items-center gap-2 border-t border-[color:var(--m-rowbr)] px-[18px] pb-4 pt-3">
|
||||
{footer}
|
||||
</div>
|
||||
)}
|
||||
</MSheet>
|
||||
);
|
||||
}
|
||||
@@ -163,7 +163,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
|
||||
<MSheet
|
||||
open={showTransportModal}
|
||||
onClose={handleClose}
|
||||
material="opaque"
|
||||
material="glass"
|
||||
ariaLabel={headerTitle}
|
||||
discardGuard={showTransportModal ? { form, waypoints, trainWaypoints, carStops, files: pendingFiles.length, travelers: [...travelerIds] } : undefined}
|
||||
>
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { Reservation } from '../../../../types'
|
||||
import { useSearchParams } from 'react-router'
|
||||
import { isTransportReservationType } from '@trek/shared'
|
||||
import { useBookingLink } from '../../../../hooks/useBookingLink'
|
||||
import { bookingFacts, stayStats } from '../../../../components/Planner/bookings/bookingFacts'
|
||||
import { Eyebrow, INNER_CLS, StatBox, TileHeader, displayTime } from './MTripSheetUi'
|
||||
|
||||
interface TransportSheetPayload {
|
||||
@@ -173,10 +174,27 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
revealOnMap(res)
|
||||
}
|
||||
|
||||
// A stay shows its check-in, check-out and nights, as the desktop dialog does.
|
||||
const hotelStats = res.type === 'hotel'
|
||||
? stayStats(
|
||||
bookingFacts(res, { t, locale, timeFormat, days: planner.days, assignmentLookup: {}, hasLinkedCost: false }),
|
||||
meta,
|
||||
t,
|
||||
locale,
|
||||
)
|
||||
: []
|
||||
|
||||
// The editor that fits the booking: transports have their own, everything
|
||||
// else (a stay, a table, a ticket) the booking editor, as in the Bookings tab.
|
||||
const editTransport = () => {
|
||||
planner.setEditingTransport(res)
|
||||
planner.setTransportModalDayId(res.day_id ?? null)
|
||||
planner.setShowTransportModal(true)
|
||||
if (isTransportReservationType(res.type)) {
|
||||
planner.setEditingTransport(res)
|
||||
planner.setTransportModalDayId(res.day_id ?? null)
|
||||
planner.setShowTransportModal(true)
|
||||
} else {
|
||||
planner.setEditingReservation(res)
|
||||
planner.setShowReservationModal(true)
|
||||
}
|
||||
shell.closeSheet()
|
||||
}
|
||||
|
||||
@@ -197,9 +215,16 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{/* ── A stay: check-in, check-out, nights ── */}
|
||||
{hotelStats.length > 0 && (
|
||||
<div className="mt-[14px] flex gap-[6px]">
|
||||
{hotelStats.map(s => <StatBox key={s.label} value={s.value} label={s.label} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Times / seat stats ── */}
|
||||
{(depTime || arrTime || seat || platform) && (
|
||||
{hotelStats.length === 0 && (depTime || arrTime || seat || platform) && (
|
||||
<div className="mt-[14px] flex gap-[6px]">
|
||||
{depTime && (
|
||||
<StatBox value={displayTime(depTime, locale, timeFormat)} label={depLabel} />
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
Check,
|
||||
Copy,
|
||||
Crown,
|
||||
Link2,
|
||||
LogOut,
|
||||
@@ -8,7 +7,6 @@ import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Share as ShareIcon,
|
||||
Trash2,
|
||||
UserMinus,
|
||||
UserPlus,
|
||||
@@ -27,11 +25,19 @@ import {
|
||||
type ListedMember,
|
||||
} from '../../../../components/Trips/useTripShare';
|
||||
import { useTranslation } from '../../../../i18n';
|
||||
import { canShareLink, shareLink } from '../../../../native/shareLink';
|
||||
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;
|
||||
@@ -46,30 +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';
|
||||
const ROUND =
|
||||
'flex h-[34px] w-[34px] 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, hint, children }: { title: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className={`rounded-[18px] p-3 ${INNER_CLS}`}>
|
||||
<Eyebrow className="uppercase">{title}</Eyebrow>
|
||||
{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,
|
||||
@@ -96,48 +78,6 @@ function PersonRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** A link with its copy button, read-only, the way the phone shows one to hand on. */
|
||||
function LinkField({
|
||||
url,
|
||||
copied,
|
||||
onCopy,
|
||||
t,
|
||||
}: {
|
||||
url: string;
|
||||
copied: boolean;
|
||||
onCopy: () => void;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] py-1 pe-1 ps-[14px]">
|
||||
<input
|
||||
readOnly
|
||||
value={url}
|
||||
aria-label={url}
|
||||
className="min-w-0 flex-1 border-none bg-transparent font-geist text-[length:calc(11px*var(--fs-scale-caption,1))] text-m-muted outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopy}
|
||||
className={`${PRIMARY} h-[32px] px-3 text-[length:calc(11.5px*var(--fs-scale-caption,1))]`}
|
||||
>
|
||||
{copied ? <Check size={13} strokeWidth={2.4} /> : <Copy size={13} strokeWidth={2.2} />}
|
||||
{copied ? t('common.copied') : t('common.copy')}
|
||||
</button>
|
||||
{canShareLink() && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.share')}
|
||||
onClick={() => void shareLink(url)}
|
||||
className={`${PRIMARY} h-[32px] w-[32px] justify-center px-0`}
|
||||
>
|
||||
<ShareIcon size={13} strokeWidth={2.2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareLinkSection({ tripId }: { tripId: number }) {
|
||||
const { t } = useTranslation();
|
||||
const link = useShareLink(tripId);
|
||||
@@ -166,7 +106,14 @@ function ShareLinkSection({ tripId }: { tripId: number }) {
|
||||
</div>
|
||||
{link.url ? (
|
||||
<>
|
||||
<LinkField url={link.url} copied={link.copied} onCopy={() => void link.copy()} t={t} />
|
||||
<MLinkField
|
||||
url={link.url}
|
||||
copied={link.copied}
|
||||
onCopy={() => void link.copy()}
|
||||
copyLabel={t('common.copy')}
|
||||
copiedLabel={t('common.copied')}
|
||||
shareLabel={t('common.share')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void link.remove()}
|
||||
@@ -192,7 +139,14 @@ function InviteLinkSection({ tripId }: { tripId: number }) {
|
||||
<Section title={t('trip.invite.linkTitle')} hint={t('trip.invite.linkHint')}>
|
||||
{invite.url ? (
|
||||
<>
|
||||
<LinkField url={invite.url} copied={invite.copied} onCopy={() => void invite.copy()} t={t} />
|
||||
<MLinkField
|
||||
url={invite.url}
|
||||
copied={invite.copied}
|
||||
onCopy={() => void invite.copy()}
|
||||
copyLabel={t('common.copy')}
|
||||
copiedLabel={t('common.copied')}
|
||||
shareLabel={t('common.share')}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -296,7 +250,7 @@ function MemberBadges({ member, m }: { member: ListedMember; m: Members }) {
|
||||
function AccessSection({ m, ask }: { m: Members; ask: Ask }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Section title={`${t('members.access')} ${m.realMembers.length}`}>
|
||||
<Section title={t('members.access')} count={m.realMembers.length}>
|
||||
{m.canManageMembers && (
|
||||
<div className="flex gap-2">
|
||||
<CustomSelect
|
||||
@@ -316,7 +270,7 @@ function AccessSection({ m, ask }: { m: Members; ask: Ask }) {
|
||||
onClick={() => void m.add()}
|
||||
disabled={m.adding || !m.selectedUserId}
|
||||
aria-label={t('members.invite')}
|
||||
className={PRIMARY}
|
||||
className={ROUND_PRIMARY}
|
||||
>
|
||||
<UserPlus size={14} strokeWidth={2.2} />
|
||||
</button>
|
||||
@@ -402,7 +356,8 @@ function GuestsSection({ m, ask }: { m: Members; ask: Ask }) {
|
||||
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')}
|
||||
title={t('members.guests')}
|
||||
count={m.guests.length > 0 ? m.guests.length : undefined}
|
||||
hint={t('members.guestsHint')}
|
||||
>
|
||||
{m.guests.map((guest) => (
|
||||
@@ -425,7 +380,7 @@ function GuestsSection({ m, ask }: { m: Members; ask: Ask }) {
|
||||
onClick={() => void m.addGuest()}
|
||||
disabled={m.addingGuest || !m.newGuestName.trim()}
|
||||
aria-label={t('members.addGuest')}
|
||||
className={PRIMARY}
|
||||
className={ROUND_PRIMARY}
|
||||
>
|
||||
<Plus size={15} strokeWidth={2.2} />
|
||||
</button>
|
||||
@@ -474,7 +429,7 @@ 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">
|
||||
<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]">
|
||||
<AccessSection m={m} ask={setQuestion} />
|
||||
<GuestsSection m={m} ask={setQuestion} />
|
||||
{m.canManageShare && <ShareLinkSection tripId={tripId} />}
|
||||
|
||||
@@ -17,8 +17,67 @@ 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'
|
||||
/** A destructive action with a label: the quiet button's shape, the danger colour on its text. */
|
||||
export const SHEET_DANGER =
|
||||
'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-[color:var(--m-st-danger)] 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,
|
||||
}: {
|
||||
/** Left out where each field below carries its own label. */
|
||||
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}`}>
|
||||
{title && (
|
||||
<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={`${title || hint ? 'mt-2.5 ' : ''}flex flex-col gap-2`}>{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
interface TileHeaderProps {
|
||||
icon: ReactNode
|
||||
/** Left out for a plain picker; every other sheet shows its icon tile. */
|
||||
icon?: ReactNode
|
||||
title: ReactNode
|
||||
sub?: ReactNode
|
||||
/** Let a sentence-length subtitle wrap instead of truncating it to one line. */
|
||||
@@ -39,9 +98,11 @@ interface TileHeaderProps {
|
||||
export function TileHeader({ icon, title, sub, subWrap = false, onClose, closeLabel }: TileHeaderProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)]">
|
||||
{icon}
|
||||
</div>
|
||||
{icon && (
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-[13px] bg-[color:var(--m-ic)]">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-[6px] text-[1.0625rem] font-bold leading-tight">{title}</div>
|
||||
{sub && (
|
||||
|
||||
@@ -65,7 +65,7 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('MSelectedTourDetail', () => {
|
||||
it('is the single mobile modal owner for Escape, focus containment, scroll lock and focus return', () => {
|
||||
it('shows the tour in a mobile sheet that owns Escape, focus containment, scroll lock and focus return', () => {
|
||||
const opener = document.createElement('button')
|
||||
opener.textContent = 'Open mobile Tour'
|
||||
document.body.appendChild(opener)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import TransitJourneyModal from '../../../../components/Planner/TransitJourneyModal'
|
||||
import BookingImportModal from '../../../../components/Planner/BookingImportModal'
|
||||
import AirTrailImportModal from '../../../../components/Planner/AirTrailImportModal'
|
||||
@@ -34,60 +32,39 @@ import MRtInfoSheet from '../roadtrip/MRtInfoSheet'
|
||||
import MRtCorridorSheet from '../roadtrip/MRtCorridorSheet'
|
||||
import MRtDraftSheet from '../roadtrip/MRtDraftSheet'
|
||||
import type { MTripSheetsProps } from '../MTripShell'
|
||||
import { lockBodyScroll } from '../../../../utils/bodyScrollLock'
|
||||
import { focusDialog, trapTab } from '../../../../components/shared/dialogFocus'
|
||||
import MTourDetailShell, { TourSheetOpen } from './MTourDetailShell'
|
||||
import { useLastOpen } from '../../../../components/shared/usePresence'
|
||||
|
||||
/** The one global mobile tour-detail owner, independent of which surface selected it. */
|
||||
/**
|
||||
* The one global mobile tour-detail owner, independent of which surface
|
||||
* selected it. The tour shows in a sheet like every other detail
|
||||
* (MTourDetailShell); it keeps the tour it showed last while it slides away.
|
||||
*/
|
||||
export function MSelectedTourDetail({ planner }: Pick<MTripSheetsProps, 'planner'>) {
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const selectedTourId = planner.selectedTour?.place_id ?? null
|
||||
const selectedPlaceId = planner.selectedPlace?.id ?? null
|
||||
useEffect(() => {
|
||||
if (selectedTourId == null || selectedPlaceId == null) return
|
||||
const previous = document.activeElement as HTMLElement | null
|
||||
const release = lockBodyScroll()
|
||||
const panel = panelRef.current
|
||||
if (panel) focusDialog(panel)
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Escape' || event.defaultPrevented) return
|
||||
const openPanels = document.querySelectorAll('[data-m-sheet="open"]')
|
||||
if (openPanels.length && openPanels[openPanels.length - 1] !== panel) return
|
||||
event.preventDefault()
|
||||
planner.setSelectedPlaceId(null)
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
release()
|
||||
if (previous?.isConnected) previous.focus()
|
||||
}
|
||||
}, [planner.setSelectedPlaceId, selectedPlaceId, selectedTourId])
|
||||
const open = !!(planner.selectedTour && planner.selectedPlace)
|
||||
const tour = useLastOpen(planner.selectedTour, open)
|
||||
const place = useLastOpen(planner.selectedPlace, open)
|
||||
if (!tour || !place) return null
|
||||
|
||||
if (!planner.selectedTour || !planner.selectedPlace) return null
|
||||
|
||||
return createPortal(
|
||||
<div className="bg-[rgba(0,0,0,0.3)]" style={{ position: 'fixed', inset: 0, zIndex: 9999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center', paddingBottom: 'var(--bottom-nav-h)' }} role="presentation" onClick={() => planner.setSelectedPlaceId(null)}>
|
||||
<div ref={panelRef} style={{ width: '100%', maxHeight: '85vh' }} role="dialog" aria-modal="true" aria-label={planner.selectedPlace.name}
|
||||
data-m-sheet="open"
|
||||
tabIndex={-1} onClick={event => event.stopPropagation()} onKeyDown={event => trapTab(event, panelRef.current!)}>
|
||||
<TourDetailDialog
|
||||
tour={planner.selectedTour}
|
||||
place={planner.selectedPlace}
|
||||
days={planner.days}
|
||||
selectedDayId={planner.selectedDayId}
|
||||
selectedAssignmentId={planner.selectedAssignmentId}
|
||||
assignments={planner.assignments}
|
||||
files={planner.files}
|
||||
readOnly
|
||||
canEdit={false}
|
||||
canAssign={planner.can('day_edit', planner.trip)}
|
||||
onClose={() => planner.setSelectedPlaceId(null)}
|
||||
onAssignToDay={planner.handleAssignToDay}
|
||||
onRemoveAssignment={planner.handleRemoveAssignment}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
return (
|
||||
<TourSheetOpen.Provider value={{ open, label: place.name }}>
|
||||
<TourDetailDialog
|
||||
shell={MTourDetailShell}
|
||||
tour={tour}
|
||||
place={place}
|
||||
days={planner.days}
|
||||
selectedDayId={planner.selectedDayId}
|
||||
selectedAssignmentId={planner.selectedAssignmentId}
|
||||
assignments={planner.assignments}
|
||||
files={planner.files}
|
||||
readOnly
|
||||
canEdit={false}
|
||||
canAssign={planner.can('day_edit', planner.trip)}
|
||||
onClose={() => planner.setSelectedPlaceId(null)}
|
||||
onAssignToDay={planner.handleAssignToDay}
|
||||
onRemoveAssignment={planner.handleRemoveAssignment}
|
||||
/>
|
||||
</TourSheetOpen.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,13 +10,14 @@ import MIconBtn from '../../../components/MIconBtn'
|
||||
*/
|
||||
|
||||
export const FIELD_CLS =
|
||||
'w-full min-w-0 box-border rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[10px] font-[inherit] text-[0.84375rem] font-medium text-m-ink outline-none placeholder:text-m-faint'
|
||||
'w-full min-w-0 box-border rounded-full border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-[10px] font-[inherit] text-[0.84375rem] font-medium text-m-ink outline-none placeholder:text-m-faint'
|
||||
|
||||
export const FIELD_AREA_CLS =
|
||||
'w-full box-border resize-none rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[10px] font-geist text-[0.78125rem] leading-[1.5] text-m-ink outline-none placeholder:text-m-faint'
|
||||
'w-full box-border resize-none rounded-[18px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-[14px] py-[10px] font-geist text-[0.78125rem] leading-[1.5] text-m-ink outline-none placeholder:text-m-faint'
|
||||
|
||||
// One Eyebrow for all trip sheets — defined with the inspection-sheet chrome.
|
||||
export { Eyebrow } from './MTripSheetUi'
|
||||
import { SHEET_PRIMARY, SHEET_SECONDARY } from './MTripSheetUi'
|
||||
|
||||
interface FormSheetHeaderProps {
|
||||
/** 40px rounded icon tile left of the title (place edit / import sheets). */
|
||||
@@ -52,7 +53,7 @@ export function FormSheetHeader({ icon: Icon, title, subtitle, leading, onClose,
|
||||
}
|
||||
|
||||
interface FormSheetFooterProps {
|
||||
/** Renders the 38px delete circle; two-tap confirm is the caller's business. */
|
||||
/** Renders the 42px delete circle; two-tap confirm is the caller's business. */
|
||||
onDelete?: () => void
|
||||
deleteLabel?: string
|
||||
/** Delete circle switches to the danger surface while armed. */
|
||||
@@ -76,7 +77,7 @@ export function FormSheetFooter({
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
aria-label={deleteLabel}
|
||||
className={`flex h-[38px] w-[38px] flex-none items-center justify-center rounded-full border ${
|
||||
className={`flex h-[42px] w-[42px] flex-none items-center justify-center rounded-full border ${
|
||||
deleteArmed
|
||||
? 'border-transparent bg-[color:var(--m-st-danger)] text-white'
|
||||
: 'border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted'
|
||||
@@ -88,7 +89,7 @@ export function FormSheetFooter({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
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 text-m-ink"
|
||||
className={`${SHEET_SECONDARY} ms-auto`}
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
@@ -96,7 +97,7 @@ export function FormSheetFooter({
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
disabled={submitDisabled}
|
||||
className="rounded-full bg-m-act px-[18px] py-[9px] text-[0.78125rem] font-semibold text-m-actfg disabled:opacity-40"
|
||||
className={SHEET_PRIMARY}
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
|
||||
@@ -136,7 +136,7 @@ export default function MDocSyncSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{sync.loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 size={20} className="animate-spin text-m-faint" />
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function MFileLinkSheet({ planner, file, onClose }: MFileLinkShee
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{isEmpty && (
|
||||
<div className="py-8 text-center font-geist text-[0.78125rem] text-m-faint">{t('files.linkEmpty')}</div>
|
||||
)}
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function MFileMenuSheet({ planner, file, onClose, onOpenLinks }:
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
|
||||
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
|
||||
{canEdit && (
|
||||
<div className="mt-3">
|
||||
<Eyebrow className="mb-[6px]">{t('files.noteLabel')}</Eyebrow>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef } from 'react'
|
||||
import { Building2, Check, ChevronDown, ChevronLeft, ChevronRight, Eye, EyeOff, Minus, PenLine, Pencil, Plus, Settings2, Share2, ShieldCheck, Trash2, Unlink, UserPlus } from 'lucide-react'
|
||||
import { Building2, Check, ChevronDown, ChevronLeft, ChevronRight, Eye, EyeOff, Minus, PenLine, Pencil, Plus, Settings2, Share2, ShieldCheck, Trash2, Unlink, UserPlus, Users, Palette } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
@@ -14,6 +14,7 @@ import MVacaySettingsSheet from './MVacaySettingsSheet'
|
||||
import MVacayShareSheet from './MVacayShareSheet'
|
||||
import { FALLBACK_PERSON_COLOR } from './vacayDayModel'
|
||||
import { fmtDays } from '../../../components/Vacay/vacayFormat'
|
||||
import { TileHeader } from '../trip/sheets/MTripSheetUi'
|
||||
|
||||
/** The company-holiday tint the calendar cells use (vacayDayModel). */
|
||||
const COMPANY_COLOR = 'var(--m-vacay-company)'
|
||||
@@ -364,8 +365,15 @@ export default function MVacay() {
|
||||
<MVacaySettingsSheet open={v.sheet === 'settings'} onClose={() => v.setSheet(null)} />
|
||||
<MVacayShareSheet open={v.sheet === 'share'} onClose={() => v.setSheet(null)} />
|
||||
<MSheet open={v.sheet === 'people'} onClose={() => v.setSheet(null)} variant="bottom" ariaLabel={t('mobileVacay.people')}>
|
||||
<div className="px-4 pb-4 pt-6">
|
||||
<div className="mb-3 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold">{t('mobileVacay.people')}</div>
|
||||
<div className="px-[18px] pb-4 pt-4">
|
||||
<div className="mb-3">
|
||||
<TileHeader
|
||||
icon={<Users size={19} strokeWidth={1.8} />}
|
||||
title={t('mobileVacay.people')}
|
||||
onClose={() => v.setSheet(null)}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{v.users.map(u => {
|
||||
const color = u.color || FALLBACK_PERSON_COLOR
|
||||
@@ -411,8 +419,15 @@ export default function MVacay() {
|
||||
</div>
|
||||
</MSheet>
|
||||
<MSheet open={v.sheet === 'legend'} onClose={() => v.setSheet(null)} variant="bottom" ariaLabel={t('mobileVacay.legend')}>
|
||||
<div className="px-4 pb-4 pt-6">
|
||||
<div className="mb-3 px-1 text-[length:calc(13px*var(--fs-scale-body,1))] font-bold">{t('mobileVacay.legend')}</div>
|
||||
<div className="px-[18px] pb-4 pt-4">
|
||||
<div className="mb-3">
|
||||
<TileHeader
|
||||
icon={<Palette size={19} strokeWidth={1.8} />}
|
||||
title={t('mobileVacay.legend')}
|
||||
onClose={() => v.setSheet(null)}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{legendItems.map(item => (
|
||||
<div key={item.key} className="flex items-center gap-3 px-1 py-2 text-[length:calc(14px*var(--fs-scale-body,1))] font-semibold">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ChevronDown, Clock, Loader2, X } from 'lucide-react'
|
||||
import { ChevronDown, Clock, Loader2, UserPlus } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MUserPickerList from '../../components/MUserPickerList'
|
||||
import { useVacayStore } from '../../../store/vacayStore'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { type VacayUser } from '../../../types'
|
||||
import { useVacayUserPicker } from '../../../components/Vacay/useVacayUserPicker'
|
||||
import { TileHeader } from '../trip/sheets/MTripSheetUi'
|
||||
|
||||
interface MVacayInviteSheetProps {
|
||||
open: boolean
|
||||
@@ -33,12 +33,12 @@ export default function MVacayInviteSheet({ open, onClose }: MVacayInviteSheetPr
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('vacay.inviteUser')}>
|
||||
<div className="px-[18px] pb-[18px] pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 text-[1.0625rem] font-bold">{t('vacay.inviteUser')}</div>
|
||||
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.close')}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
</div>
|
||||
<TileHeader
|
||||
icon={<UserPlus size={19} strokeWidth={1.8} />}
|
||||
title={t('vacay.inviteUser')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
<div className="mb-3 mt-2 font-geist text-[0.75rem] leading-normal text-m-muted">
|
||||
{t('vacay.inviteHint')}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ArrowRightLeft, Building2, Calendar, CalendarRange, CalendarX, ChevronDown, Globe, GraduationCap, Loader2, Plus, Trash2, Unlink, X } from 'lucide-react'
|
||||
import { ArrowRightLeft, Building2, Calendar, CalendarRange, CalendarX, ChevronDown, Globe, GraduationCap, Loader2, Plus, Trash2, Unlink, X, Settings2 } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MToggle from '../../components/MToggle'
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from '../../../components/Vacay/useVacaySettings'
|
||||
import { FALLBACK_PERSON_COLOR } from './vacayDayModel'
|
||||
import type { VacayHolidayCalendar } from '../../../types'
|
||||
import { INNER_CLS, TileHeader } from '../trip/sheets/MTripSheetUi'
|
||||
|
||||
const CALENDAR_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af']
|
||||
|
||||
@@ -28,6 +29,9 @@ interface MVacaySettingsSheetProps {
|
||||
* start, carry-over, company holidays, public-holiday calendars (nager.at —
|
||||
* label/color/country/region, several) and dissolving the Fusion.
|
||||
*/
|
||||
/** Each setting is a card on the inner surface, as the trip settings' sections. */
|
||||
const CARD = `flex-none rounded-[18px] px-3 pb-1 ${INNER_CLS}`
|
||||
|
||||
export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsSheetProps) {
|
||||
const { t, language } = useTranslation()
|
||||
const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, users } = useVacayStore()
|
||||
@@ -52,15 +56,18 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
|
||||
]
|
||||
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="opaque" ariaLabel={t('vacay.settings')} className="h-[calc(100dvh-120px)]">
|
||||
<div className="flex flex-none items-center gap-3 border-b border-[color:var(--m-rowbr)] px-[18px] pb-[10px] pt-4">
|
||||
<div className="flex-1 text-[1.0625rem] font-bold">{t('vacay.settings')}</div>
|
||||
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.close')}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('vacay.settings')} className="h-[calc(100dvh-120px)]">
|
||||
<div className="flex-none px-[18px] pt-4">
|
||||
<TileHeader
|
||||
icon={<Settings2 size={19} strokeWidth={1.8} />}
|
||||
title={t('vacay.settings')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-2">
|
||||
<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]">
|
||||
<section className={CARD}>
|
||||
{/* Block weekends */}
|
||||
<div className="flex items-start gap-[11px] py-3">
|
||||
<CalendarX size={17} strokeWidth={2} className="mt-[1px] flex-none text-m-muted" />
|
||||
@@ -94,7 +101,8 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="my-2 h-px bg-[color:var(--m-rowbr)]" />
|
||||
</section>
|
||||
<section className={CARD}>
|
||||
|
||||
{/* Week start */}
|
||||
<div className="flex items-start gap-[11px] py-3">
|
||||
@@ -121,7 +129,8 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="my-2 h-px bg-[color:var(--m-rowbr)]" />
|
||||
</section>
|
||||
<section className={CARD}>
|
||||
|
||||
{/* Carry over */}
|
||||
<div className="flex items-start gap-[11px] py-3">
|
||||
@@ -132,11 +141,13 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
|
||||
</div>
|
||||
<MToggle checked={plan.carry_over_enabled} onChange={() => updatePlan({ carry_over_enabled: !plan.carry_over_enabled })} ariaLabel={t('vacay.carryOver')} />
|
||||
</div>
|
||||
<div className="my-2 h-px bg-[color:var(--m-rowbr)]" />
|
||||
</section>
|
||||
<section className={CARD}>
|
||||
|
||||
{/* Vacation year (#737) — personal, unlike the plan settings around it */}
|
||||
<YearTypeRows />
|
||||
<div className="my-2 h-px bg-[color:var(--m-rowbr)]" />
|
||||
</section>
|
||||
<section className={CARD}>
|
||||
|
||||
{/* Company holidays */}
|
||||
<div className="flex items-start gap-[11px] py-3">
|
||||
@@ -147,7 +158,8 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
|
||||
</div>
|
||||
<MToggle checked={plan.company_holidays_enabled} onChange={() => updatePlan({ company_holidays_enabled: !plan.company_holidays_enabled })} ariaLabel={t('vacay.companyHolidays')} />
|
||||
</div>
|
||||
<div className="my-2 h-px bg-[color:var(--m-rowbr)]" />
|
||||
</section>
|
||||
<section className={CARD}>
|
||||
|
||||
{/* Public holidays + calendar editor */}
|
||||
<div className="flex items-start gap-[11px] py-3">
|
||||
@@ -189,7 +201,8 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="my-2 h-px bg-[color:var(--m-rowbr)]" />
|
||||
</section>
|
||||
<section className={CARD}>
|
||||
|
||||
{/* School holidays + calendar editor */}
|
||||
<div className="flex items-start gap-[11px] py-3">
|
||||
@@ -233,6 +246,8 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
|
||||
</>
|
||||
)}
|
||||
|
||||
</section>
|
||||
|
||||
{/* Dissolve Fusion */}
|
||||
{isFused && (
|
||||
<div className="mt-[14px] rounded-2xl border border-[rgba(214,39,59,.16)] bg-[rgba(214,39,59,.07)] p-[13px]">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ChevronDown, Eye, EyeOff, Loader2, Share2, X } from 'lucide-react'
|
||||
import { ChevronDown, Eye, EyeOff, Loader2, Share2 } from 'lucide-react'
|
||||
import MSheet from '../../components/MSheet'
|
||||
import MIconBtn from '../../components/MIconBtn'
|
||||
import MUserPickerList from '../../components/MUserPickerList'
|
||||
import { useVacayStore } from '../../../store/vacayStore'
|
||||
import { useTranslation } from '../../../i18n'
|
||||
import { useVacayShareActions } from '../../../components/Vacay/useVacayShareActions'
|
||||
import { useVacayUserPicker } from '../../../components/Vacay/useVacayUserPicker'
|
||||
import { TileHeader } from '../trip/sheets/MTripSheetUi'
|
||||
|
||||
interface MVacayShareSheetProps {
|
||||
open: boolean
|
||||
@@ -34,12 +34,12 @@ export default function MVacayShareSheet({ open, onClose }: MVacayShareSheetProp
|
||||
return (
|
||||
<MSheet open={open} onClose={onClose} variant="card" material="glass" ariaLabel={t('vacay.sharedCalendars')}>
|
||||
<div className="px-[18px] pb-[18px] pt-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 text-[1.0625rem] font-bold">{t('vacay.sharedCalendars')}</div>
|
||||
<MIconBtn variant="neutral" size={34} onClick={onClose} ariaLabel={t('common.close')}>
|
||||
<X size={15} strokeWidth={2.2} />
|
||||
</MIconBtn>
|
||||
</div>
|
||||
<TileHeader
|
||||
icon={<Share2 size={19} strokeWidth={1.8} />}
|
||||
title={t('vacay.sharedCalendars')}
|
||||
onClose={onClose}
|
||||
closeLabel={t('common.close')}
|
||||
/>
|
||||
<div className="mb-3 mt-2 font-geist text-[0.75rem] leading-normal text-m-muted">
|
||||
{t('vacay.shareCalendarHint')}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Keyboard } from '@capacitor/keyboard';
|
||||
import { SplashScreen } from '@capacitor/splash-screen';
|
||||
import { StatusBar, Style } from '@capacitor/status-bar';
|
||||
import { closeTopOverlay } from '../mobile/overlayStack';
|
||||
import { useSettingsStore } from '../store/settingsStore';
|
||||
import { isEffectivelyOffline } from '../sync/networkMode';
|
||||
import { checkAppUpdate, loadNativeCapabilities } from './capabilities';
|
||||
import { installNativeGeolocation } from './geolocation';
|
||||
@@ -64,7 +65,7 @@ export function startNativeApp(): void {
|
||||
if (isEffectivelyOffline()) return;
|
||||
// Not refreshed this time is no harm: the next launch tries again. An app
|
||||
// older than the offline copy has no such method, which is no failure.
|
||||
keepOfflineShellFresh().catch((err: unknown) => {
|
||||
keepOfflineShellFresh(fetch, useSettingsStore.getState().settings.language).catch((err: unknown) => {
|
||||
if (!isUnimplemented(err)) reportNativeFailure(err);
|
||||
});
|
||||
}, OFFLINE_SHELL_DELAY_MS);
|
||||
|
||||
@@ -17,6 +17,7 @@ const manifest = {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(TrekShell.refreshOfflineCopy).mockReset().mockResolvedValue({ saved: true });
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
describe('manifestFiles', () => {
|
||||
@@ -67,4 +68,35 @@ describe('keepOfflineShellFresh', () => {
|
||||
await keepOfflineShellFresh(fetchImpl);
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('fetches at low priority, only the own language, and once per build', async () => {
|
||||
const withLocales = {
|
||||
...manifest,
|
||||
'../shared/dist/i18n/de/index.mjs': { file: 'assets/de-aaaa1111.js' },
|
||||
'../shared/dist/i18n/fr/index.mjs': { file: 'assets/fr-bbbb2222.js' },
|
||||
'../shared/dist/i18n/en/index.mjs': { file: 'assets/en-cccc3333.js' },
|
||||
};
|
||||
const inits: (RequestInit | undefined)[] = [];
|
||||
const fetched: string[] = [];
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === '/asset-manifest.json') return new Response(JSON.stringify(withLocales));
|
||||
fetched.push(url);
|
||||
inits.push(init);
|
||||
return new Response('x');
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
await keepOfflineShellFresh(fetchImpl, 'de');
|
||||
expect(fetched).toContain('/assets/de-aaaa1111.js');
|
||||
expect(fetched).toContain('/assets/en-cccc3333.js');
|
||||
expect(fetched).not.toContain('/assets/fr-bbbb2222.js');
|
||||
expect(inits.every((i) => (i as { priority?: string } | undefined)?.priority === 'low')).toBe(true);
|
||||
|
||||
const before = fetched.length;
|
||||
await keepOfflineShellFresh(fetchImpl, 'de');
|
||||
expect(fetched.length).toBe(before);
|
||||
|
||||
await keepOfflineShellFresh(fetchImpl, 'fr');
|
||||
expect(fetched).toContain('/assets/fr-bbbb2222.js');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,16 @@ import { TrekShell } from './trekShell';
|
||||
*/
|
||||
|
||||
const MANIFEST_URL = '/asset-manifest.json';
|
||||
const CONCURRENCY = 4;
|
||||
/**
|
||||
* Few at a time and at low priority: the warm-up runs while the user already
|
||||
* moves through the app, and the page they open must never queue behind it.
|
||||
* Measured on a phone: four at normal priority held a first visit to Vacay
|
||||
* back by five to seven seconds right after a server update.
|
||||
*/
|
||||
const CONCURRENCY = 2;
|
||||
/** The build whose files are all in the cache; a launch on the same build skips the walk. */
|
||||
const WARMED_KEY = 'trek.offlineShellBuild';
|
||||
const LOCALE_KEY = /\/i18n\/([^/]+)\/index\.[cm]?js$/;
|
||||
|
||||
interface ManifestChunk {
|
||||
file?: string;
|
||||
@@ -19,10 +28,18 @@ interface ManifestChunk {
|
||||
assets?: string[];
|
||||
}
|
||||
|
||||
export function manifestFiles(manifest: unknown): string[] {
|
||||
/**
|
||||
* Every file of the build, without the translations of other languages: only
|
||||
* the user's own and English (the fallback) are needed offline, the other 25
|
||||
* would cost several megabytes per build for nothing.
|
||||
*/
|
||||
export function manifestFiles(manifest: unknown, language?: string): string[] {
|
||||
if (typeof manifest !== 'object' || manifest === null) return [];
|
||||
const keep = new Set(['en', language ?? 'en']);
|
||||
const files = new Set<string>();
|
||||
for (const chunk of Object.values(manifest as Record<string, ManifestChunk>)) {
|
||||
for (const [key, chunk] of Object.entries(manifest as Record<string, ManifestChunk>)) {
|
||||
const locale = LOCALE_KEY.exec(key)?.[1];
|
||||
if (locale && !keep.has(locale)) continue;
|
||||
if (typeof chunk?.file === 'string') files.add(chunk.file);
|
||||
for (const list of [chunk?.css, chunk?.assets]) {
|
||||
if (Array.isArray(list)) list.forEach((f) => typeof f === 'string' && files.add(f));
|
||||
@@ -31,24 +48,50 @@ export function manifestFiles(manifest: unknown): string[] {
|
||||
return [...files];
|
||||
}
|
||||
|
||||
async function warmCache(files: string[], fetchFile: (path: string) => Promise<unknown>): Promise<void> {
|
||||
/** True when every file was fetched. */
|
||||
async function warmCache(files: string[], fetchFile: (path: string) => Promise<unknown>): Promise<boolean> {
|
||||
const queue = [...files];
|
||||
let complete = true;
|
||||
const worker = async (): Promise<void> => {
|
||||
for (let path = queue.shift(); path !== undefined; path = queue.shift()) {
|
||||
try {
|
||||
await fetchFile(`/${path}`);
|
||||
} catch {
|
||||
// One missing chunk must not stop the rest; the next launch tries again.
|
||||
complete = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
|
||||
return complete;
|
||||
}
|
||||
|
||||
export async function keepOfflineShellFresh(fetchImpl: typeof fetch = fetch): Promise<void> {
|
||||
function warmedBuild(): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(WARMED_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function rememberWarmed(build: string): void {
|
||||
try {
|
||||
window.localStorage.setItem(WARMED_KEY, build);
|
||||
} catch {
|
||||
// Not remembered: the next launch walks the files again, from the cache.
|
||||
}
|
||||
}
|
||||
|
||||
export async function keepOfflineShellFresh(fetchImpl: typeof fetch = fetch, language?: string): Promise<void> {
|
||||
await TrekShell.refreshOfflineCopy();
|
||||
const response = await fetchImpl(MANIFEST_URL, { cache: 'no-store' });
|
||||
if (!response.ok) return;
|
||||
const files = manifestFiles(await response.json());
|
||||
await warmCache(files, (path) => fetchImpl(path).then((r) => r.blob()));
|
||||
const files = manifestFiles(await response.json(), language);
|
||||
// The build and the language together: a new language needs its own chunk.
|
||||
const build = `${[...files].sort().find((f) => /index-[^/]+\.js$/.test(f)) ?? files.length}:${language ?? 'en'}`;
|
||||
if (warmedBuild() === build) return;
|
||||
const complete = await warmCache(files, (path) =>
|
||||
fetchImpl(path, { priority: 'low' } as RequestInit).then((r) => r.blob())
|
||||
);
|
||||
if (complete) rememberWarmed(build);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user