Map/planner/dashboard polish and small community features (#1155)

* feat(planner): reorder days in a modal instead of a dropdown

The day-reorder control opened a small anchored dropdown; move it into the shared Modal (portal, dimmed backdrop, Esc/backdrop close) so it matches the Add activity dialog. Drag handles, up/down arrows and the day badges are unchanged.

* feat(map): explore reliability, Mapbox popups + compass, region-biased search

POI explore: clamp oversized viewports, query the Overpass mirrors in parallel (first valid response wins) with a per-request timeout and a short-lived cache, and surface a retry when every mirror fails - so it returns results at any zoom instead of timing out.

Mapbox renderer: add the place/POI hover popups (name, category, address, photo) the Leaflet map already had, plus a compass pill next to the explore pill that resets the view to north.

/api/maps/search: accept an optional locationBias to fix foreign-region bias and expose Google's place types in the result.

* feat(dashboard): list-view and mobile polish

Use the Archived status label for the filter and show Open dates for trips without dates; drop the unused settings button next to the view toggle. Desktop list view renders the date as a stat-style block separated from the counts.

Mobile list rows are stacked (slim cover banner + centred date), trip actions stay visible (touch has no hover), and the hero card's hover lift is disabled on touch; small spacing fix under the sidebar.

* feat: small community-requested options

Raise the plan-note subtitle limit to 250 characters and add more note icons. Expose is_archived and cover_image on the update_trip MCP tool. Add place coordinates to the PDF export. Allow creating a category from an existing to-do, and add a show/hide toggle on the admin password fields.

* test(shared): bump day-note subtitle limit assertion to 250

* test: align specs with the new search param order and archive label

Keep lang as the 3rd positional arg of the maps search controller so the existing unit test stays valid, and forward locationBias as the 4th. Add the now-used Popup to the MapViewGL mapbox mock, switch the dashboard archive-filter query to the Archived label, and expect the 4-arg search call.
This commit is contained in:
Maurice
2026-06-12 20:23:34 +02:00
committed by GitHub
parent f46cc8a98e
commit e224befde7
28 changed files with 623 additions and 186 deletions
+3 -1
View File
@@ -559,8 +559,10 @@ export const mapsApi = {
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
// OSM-only POI explore: places of a category within the current map viewport bbox.
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
// timeout than the global default instead of aborting at 8s and showing nothing.
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean }),
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
}
export const airportsApi = {
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import { Navigation } from 'lucide-react'
import type mapboxgl from 'mapbox-gl'
/**
* Round compass pill for the Mapbox planner map. The Mapbox map can be rotated and
* pitched, so this shows the current bearing (the arrow points to north) and snaps
* the camera back to north + flat on click. Rendered next to the POI "explore" pill
* (Mapbox only) and built as the SAME frosted shell (padding 4 around a 34px button)
* so its height and transparency match the POI pill exactly.
*/
export function MapCompassPill({ map }: { map: mapboxgl.Map }) {
const [bearing, setBearing] = useState(() => map.getBearing())
useEffect(() => {
const update = () => setBearing(map.getBearing())
update()
map.on('rotate', update)
return () => { map.off('rotate', update) }
}, [map])
return (
<div style={{
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(20px) saturate(180%)',
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
}}>
<button
type="button"
onClick={() => map.easeTo({ bearing: 0, pitch: 0, duration: 300 })}
aria-label="Reset north"
className="text-content-muted"
style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: 'transparent', padding: 0,
transition: 'background 0.14s, color 0.14s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
>
<Navigation size={16} strokeWidth={2} style={{ transform: `rotate(${-bearing}deg)`, transition: 'transform 0.1s linear' }} />
</button>
</div>
)
}
@@ -40,6 +40,12 @@ vi.mock('mapbox-gl', () => ({
})),
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
NavigationControl: vi.fn(),
Popup: vi.fn(() => ({
setLngLat: vi.fn().mockReturnThis(),
setHTML: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
})),
},
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
+35 -1
View File
@@ -13,6 +13,7 @@ import LocationButton from './LocationButton'
import { useGeolocation } from '../../hooks/useGeolocation'
import type { Place, Reservation } from '../../types'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import { buildPlacePopupHtml, buildPoiPopupHtml } from './placePopup'
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
@@ -53,6 +54,7 @@ interface Props {
pois?: Poi[]
onPoiClick?: (poi: Poi) => void
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
onMapReady?: (map: mapboxgl.Map | null) => void
}
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
@@ -167,6 +169,7 @@ export function MapViewGL({
pois = [],
onPoiClick,
onViewportChange,
onMapReady,
}: Props) {
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
@@ -186,10 +189,15 @@ export function MapViewGL({
const onReservationClickRef = useRef(onReservationClick)
onReservationClickRef.current = onReservationClick
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
// Single reusable hover popup (name/category/address card) shared by planned
// places and POI markers — mirrors the Leaflet map's hover tooltip.
const popupRef = useRef<mapboxgl.Popup | null>(null)
const onPoiClickRef = useRef(onPoiClick)
onPoiClickRef.current = onPoiClick
const onViewportChangeRef = useRef(onViewportChange)
onViewportChangeRef.current = onViewportChange
const onMapReadyRef = useRef(onMapReady)
onMapReadyRef.current = onMapReady
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
onClickRefs.current.marker = onMarkerClick
@@ -212,6 +220,16 @@ export function MapViewGL({
projection: mapboxQuality ? 'globe' : 'mercator',
})
mapRef.current = map
popupRef.current = new mapboxgl.Popup({
closeButton: false,
closeOnClick: false,
offset: 18,
maxWidth: '240px',
className: 'trek-map-popup',
})
// Hand the map out so the trip planner can render its own compass pill next to
// the POI pill (a custom round control instead of Mapbox's default top-right one).
onMapReadyRef.current?.(map)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(window as any).__trek_map = map
@@ -357,6 +375,8 @@ export function MapViewGL({
canvas.removeEventListener('auxclick', onAuxClick)
markersRef.current.forEach(m => m.remove())
markersRef.current.clear()
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
onMapReadyRef.current?.(null)
if (reservationOverlayRef.current) {
reservationOverlayRef.current.destroy()
reservationOverlayRef.current = null
@@ -430,6 +450,10 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map) return
// Markers are about to be rebuilt; drop any open hover popup first. A marker
// recreated under the pointer (e.g. when its photo streams in) never fires
// mouseleave, which would otherwise leave the popup orphaned on the map.
popupRef.current?.remove()
const ids = new Set(places.map(p => p.id))
markersRef.current.forEach((marker, id) => {
@@ -450,6 +474,12 @@ export function MapViewGL({
ev.stopPropagation()
onClickRefs.current.marker?.(place.id)
})
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([place.lng, place.lat])
.setHTML(buildPlacePopupHtml(place as Place & { category_color?: string; category_icon?: string; category_name?: string }, photoUrl))
.addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
// Recreate marker each time rather than patching internal state —
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
const existing = markersRef.current.get(place.id)
@@ -471,11 +501,15 @@ export function MapViewGL({
useEffect(() => {
const map = mapRef.current
if (!map || !mapReady) return
popupRef.current?.remove() // same orphan-popup guard as the place markers
poiMarkersRef.current.forEach(m => m.remove())
poiMarkersRef.current = []
for (const poi of (pois as Poi[])) {
const el = createPoiMarkerElement(poi.category)
el.title = poi.name
el.addEventListener('mouseenter', () => {
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
})
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
poiMarkersRef.current.push(m)
+18 -4
View File
@@ -1,4 +1,4 @@
import { RotateCw } from 'lucide-react'
import { RotateCw, AlertTriangle } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Tooltip } from '../shared/Tooltip'
import { POI_CATEGORIES } from './poiCategories'
@@ -7,6 +7,8 @@ interface Props {
active: Set<string>
onToggle: (key: string) => void
loadingKeys?: Set<string>
/** categories whose last fetch failed → show a retry affordance */
errorKeys?: Set<string>
/** true when the map moved since the last search → offer "search this area" */
moved?: boolean
onSearchArea?: () => void
@@ -15,8 +17,9 @@ interface Props {
// Frosted, icon-only segmented control that floats over the map. Active segments
// fill with the category colour (matching their markers); the label shows in a
// custom tooltip on hover so the pill stays compact and never needs to scroll.
export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved, onSearchArea }: Props) {
export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKeys, moved, onSearchArea }: Props) {
const { t } = useTranslation()
const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
const frosted: React.CSSProperties = {
background: 'var(--sidebar-bg)',
@@ -40,6 +43,7 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
aria-label={t(cat.labelKey)}
className={on ? '' : 'text-content-muted'}
style={{
position: 'relative',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
background: on ? cat.color : 'transparent',
@@ -61,13 +65,19 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
) : (
<cat.Icon size={16} strokeWidth={2} />
)}
{on && !loading && errorKeys?.has(cat.key) && (
<span style={{
position: 'absolute', top: 2, right: 2, width: 8, height: 8,
borderRadius: 999, background: '#ef4444', border: '1.5px solid var(--sidebar-bg)',
}} />
)}
</button>
</Tooltip>
)
})}
</div>
{moved && active.size > 0 && (
{(moved || anyError) && active.size > 0 && (
<button
type="button"
onClick={onSearchArea}
@@ -76,10 +86,14 @@ export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved,
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '6px 13px', borderRadius: 999, border: 'none', cursor: 'pointer',
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', pointerEvents: 'auto',
color: anyError ? '#ef4444' : undefined,
...frosted,
}}
>
<RotateCw size={13} strokeWidth={2.4} /> {t('poi.searchThisArea')}
{anyError
? <AlertTriangle size={13} strokeWidth={2.4} />
: <RotateCw size={13} strokeWidth={2.4} />}
{t('poi.searchThisArea')}
</button>
)}
</div>
+68
View File
@@ -0,0 +1,68 @@
import { createElement } from 'react'
import { renderToStaticMarkup } from 'react-dom/server'
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
import type { Place } from '../../types'
// HTML builders for the Mapbox GL hover popup. The Leaflet map already shows a
// name/category/address card on hover (a cursor-following overlay); Mapbox GL has
// no equivalent, so these produce the same card as an HTML string for a
// mapboxgl.Popup. Kept framework-agnostic (plain strings) on purpose.
type PlaceWithCategory = Place & {
category_color?: string | null
category_icon?: string | null
category_name?: string | null
}
function esc(s: string | null | undefined): string {
if (!s) return ''
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
// Render a lucide category icon to an inline SVG string in the given colour.
function iconSvg(iconName: string | null | undefined, size: number, color: string): string {
const Icon = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
try {
return renderToStaticMarkup(createElement(Icon, { size, color, strokeWidth: 2 }))
} catch {
return ''
}
}
// Only data: thumbnails and our own photo-proxy URLs are safe to drop straight
// into an <img src> — everything else is a fetch seed, not a displayable URL.
function isDisplayablePhoto(url: string | null | undefined): url is string {
return !!url && (url.startsWith('data:') || url.startsWith('/api/maps/place-photo/'))
}
const CARD_OPEN = '<div style="font-family:var(--font-system);max-width:220px;">'
const NAME_STYLE = 'font-weight:600;font-size:12.5px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
const ADDR_STYLE = 'font-size:11px;color:#9ca3af;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
/** Hover-popup card for a planned place: optional photo, name, category row, address. */
export function buildPlacePopupHtml(place: PlaceWithCategory, photoUrl: string | null): string {
const img = isDisplayablePhoto(photoUrl)
? `<div style="width:100%;height:84px;border-radius:8px;overflow:hidden;margin-bottom:6px;background:#f3f4f6;"><img src="${esc(photoUrl)}" style="width:100%;height:100%;object-fit:cover;display:block;" /></div>`
: ''
const category =
place.category_name && place.category_icon
? `<div style="display:flex;align-items:center;gap:4px;margin-top:2px;">${iconSvg(place.category_icon, 11, place.category_color || '#6b7280')}<span style="font-size:11px;color:#6b7280;">${esc(place.category_name)}</span></div>`
: ''
const address = place.address ? `<div style="${ADDR_STYLE}">${esc(place.address)}</div>` : ''
return `${CARD_OPEN}${img}<div style="${NAME_STYLE}">${esc(place.name)}</div>${category}${address}</div>`
}
/** Hover-popup card for an OSM "explore" POI: category-coloured icon, name, address. */
export function buildPoiPopupHtml(poi: Poi): string {
const cat = POI_CATEGORY_BY_KEY[poi.category]
const color = cat?.color || '#6b7280'
const icon = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 12, color, strokeWidth: 2 })) : ''
const head = `<div style="display:flex;align-items:center;gap:5px;"><span style="flex-shrink:0;display:inline-flex;line-height:0;">${icon}</span><span style="${NAME_STYLE}">${esc(poi.name)}</span></div>`
const address = poi.address ? `<div style="${ADDR_STYLE}">${esc(poi.address)}</div>` : ''
return `${CARD_OPEN}${head}${address}</div>`
}
+44 -5
View File
@@ -4,6 +4,12 @@ import type { Poi } from './poiCategories'
export interface Bbox { south: number; west: number; north: number; east: number }
// A request we cancelled on purpose (newer search superseded it) — not a failure.
function isAbortError(err: unknown): boolean {
const e = err as { name?: string; code?: string } | null
return e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED' || e?.name === 'AbortError'
}
/**
* State for the map POI "explore" pill. Toggling a category fetches its OSM POIs
* for the current viewport; panning/zooming does NOT auto-refetch — it just marks
@@ -15,12 +21,18 @@ export function usePoiExplore() {
const [byCat, setByCat] = useState<Record<string, Poi[]>>({})
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(() => new Set())
const [moved, setMoved] = useState(false)
// Categories whose last fetch genuinely failed (all Overpass mirrors down), so
// the pill can offer a retry instead of looking like "no places here".
const [errorKeys, setErrorKeys] = useState<Set<string>>(() => new Set())
const bboxRef = useRef<Bbox | null>(null)
// activeRef always mirrors the latest active set so async callbacks (fetch
// completions) can check whether a category is still wanted.
const activeRef = useRef(active)
activeRef.current = active
// One in-flight AbortController per category, so re-toggling / re-searching
// cancels the previous (possibly slow) Overpass request instead of racing it.
const abortRef = useRef<Record<string, AbortController>>({})
const setLoading = useCallback((key: string, on: boolean) => setLoadingKeys(prev => {
const next = new Set(prev)
@@ -28,19 +40,41 @@ export function usePoiExplore() {
return next
}), [])
const setError = useCallback((key: string, on: boolean) => setErrorKeys(prev => {
if (on === prev.has(key)) return prev
const next = new Set(prev)
if (on) next.add(key); else next.delete(key)
return next
}), [])
const fetchCat = useCallback(async (key: string, bbox: Bbox) => {
abortRef.current[key]?.abort()
const ctrl = new AbortController()
abortRef.current[key] = ctrl
setLoading(key, true)
setError(key, false)
try {
const res = await mapsApi.pois(key, bbox)
const res = await mapsApi.pois(key, bbox, ctrl.signal)
// Drop the result if the user toggled this category off while the (slow)
// Overpass request was in flight — otherwise stale results re-appear.
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: res.pois } : prev))
} catch {
} catch (err) {
// A superseded request was aborted on purpose — leave its state untouched
// so the newer request owns the spinner and results.
if (isAbortError(err)) return
// A real failure (every Overpass mirror down/timed out): surface it instead
// of a silent empty so the user can retry rather than assume "no places".
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev))
if (activeRef.current.has(key)) setError(key, true)
} finally {
setLoading(key, false)
// Only the latest controller for this key clears the spinner; a superseded
// one must not, or it would hide the newer request's in-flight state.
if (abortRef.current[key] === ctrl) {
setLoading(key, false)
delete abortRef.current[key]
}
}
}, [setLoading])
}, [setLoading, setError])
const onViewportChange = useCallback((bbox: Bbox) => {
bboxRef.current = bbox
@@ -53,6 +87,11 @@ export function usePoiExplore() {
const toggle = useCallback((key: string) => {
const isOnlyActive = activeRef.current.has(key) && activeRef.current.size === 1
setMoved(false)
setErrorKeys(new Set())
// Switching to another category (or turning off) — cancel any in-flight
// fetches so their results can't land after the selection changed.
Object.values(abortRef.current).forEach(c => c.abort())
abortRef.current = {}
if (isOnlyActive) {
setActive(new Set())
setByCat({})
@@ -72,5 +111,5 @@ export function usePoiExplore() {
const pois = useMemo(() => Object.values(byCat).flat(), [byCat])
return { active, pois, loadingKeys, moved, toggle, searchArea, onViewportChange }
return { active, pois, loadingKeys, errorKeys, moved, toggle, searchArea, onViewportChange }
}
+1
View File
@@ -293,6 +293,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
</div>
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
${chips ? `<div class="chips">${chips}</div>` : ''}
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
@@ -2,6 +2,7 @@ import {
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
Wine, ParkingSquare, Fuel, Footprints, Mountain, Waves, Sun, Umbrella, Music, Landmark, Gift,
} from 'lucide-react'
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
@@ -27,6 +28,18 @@ export const NOTE_ICONS = [
{ id: 'AlertTriangle', Icon: AlertTriangle },
{ id: 'ShoppingBag', Icon: ShoppingBag },
{ id: 'Bookmark', Icon: Bookmark },
{ id: 'Utensils', Icon: Utensils },
{ id: 'Wine', Icon: Wine },
{ id: 'ParkingSquare', Icon: ParkingSquare },
{ id: 'Fuel', Icon: Fuel },
{ id: 'Footprints', Icon: Footprints },
{ id: 'Mountain', Icon: Mountain },
{ id: 'Waves', Icon: Waves },
{ id: 'Sun', Icon: Sun },
{ id: 'Umbrella', Icon: Umbrella },
{ id: 'Music', Icon: Music },
{ id: 'Landmark', Icon: Landmark },
{ id: 'Gift', Icon: Gift },
]
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
@@ -58,7 +58,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
/>
<textarea
value={ui.time}
maxLength={150}
maxLength={250}
rows={3}
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
@@ -66,7 +66,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
className="text-content"
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'none', lineHeight: 1.4 }}
/>
<div className={(ui.time?.length || 0) >= 140 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/150</div>
<div className={(ui.time?.length || 0) >= 240 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/250</div>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} className={!ui.text?.trim() ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'} style={{ fontSize: 12, border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
@@ -225,16 +225,15 @@ export function DayPlanSidebarToolbar({
<ArrowUpDown size={14} strokeWidth={2} />
</button>
</Tooltip>
{reorderOpen && (
<DayReorderPopup
days={days}
t={t}
locale={locale}
onReorder={onReorderDays}
onAddDay={() => onAddDay()}
onClose={() => setReorderOpen(false)}
/>
)}
<DayReorderPopup
isOpen={reorderOpen}
days={days}
t={t}
locale={locale}
onReorder={onReorderDays}
onAddDay={() => onAddDay()}
onClose={() => setReorderOpen(false)}
/>
</div>
)}
</div>
@@ -1,8 +1,10 @@
import { useState } from 'react'
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
import Modal from '../shared/Modal'
import type { Day } from '../../types'
interface DayReorderPopupProps {
isOpen: boolean
days: Day[]
t: (key: string, params?: Record<string, any>) => string
locale: string
@@ -12,12 +14,12 @@ interface DayReorderPopupProps {
}
/**
* Compact panel for moving whole days around: drag a row by its grip or use the
* up/down arrows, and add a day at the end. Day headers stay untouched — this is
* the single surface for ordering. Reorders are applied optimistically by the
* store, so the list reflects each move immediately.
* Modal for moving whole days around: drag a row by its grip or use the up/down
* arrows, and add a day at the end. Day headers stay untouched — this is the
* single surface for ordering. Reorders are applied optimistically by the store,
* so the list reflects each move immediately.
*/
export function DayReorderPopup({ days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [overIndex, setOverIndex] = useState<number | null>(null)
@@ -41,97 +43,101 @@ export function DayReorderPopup({ days, t, locale, onReorder, onAddDay, onClose
}
const cellBtn = {
display: 'grid', placeItems: 'center', width: 26, height: 26,
display: 'grid', placeItems: 'center', width: 28, height: 28,
border: '1px solid var(--border-faint)', borderRadius: 7,
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
} as const
return (
<>
{/* outside-click catcher */}
<div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 250 }} />
<div
onClick={e => e.stopPropagation()}
style={{
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 251,
width: 290, maxHeight: 360, display: 'flex', flexDirection: 'column',
background: 'var(--bg-card, white)', color: 'var(--text-primary)',
border: '1px solid var(--border-faint)', borderRadius: 12,
boxShadow: '0 12px 32px rgba(0,0,0,0.18)', overflow: 'hidden',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '11px 12px 8px' }}>
<span style={{ fontSize: 12.5, fontWeight: 600 }}>{t('dayplan.reorderTitle')}</span>
<Modal
isOpen={isOpen}
onClose={onClose}
title={t('dayplan.reorderTitle')}
size="md"
footer={
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<button
onClick={onClose}
style={{
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
border: '1px solid var(--border-primary)', background: 'none',
color: 'var(--text-muted)', cursor: 'pointer', fontFamily: 'inherit',
}}
>
{t('common.close')}
</button>
<button
onClick={onAddDay}
className="bg-accent text-accent-text"
style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 9px',
borderRadius: 7, border: 'none', fontSize: 11, fontWeight: 500,
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<Plus size={13} strokeWidth={2} />
<Plus size={15} strokeWidth={2} />
{t('dayplan.addDay')}
</button>
</div>
<div style={{ padding: '0 12px 8px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.35 }}>
{t('dayplan.reorderHint')}
</div>
}
>
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
{t('dayplan.reorderHint')}
</p>
<div className="scroll-container" style={{ overflowY: 'auto', padding: '0 8px 8px', minHeight: 0 }}>
{ordered.map((day, index) => (
<div
key={day.id}
draggable
onDragStart={() => setDragIndex(index)}
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
onDrop={e => {
e.preventDefault()
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
setDragIndex(null); setOverIndex(null)
}}
style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px',
borderRadius: 8, marginTop: 2,
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'transparent',
opacity: dragIndex === index ? 0.5 : 1,
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
outlineOffset: -2,
}}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{ordered.map((day, index) => (
<div
key={day.id}
draggable
onDragStart={() => setDragIndex(index)}
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
onDrop={e => {
e.preventDefault()
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
setDragIndex(null); setOverIndex(null)
}}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px',
borderRadius: 9,
border: '1px solid var(--border-faint)',
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'var(--bg-card, white)',
opacity: dragIndex === index ? 0.5 : 1,
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
outlineOffset: -2,
}}
>
<GripVertical size={15} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{
flexShrink: 0, width: 24, height: 24, borderRadius: '50%',
background: 'var(--bg-hover)', color: 'var(--text-muted)',
display: 'grid', placeItems: 'center', fontSize: 11, fontWeight: 700,
}}>
{index + 1}
</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label(day, index)}
</span>
<button
onClick={() => move(index, index - 1)}
disabled={index === 0}
aria-label={t('dayplan.moveUp')}
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
>
<GripVertical size={14} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{
flexShrink: 0, width: 22, height: 22, borderRadius: '50%',
background: 'var(--bg-hover)', color: 'var(--text-muted)',
display: 'grid', placeItems: 'center', fontSize: 10.5, fontWeight: 700,
}}>
{index + 1}
</span>
<span style={{ flex: 1, minWidth: 0, fontSize: 12.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{label(day, index)}
</span>
<button
onClick={() => move(index, index - 1)}
disabled={index === 0}
aria-label={t('dayplan.moveUp')}
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
>
<ArrowUp size={13} strokeWidth={2} />
</button>
<button
onClick={() => move(index, index + 1)}
disabled={index === ordered.length - 1}
aria-label={t('dayplan.moveDown')}
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
>
<ArrowDown size={13} strokeWidth={2} />
</button>
</div>
))}
</div>
<ArrowUp size={14} strokeWidth={2} />
</button>
<button
onClick={() => move(index, index + 1)}
disabled={index === ordered.length - 1}
aria-label={t('dayplan.moveDown')}
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
>
<ArrowDown size={14} strokeWidth={2} />
</button>
</div>
))}
</div>
</>
</Modal>
)
}
+47 -15
View File
@@ -277,6 +277,7 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
const [desc, setDesc] = useState(item.description || '')
const [dueDate, setDueDate] = useState(item.due_date || '')
const [category, setCategory] = useState(item.category || '')
const [addingCategory, setAddingCategoryInline] = useState(false)
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
const [priority, setPriority] = useState(item.priority || 0)
const [saving, setSaving] = useState(false)
@@ -378,21 +379,52 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
{/* Category */}
<div>
<label className={labelClass}>{t('todo.detail.category')}</label>
<CustomSelect
value={category}
onChange={v => setCategory(String(v))}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
value: c,
label: c,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
})),
]}
placeholder={t('todo.noCategory')}
size="sm"
disabled={!canEdit}
/>
{addingCategory ? (
<div style={{ display: 'flex', gap: 4 }}>
<input
autoFocus
value={category}
onChange={e => setCategory(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }}
placeholder={t('todo.newCategory')}
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
/>
<button type="button" onClick={() => setAddingCategoryInline(false)}
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-primary)' }}>
<Check size={14} />
</button>
</div>
) : (
<div style={{ display: 'flex', gap: 4 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<CustomSelect
value={category}
onChange={v => setCategory(String(v))}
options={[
{ value: '', label: t('todo.noCategory') },
...categories.map(c => ({
value: c, label: c,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
})),
...(category && !categories.includes(category) ? [{
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#9ca3af', display: 'inline-block' }} />,
}] : []),
]}
placeholder={t('todo.noCategory')}
size="sm"
disabled={!canEdit}
/>
</div>
{canEdit && (
<button type="button" onClick={() => { setCategory(''); setAddingCategoryInline(true) }}
title={t('todo.newCategory')}
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>
<Plus size={14} />
</button>
)}
</div>
)}
</div>
{/* Due date */}
+17
View File
@@ -35,6 +35,23 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
color: var(--text-primary) !important;
}
/* Mapbox GL hover popup — the name/category/address card on marker hover.
Matches the Leaflet map's white hover tooltip. pointer-events:none so moving
onto the popup never steals the marker's mouseleave and causes flicker. */
.trek-map-popup { pointer-events: none; }
.trek-map-popup .mapboxgl-popup-content {
padding: 7px 10px;
border-radius: 10px;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16);
}
.trek-map-popup .mapboxgl-popup-tip {
border-top-color: #fff;
border-bottom-color: #fff;
border-left-color: #fff;
border-right-color: #fff;
}
.atlas-tooltip {
background: rgba(10, 10, 20, 0.6) !important;
backdrop-filter: blur(20px) saturate(180%) !important;
+5 -5
View File
@@ -226,7 +226,7 @@ describe('DashboardPage', () => {
await user.click(archiveButtons[0]);
// Switch to the archive filter segment
await user.click(screen.getByText('Archive'));
await user.click(screen.getByText('Archived'));
await waitFor(() => {
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
@@ -293,7 +293,7 @@ describe('DashboardPage', () => {
});
// Switch to the archive filter
await user.click(screen.getByText('Archive'));
await user.click(screen.getByText('Archived'));
await waitFor(() => {
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
@@ -442,7 +442,7 @@ describe('DashboardPage', () => {
});
// Switch to the archive filter
await user.click(screen.getByText('Archive'));
await user.click(screen.getByText('Archived'));
await waitFor(() => {
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
@@ -644,7 +644,7 @@ describe('DashboardPage', () => {
});
// Archive filter reveals the archived trip
await user.click(screen.getByText('Archive'));
await user.click(screen.getByText('Archived'));
await waitFor(() => {
expect(screen.getByText('Old Archived Trip')).toBeInTheDocument();
});
@@ -687,7 +687,7 @@ describe('DashboardPage', () => {
expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument();
});
await user.click(screen.getByText('Archive'));
await user.click(screen.getByText('Archived'));
await waitFor(() => {
expect(screen.getByText('Restored Trip')).toBeInTheDocument();
+2 -5
View File
@@ -16,7 +16,7 @@ import {
import {
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
LayoutGrid, List, SlidersHorizontal, Ticket, X,
LayoutGrid, List, Ticket, X,
} from 'lucide-react'
import '../styles/dashboard.css'
@@ -120,15 +120,12 @@ export default function DashboardPage(): React.ReactElement {
<div className="sec-tools">
<div className="seg">
<button className={tripFilter === 'planned' ? 'on' : ''} onClick={() => setTripFilter('planned')}>{t('dashboard.filter.planned')}</button>
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archive')}</button>
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archived')}</button>
<button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
</div>
<button className="tool-action" aria-label={t('dashboard.aria.toggleView')} onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}>
{viewMode === 'grid' ? <List size={17} /> : <LayoutGrid size={17} />}
</button>
<button className="tool-action" aria-label={t('dashboard.aria.filter')} style={{ width: 38, height: 38, borderRadius: 11 }}>
<SlidersHorizontal size={17} />
</button>
</div>
</div>
+9 -3
View File
@@ -5,6 +5,7 @@ import { useTripStore } from '../store/tripStore'
import { useCanDo } from '../store/permissionsStore'
import { useSettingsStore } from '../store/settingsStore'
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
import { MapCompassPill } from '../components/Map/MapCompassPill'
import { getCached, fetchPhoto } from '../services/photoService'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import PlacesSidebar from '../components/Planner/PlacesSidebar'
@@ -206,6 +207,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
} = useTripPlanner()
const poi = usePoiExplore()
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
if (isLoading || !splashDone) {
@@ -308,11 +310,15 @@ export default function TripPlannerPage(): React.ReactElement | null {
pois={poi.pois}
onPoiClick={openAddPlaceFromPoi}
onViewportChange={poi.onViewportChange}
onMapReady={setGlMap}
/>
{poiPillEnabled && (
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
{(poiPillEnabled || glMap) && (
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8 }}>
{poiPillEnabled && (
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
)}
{glMap && <MapCompassPill map={glMap} />}
</div>
)}
+39 -15
View File
@@ -2,7 +2,7 @@ import React from 'react'
import { adminApi } from '../../api/client'
import Modal from '../../components/shared/Modal'
import CustomSelect from '../../components/shared/CustomSelect'
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint } from 'lucide-react'
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint, Eye, EyeOff } from 'lucide-react'
import type { TranslationFn } from '../../types'
import type { useAdmin } from './useAdmin'
@@ -22,6 +22,8 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
handleCreateUser, handleSaveUser,
} = admin
const [showCreatePw, setShowCreatePw] = React.useState(false)
const [showEditPw, setShowEditPw] = React.useState(false)
return (
<>
@@ -71,13 +73,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
<input
type="password"
value={createForm.password}
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
placeholder={t('common.password')}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
/>
<div className="relative">
<input
type={showCreatePw ? 'text' : 'password'}
value={createForm.password}
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
placeholder={t('common.password')}
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
/>
<button
type="button"
onClick={() => setShowCreatePw(v => !v)}
tabIndex={-1}
aria-label="Show or hide password"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
>
{showCreatePw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
@@ -138,13 +151,24 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
<input
type="password"
value={editForm.password}
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
placeholder={t('admin.newPasswordPlaceholder')}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
/>
<div className="relative">
<input
type={showEditPw ? 'text' : 'password'}
value={editForm.password}
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
placeholder={t('admin.newPasswordPlaceholder')}
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
/>
<button
type="button"
onClick={() => setShowEditPw(v => !v)}
tabIndex={-1}
aria-label="Show or hide password"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
>
{showEditPw ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
+24 -9
View File
@@ -378,8 +378,12 @@
.trek-dash .trips.list-view { grid-template-columns: 1fr; gap: 12px; }
.trek-dash .trips.list-view .trip-card { display: grid; grid-template-columns: 520px 1fr; gap: 0; height: auto; }
.trek-dash .trips.list-view .trip-cover { border-radius: var(--r-lg) 0 0 var(--r-lg); height: 100px; aspect-ratio: unset; }
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: space-between; padding: 20px 32px; gap: 48px; }
.trek-dash .trips.list-view .trip-meta { display: flex; gap: 32px; padding: 0; border: none; }
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-end; padding: 16px 36px; gap: 28px; }
/* Date rendered as a peer of the counts, set off by a vertical divider rather than
floating alone at the far left. */
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; gap: 6px; }
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 15px; font-weight: 600; color: var(--ink); }
.trek-dash .trips.list-view .trip-meta { display: flex; gap: 28px; padding: 0 0 0 28px; border: none; border-left: 1px solid var(--line); }
.trek-dash .trip-card {
position: relative; border-radius: var(--r-xl); overflow: hidden; background: var(--glass-bg);
border: 1px solid var(--glass-border);
@@ -526,6 +530,9 @@
/* Hero — immersive cover, title only (the pass is its own card below) */
.trek-dash .hero-trip { height: 340px; margin-bottom: 16px; border-radius: var(--r-xl); }
/* No hover on touch — the lift/zoom just sticks after a tap and looks broken. */
.trek-dash .hero-trip:hover { transform: none; box-shadow: var(--sh-lg); }
.trek-dash .hero-trip:hover img.bg { transform: none; }
.trek-dash .hero-content { padding: 18px; }
/* the page already opens with the notification/profile strip, trim its top gap */
.trek-dash .page { padding-top: 4px; }
@@ -580,25 +587,33 @@
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
.trek-dash .add-trip-card { min-height: 180px; }
/* Touch devices have no hover keep the edit/copy/archive/delete actions
visible at all times instead of revealing them on hover. */
.trek-dash .trip-actions { opacity: 1; }
/* Compact list row on mobile keeps the list view distinct from the grid. The
desktop list row uses a 520px cover, which overflowed the phone width: the
cover was clipped, the body pushed off-screen, and the fixed 100px cover
height left a white strip beneath it. Use a fitting cover that stretches to
the row, and show just the title + dates (the counts live in grid view and
on the trip itself). */
.trek-dash .trips.list-view .trip-card { grid-template-columns: 42% 1fr; min-height: 92px; }
.trek-dash .trips.list-view .trip-cover { height: auto; aspect-ratio: unset; }
.trek-dash .trips.list-view .trip-cover-content { left: 14px; right: 14px; bottom: 12px; }
/* Mobile list row stacked two-row: row 1 is a slim full-width cover banner
(image + title overlay + status top-left), row 2 is just the date, centred.
The counts stay grid-view-only on mobile. */
.trek-dash .trips.list-view .trip-card { grid-template-columns: 1fr; min-height: 0; }
.trek-dash .trips.list-view .trip-cover { height: 110px; aspect-ratio: unset; border-radius: 0; }
.trek-dash .trips.list-view .trip-cover-content { left: 16px; right: 16px; bottom: 11px; }
.trek-dash .trips.list-view .trip-name {
font-size: 17px; overflow: hidden; text-overflow: ellipsis;
font-size: 18px; overflow: hidden; text-overflow: ellipsis;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: flex-start; padding: 12px 16px; }
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: flex-start; }
.trek-dash .trips.list-view .trip-body { display: flex; align-items: center; justify-content: center; padding: 10px 16px; }
.trek-dash .trips.list-view .trip-dates { margin-bottom: 0; justify-content: center; font-size: 12.5px; }
.trek-dash .trips.list-view .trip-dates .date-num { font-size: 12.5px; }
.trek-dash .trips.list-view .trip-meta { display: none; }
/* Tools — stacked full-width cards (mockup) */
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0; padding: 0; }
.trek-dash .page-sidebar { flex-direction: column; flex-wrap: nowrap; gap: 14px; margin: 0 0 40px; padding: 0; }
.trek-dash .page-sidebar .tool { flex: none; width: auto; }
}