mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Fix a batch of reported bugs (#1145)
* fix(maps): fall back to OSM/Wikipedia for place photos and normalize non-standard language codes (#1137) * fix(auth): refuse password reset for OIDC/SSO-linked accounts (#1129) * fix(docker): ship server/assets (airports + atlas geo) in the runtime image (#1133, #1119) * fix(unraid): point the template at a PNG icon Unraid can render (#1073) * fix(offline): serve cached file blobs when offline or on network failure (#1046, #1069) * fix(map): centre the selected pin in the visible map area above the bottom panel (#1125) * fix(pdf): render persisted place-photo proxy URLs as images (#1130) * fix(planner): show the selected place category in the edit form (#1134) * fix(dashboard): collapse list-view trip cards to a compact row on mobile (#1132)
This commit is contained in:
@@ -131,10 +131,21 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||
// Pan to the selected place without changing zoom
|
||||
// Pan to the selected place without changing zoom. Offset the centre by the
|
||||
// side-panel + bottom-inspector padding so the pin lands in the middle of the
|
||||
// *visible* map area rather than the geometric centre (where the bottom panel
|
||||
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
|
||||
const selected = places.find(p => p.id === selectedPlaceId)
|
||||
if (selected?.lat && selected?.lng) {
|
||||
map.panTo([selected.lat, selected.lng], { animate: true })
|
||||
if (selected?.lat != null && selected?.lng != null) {
|
||||
const latlng: [number, number] = [selected.lat, selected.lng]
|
||||
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
|
||||
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
|
||||
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
|
||||
const point = map.project(latlng).add([(br[0] - tl[0]) / 2, (br[1] - tl[1]) / 2])
|
||||
map.panTo(map.unproject(point), { animate: true })
|
||||
} else {
|
||||
map.panTo(latlng, { animate: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
prev.current = selectedPlaceId
|
||||
|
||||
@@ -553,6 +553,10 @@ export function MapViewGL({
|
||||
zoom: Math.max(map.getZoom(), 14),
|
||||
pitch: mapbox3d ? 45 : 0,
|
||||
duration: 400,
|
||||
// Account for the side panels and the bottom inspector / day-detail panel
|
||||
// so the selected pin lands in the centre of the *visible* map area rather
|
||||
// than the geometric centre (where the bottom panel would cover it).
|
||||
padding: paddingOpts,
|
||||
})
|
||||
} catch { /* noop */ }
|
||||
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -259,6 +259,23 @@ describe('downloadTripPDF', () => {
|
||||
expect(iframe!.srcdoc).toContain('colosseum.jpg')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-018b: renders a persisted place-photo proxy image_url as an <img>, not the category icon (#1130)', async () => {
|
||||
const args = {
|
||||
...richArgs,
|
||||
assignments: {
|
||||
'10': [{
|
||||
...assignmentForDay,
|
||||
place: { ...placeWithDetails, image_url: '/api/maps/place-photo/ChIJabc/bytes' },
|
||||
}],
|
||||
} as any,
|
||||
}
|
||||
await downloadTripPDF(args)
|
||||
const iframe = getIframe()
|
||||
// The proxy path (no file extension) must still embed as an absolute <img>.
|
||||
expect(iframe!.srcdoc).toContain('http://localhost:3000/api/maps/place-photo/ChIJabc/bytes')
|
||||
expect(iframe!.srcdoc).toContain('class="place-thumb"')
|
||||
})
|
||||
|
||||
it('FE-COMP-TRIPPDF-019: fetches google place photos for places with google_place_id', async () => {
|
||||
let photoCalled = false
|
||||
server.use(
|
||||
|
||||
@@ -55,6 +55,10 @@ function absUrl(url) {
|
||||
function safeImg(url) {
|
||||
if (!url) return null
|
||||
if (url.startsWith('https://') || url.startsWith('http://')) return url
|
||||
// The in-app place-photo proxy always streams a JPEG but has no file extension
|
||||
// (it ends in …/bytes), so the extension check below would wrongly reject it —
|
||||
// which is why persisted place photos showed as category icons in the PDF.
|
||||
if (url.startsWith('/api/maps/place-photo/')) return absUrl(url)
|
||||
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
|
||||
}
|
||||
|
||||
@@ -254,9 +258,10 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const color = cat?.color || '#6366f1'
|
||||
|
||||
// Image: direct > google photo > fallback icon
|
||||
// Image: direct > google photo > fallback icon. Both go through safeImg
|
||||
// so the proxy path is resolved to an absolute URL the PDF can load.
|
||||
const directImg = safeImg(place.image_url)
|
||||
const googleImg = photoMap[place.id] || null
|
||||
const googleImg = safeImg(photoMap[place.id])
|
||||
const img = directImg || googleImg
|
||||
|
||||
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
|
||||
|
||||
@@ -270,6 +270,18 @@ describe('PlaceFormModal', () => {
|
||||
expect(screen.getByText(/No category/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-023b: editing a place shows its assigned category, not the placeholder (#1134)', () => {
|
||||
// Regression: form.category_id is a string but the option values were numbers,
|
||||
// so CustomSelect's strict-equality match failed and the trigger fell back to
|
||||
// "No category". With string option values the chosen category renders.
|
||||
const cat = buildCategory({ name: 'Museums' });
|
||||
const place = buildPlace({ name: 'Louvre', category_id: cat.id });
|
||||
render(<PlaceFormModal {...defaultProps} place={place} categories={[cat]} />);
|
||||
// Dropdown is closed, so the only place the category name can appear is the trigger.
|
||||
expect(screen.getByText('Museums')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/No category/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-PLACEFORM-024: onCategoryCreated is called when creating a category', async () => {
|
||||
const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
|
||||
// Directly invoke handleCreateCategory by setting showNewCategory via the category name input
|
||||
|
||||
@@ -636,7 +636,10 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
||||
options={[
|
||||
{ value: '', label: t('places.noCategory') },
|
||||
...(categories || []).map(c => ({
|
||||
value: c.id,
|
||||
// form.category_id is a string; CustomSelect matches options by
|
||||
// strict equality, so the option value must be a string too —
|
||||
// otherwise the chosen category never renders in the trigger.
|
||||
value: String(c.id),
|
||||
label: c.name,
|
||||
})),
|
||||
]}
|
||||
|
||||
@@ -148,6 +148,24 @@ export async function upsertSyncMeta(meta: SyncMeta): Promise<void> {
|
||||
await offlineDb.syncMeta.put(meta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a pre-downloaded file blob for offline use. Returns null when the file
|
||||
* was never cached (or on any read error). The stored MIME is reapplied so the
|
||||
* caller's inline-vs-download decision stays correct even if the persisted Blob
|
||||
* lost its type.
|
||||
*/
|
||||
export async function getCachedBlob(url: string): Promise<Blob | null> {
|
||||
try {
|
||||
const entry = await offlineDb.blobCache.get(url);
|
||||
if (!entry) return null;
|
||||
return entry.blob.type
|
||||
? entry.blob
|
||||
: new Blob([entry.blob], { type: entry.mime || 'application/octet-stream' });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
||||
|
||||
/** Delete all cached data for one trip (eviction or explicit clear). */
|
||||
|
||||
@@ -580,6 +580,23 @@
|
||||
.trek-dash .trips { grid-template-columns: 1fr; gap: 16px; margin-bottom: 28px; }
|
||||
.trek-dash .add-trip-card { min-height: 180px; }
|
||||
|
||||
/* 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; }
|
||||
.trek-dash .trips.list-view .trip-name {
|
||||
font-size: 17px; 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-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 .tool { flex: none; width: auto; }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getCachedBlob } from '../db/offlineDb'
|
||||
|
||||
// MIME types safe to open inline (will not execute script in any browser).
|
||||
// Everything else (text/html, image/svg+xml, text/javascript, …) is forced to
|
||||
// download so a maliciously-named upload cannot run code in the TREK origin.
|
||||
@@ -39,17 +41,46 @@ function isIosStandalone(): boolean {
|
||||
return (navigator as any).standalone === true
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a protected file to a Blob, preferring the live server but falling
|
||||
* back to the offline cache (pre-downloaded by the trip sync manager). This is
|
||||
* what lets attachments open in a PWA / airplane mode. When offline we go
|
||||
* straight to the cache; when online we fetch live and only fall back if the
|
||||
* network actually fails — which also covers flaky links where navigator.onLine
|
||||
* still reports true ("sometimes it works, sometimes it doesn't").
|
||||
*/
|
||||
async function getFileBlob(url: string): Promise<Blob> {
|
||||
assertRelativeUrl(url)
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
const cached = await getCachedBlob(url)
|
||||
if (cached) return cached
|
||||
throw new Error('File not available offline')
|
||||
}
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await fetch(url, { credentials: 'include' })
|
||||
} catch (err) {
|
||||
// Genuine network failure — the fetch itself rejected (offline, or a flaky
|
||||
// link even though navigator.onLine is true). Serve the pre-downloaded copy.
|
||||
const cached = await getCachedBlob(url)
|
||||
if (cached) return cached
|
||||
throw err
|
||||
}
|
||||
// The server answered: a non-ok status (401/403/404/…) is a real error and must
|
||||
// surface, not be masked by a stale cached copy.
|
||||
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
|
||||
return await resp.blob()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a protected file using cookie auth (credentials: include) and
|
||||
* triggers a browser download. Works inside PWA standalone mode because the
|
||||
* fetch stays in the PWA's WebView rather than handing off to the system
|
||||
* browser (which would lose the session cookie).
|
||||
* browser (which would lose the session cookie). Falls back to the offline
|
||||
* cache when the network is unavailable.
|
||||
*/
|
||||
export async function downloadFile(url: string, filename?: string): Promise<void> {
|
||||
assertRelativeUrl(url)
|
||||
const resp = await fetch(url, { credentials: 'include' })
|
||||
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
|
||||
const blob = await resp.blob()
|
||||
const blob = await getFileBlob(url)
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
triggerAnchorDownload(blobUrl, filename)
|
||||
}
|
||||
@@ -72,10 +103,7 @@ export async function downloadFile(url: string, filename?: string): Promise<void
|
||||
* spurious in-page download is triggered.
|
||||
*/
|
||||
export async function openFile(url: string, filename?: string): Promise<void> {
|
||||
assertRelativeUrl(url)
|
||||
const resp = await fetch(url, { credentials: 'include' })
|
||||
if (!resp.ok) throw new Error(resp.status === 401 ? 'Unauthorized' : `HTTP ${resp.status}`)
|
||||
const blob = await resp.blob()
|
||||
const blob = await getFileBlob(url)
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
// Force download for MIME types that can execute script when rendered inline
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { downloadFile, openFile } from '../../../src/utils/fileDownload'
|
||||
import { getCachedBlob } from '../../../src/db/offlineDb'
|
||||
|
||||
// Mock the offline DB so these tests never touch Dexie/IndexedDB.
|
||||
vi.mock('../../../src/db/offlineDb', () => ({ getCachedBlob: vi.fn() }))
|
||||
|
||||
function makeFetchMock(status: number, blob: Blob = new Blob(['data'], { type: 'application/pdf' })) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
@@ -170,3 +174,52 @@ describe('openFile', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('offline fallback (#1046)', () => {
|
||||
function setOnline(value: boolean) {
|
||||
Object.defineProperty(navigator, 'onLine', { value, configurable: true })
|
||||
}
|
||||
beforeEach(() => vi.mocked(getCachedBlob).mockReset())
|
||||
afterEach(() => setOnline(true))
|
||||
|
||||
it('serves the cached blob without a network call when offline', async () => {
|
||||
setOnline(false)
|
||||
const blob = new Blob(['x'], { type: 'application/pdf' })
|
||||
vi.mocked(getCachedBlob).mockResolvedValue(blob)
|
||||
const fetchSpy = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await downloadFile('/uploads/files/cached.pdf')
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
expect(getCachedBlob).toHaveBeenCalledWith('/uploads/files/cached.pdf')
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(blob)
|
||||
})
|
||||
|
||||
it('falls back to the cache when a live fetch rejects (network error) while online', async () => {
|
||||
setOnline(true)
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
|
||||
const blob = new Blob(['x'], { type: 'application/pdf' })
|
||||
vi.mocked(getCachedBlob).mockResolvedValue(blob)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await downloadFile('/uploads/files/cached.pdf')
|
||||
|
||||
expect(getCachedBlob).toHaveBeenCalledWith('/uploads/files/cached.pdf')
|
||||
expect(URL.createObjectURL).toHaveBeenCalledWith(blob)
|
||||
})
|
||||
|
||||
it('throws when offline and the file was never cached', async () => {
|
||||
setOnline(false)
|
||||
vi.mocked(getCachedBlob).mockResolvedValue(null)
|
||||
await expect(downloadFile('/uploads/files/missing.pdf')).rejects.toThrow(/offline/i)
|
||||
})
|
||||
|
||||
it('does not consult the cache on an HTTP error — a 401 still surfaces', async () => {
|
||||
setOnline(true)
|
||||
vi.stubGlobal('fetch', makeFetchMock(401))
|
||||
await expect(downloadFile('/uploads/files/secret.pdf')).rejects.toThrow('Unauthorized')
|
||||
expect(getCachedBlob).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user