diff --git a/client/src/components/Trips/TripFormModal.tsx b/client/src/components/Trips/TripFormModal.tsx index a5bb59f7f..83113e617 100644 --- a/client/src/components/Trips/TripFormModal.tsx +++ b/client/src/components/Trips/TripFormModal.tsx @@ -1,6 +1,7 @@ -import { useState, useEffect, useRef } from 'react' -import Modal from '../shared/Modal' -import { Calendar, Camera, Search, X, UserPlus, Bell } from 'lucide-react' +import { useId, useState, useEffect, useRef } from 'react' +import { DialogButton, DialogFooter, DialogHeader, DialogShell, DialogTile, FooterSpacer, NEUTRAL_TINT } from '../shared/DialogShell' +import { CardHead, INPUT, LABEL, PANEL } from '../shared/dialogParts' +import { CalendarDays, Camera, Image as ImageIcon, Pencil, Plane, Search, Type, X } from 'lucide-react' import { tripsApi, authApi } from '../../api/client' import CustomSelect from '../shared/CustomSelect' import { useAuthStore } from '../../store/authStore' @@ -43,10 +44,17 @@ interface TripFormModalProps { onSave: (data: TripCreateRequest & { date_shift_mode?: DateShiftMode }) => Promise<{ trip?: Trip } | void> | void trip: Trip | null onCoverUpdate?: (tripId: number, coverUrl: string | null) => void + /** + * Drawn inside the trip settings rather than as a dialog of its own (#2811): no + * frame and no Cancel, the settings around it close it. One column there, since + * beside the settings' own side column two would cut the dates and wrap the chips. + */ + embedded?: boolean } -export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }: TripFormModalProps) { +export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate, embedded = false }: TripFormModalProps) { const isEditing = !!trip + const labelId = useId() const fileRef = useRef(null) const toast = useToast() const { t } = useTranslation() @@ -256,352 +264,367 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp variables are reachable from here — what carries over is the geometry: generous radii, grouped panels instead of a single stack of labelled fields, and overline captions rather than sentence-case labels. */ - const inputCls = "w-full px-3.5 py-2.5 border border-edge rounded-xl bg-surface-input text-content placeholder:text-content-faint focus:outline-none focus:ring-2 focus:ring-accent/30 focus:border-accent text-body transition-shadow" - const labelCls = "flex items-center gap-1 text-caption font-semibold uppercase tracking-[0.14em] text-content-faint mb-2" - const ghostBtnCls = "px-4 py-2.5 text-body font-medium text-content-secondary hover:text-content border border-edge rounded-xl hover:bg-surface-hover transition-colors" - const primaryBtnCls = "px-5 py-2.5 text-body font-medium bg-accent hover:bg-accent-hover disabled:opacity-50 text-accent-text rounded-xl shadow-card transition-colors flex items-center gap-2" - const dangerBtnCls = "px-5 py-2.5 text-body font-medium whitespace-nowrap bg-danger hover:opacity-90 disabled:opacity-50 text-white rounded-xl shadow-card transition-opacity flex items-center gap-2" + // The dialogs' own language: their fields, eyebrows, panels and buttons, so the + // form reads alike on its own and inside the trip settings. + const inputCls = INPUT + const labelCls = `${LABEL.replace('block ', '')} flex items-center gap-1` /* Two columns once there is room for them: the form had grown to eight stacked blocks and the create button sat a full screen below the title. Left is what the trip looks like, right is when and with whom. One column below md. */ const columnCls = "flex flex-col gap-4" - const panelCls = "rounded-2xl border border-edge bg-surface-secondary p-4 space-y-3.5" + const panelCls = PANEL - return ( - { if (!busy) setPendingReview(null) } : onClose} - title={pendingReview - ? t(pendingReview.askShift ? 'dashboard.dateShiftTitle' : 'dashboard.shrinkTitle') - : isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')} - /* The review step is a few radio buttons and a short list; alone, either - takes the width of the day dialog, where the same list stands. With both, - they stand side by side at the form's own width. */ - size={pendingReview && !dateReviewIsWide(pendingReview.askShift, pendingReview.removal) ? 'lg' : '2xl'} - footer={ -
- {pendingReview ? ( - <> - - {/* Removing days with something on them is the one save here that - loses work, so it asks in the danger tone and says so. */} - - - ) : ( - <> - - - - )} -
- } - > - {pendingReview && ( - + const reviewTitle = pendingReview ? t(pendingReview.askShift ? 'dashboard.dateShiftTitle' : 'dashboard.shrinkTitle') : '' + const buttons = pendingReview ? ( + <> + setPendingReview(null)} disabled={busy}>{t('common.back')} + {/* Removing days with something on them is the one save here that + loses work, so it asks in the danger tone and says so. */} + performSave(pendingReview.askShift ? { ...pendingReview.payload, date_shift_mode: dateShiftMode } : pendingReview.payload)}> + {isLoading ? t('common.saving') : t(pendingReview.removal ? 'dashboard.shrinkConfirm' : 'common.update')} + + + ) : ( + <> + {!embedded && {t('common.cancel')}} + + {busy ? t('common.saving') : isEditing ? t('common.update') : t('dashboard.createTrip')} + + + ) + const content = ( + <> + {pendingReview && ( + + )} +
+ {error && ( +
{error}
)} - - {error && ( -
{error}
- )} -
+
- {/* Left column — what the trip is: its picture, its name, what it is about. */} -
+ {/* Left column — what the trip is: its picture, its name, what it is about. */} +
- {/* Cover image — gated by trip_cover_upload permission */} - {canUploadCover &&
- - - {coverPreview ? ( -
- -
- {/* Chrome sitting on top of a photo, so it is deliberately dark in - both themes rather than following the surface tokens. */} - - -
+ {/* Cover image — gated by trip_cover_upload permission */} + {canUploadCover &&
+ } title={t('trip.form.coverTitle')} hint={t('trip.form.coverHint')} /> + + {coverPreview ? ( +
+ +
+ {/* Chrome sitting on top of a photo, so it is deliberately dark in + both themes rather than following the surface tokens. */} + +
- ) : ( - - )} -
- setCoverSearchQuery(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void handleCoverSearch() } }} - placeholder={t('dashboard.unsplashSearchPlaceholder')} - className={inputCls} - /> -
- {coverSearchError &&

{coverSearchError}

} - {coverSearchResults.length > 0 && ( -
- {coverSearchResults.map(photo => ( - + )} +
+ setCoverSearchQuery(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); void handleCoverSearch() } }} + placeholder={t('dashboard.unsplashSearchPlaceholder')} + className={inputCls} + /> + +
+ {coverSearchError &&

{coverSearchError}

} + {coverSearchResults.length > 0 && ( +
+ {coverSearchResults.map(photo => ( + + ))} +
+ )} +
} + +
+ } title={t('trip.form.aboutTitle')} hint={t('trip.form.aboutHint')} /> +
+ + canEditTrip && update('title', e.target.value)} + required readOnly={!canEditTrip} placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} /> +
+ +
+ +