mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 08:11:03 +00:00
0b7cee0b0d
On an iPad or an unfolded foldable the list screens kept one phone-wide column in the middle of the page. They now lay out for the room they have: - Transports, bookings, polls and tasks put their cards in a grid of as many phone-wide columns as the page holds (CardGrid), three on a 13" iPad on its side, two upright, one in a narrow Split View. - Costs: with room for two panes (split size class) the summary (total, owe and owed, settle up, final budget, categories) stands in a pane of its own beside the expenses, each scrolling on its own (TabPanes). The search field sits in the expenses' column again instead of running past it. - Files: a grid of tiles with each photo's picture; with room for two panes the picked file is previewed beside the grid with what it belongs to and its actions, the way Files on an iPad does. The signed preview address comes through fileRepo.previewUrl. - Packing: the categories stand in columns, each card whole in its column; the action menu drops from its button at a popover's width. - To-dos: the four smart filters as tiles and the trip's lists in a pane beside the tasks, which take the filter's name as their head. - Collab: the chat stays in view at the trailing edge beside the notes, links or polls, and the header switches only the board. The chat keeps its place in the tree, so a fold or a rotation into two panes keeps the draft; a chat at its newest message stays there when its box changes size. Notes stand as a board in columns. - Administration and settings share a section list beside the page (MSectionNav); the stats grid takes four columns. - Collections: the lists stand in a column beside the places, which take two columns, and the head names the list on show. - Journal: the entries become a feed beside the map, under their days, with photo, place and the start of the story, in step with the map like the strip of cards it replaces; it fades out under the header. - The assistant opens as a tall panel at the trailing edge over a dimmed page, as Slide Over would, instead of covering the whole screen. - The rail fits every name: a word too wide for it shrinks the label before it is hyphenated, and the admin entry uses its short name. - The journeys and notifications headers keep to their column. - The address screen of the native shell sets the photo as a panel beside the choices on a large screen on its side. On a foldable that reports its fold, a leading pane ends at the fold and a trailing one starts there. Phones keep their layout.
213 lines
8.4 KiB
TypeScript
213 lines
8.4 KiB
TypeScript
import { filesApi } from '../api/client'
|
|
import { getAuthUrl } from '../api/authUrl'
|
|
import { offlineDb, replaceTripRows } from '../db/offlineDb'
|
|
import { mutationQueue, generateUUID } from '../sync/mutationQueue'
|
|
import { isEffectivelyOffline } from '../sync/networkMode'
|
|
import { onlineThenCache } from './withOfflineFallback'
|
|
import type { TripFile } from '../types'
|
|
import type { FileLink, FileLinkRequest, FileUpdateRequest } from '@trek/shared'
|
|
|
|
/**
|
|
* What the file writes that need the server throw offline: an upload, and the
|
|
* removal of a further link (see removeLinkTo). The messages are translation
|
|
* keys, so a view that shows the failure through translateApiError says why.
|
|
*/
|
|
export const NEEDS_CONNECTION = {
|
|
uploadKey: 'files.uploadNeedsConnection',
|
|
linkKey: 'files.linkNeedsConnection',
|
|
} as const
|
|
|
|
/**
|
|
* A file the server answered with. The single-file routes answer the stored row
|
|
* without the ids of its further links, so the ones the local copy has stay.
|
|
*/
|
|
async function keepLinks(file: TripFile | undefined): Promise<void> {
|
|
if (!file || typeof file.id !== 'number') return
|
|
const local = await offlineDb.tripFiles.get(file.id)
|
|
await offlineDb.tripFiles.put({ ...local, ...file })
|
|
}
|
|
|
|
/** Queue a write to one file, held behind any older write to it. */
|
|
async function enqueueFileWrite(
|
|
tripId: number | string,
|
|
id: number,
|
|
method: 'POST' | 'PUT' | 'PATCH' | 'DELETE',
|
|
path: string,
|
|
body: unknown,
|
|
): Promise<void> {
|
|
await mutationQueue.enqueue({
|
|
id: generateUUID(),
|
|
tripId: Number(tripId),
|
|
method,
|
|
url: `/trips/${tripId}/files/${id}${path}`,
|
|
body,
|
|
resource: 'tripFiles',
|
|
entityId: id,
|
|
})
|
|
mutationQueue.sendSoon()
|
|
}
|
|
|
|
/** The local copy of a file with some fields changed, kept in Dexie. */
|
|
async function patchLocal(tripId: number | string, id: number, change: (file: TripFile) => Partial<TripFile>): Promise<TripFile> {
|
|
const before = await offlineDb.tripFiles.get(id)
|
|
const file = { ...(before ?? {}), ...(before ? change(before) : {}), id, trip_id: Number(tripId) } as TripFile
|
|
await offlineDb.tripFiles.put(file)
|
|
return file
|
|
}
|
|
|
|
function asId(value: unknown): number | null {
|
|
if (value === null || value === undefined || value === '') return null
|
|
const id = Number(value)
|
|
return Number.isFinite(id) ? id : null
|
|
}
|
|
|
|
/**
|
|
* Files through the offline core. Reads fall back to the Dexie copy; a change to
|
|
* a file made offline (its note, its place or booking, the star, a link, the
|
|
* trash) lands there and in the mutation queue and replays once the connection
|
|
* is back. File writes carry no version token, so an online write to a file that
|
|
* still has an older one queued joins the queue behind it (mustQueue). An upload
|
|
* cannot wait in the queue, so offline it is refused with a hint.
|
|
*/
|
|
export const fileRepo = {
|
|
async list(tripId: number | string): Promise<{ files: TripFile[] }> {
|
|
return onlineThenCache(
|
|
async () => {
|
|
const result = await filesApi.list(tripId)
|
|
void replaceTripRows('tripFiles', Number(tripId), result.files)
|
|
return result
|
|
},
|
|
async () => ({
|
|
files: await offlineDb.tripFiles
|
|
.where('trip_id').equals(Number(tripId)).toArray(),
|
|
}),
|
|
)
|
|
},
|
|
|
|
/** The trip's trashed files. Only the server has them. */
|
|
async listTrash(tripId: number | string): Promise<{ files?: TripFile[] }> {
|
|
return filesApi.list(tripId, true)
|
|
},
|
|
|
|
async upload(tripId: number | string, formData: FormData): Promise<{ file: TripFile }> {
|
|
if (isEffectivelyOffline()) throw new Error(NEEDS_CONNECTION.uploadKey)
|
|
const result: { file: TripFile } = await filesApi.upload(tripId, formData)
|
|
await offlineDb.tripFiles.put(result.file)
|
|
return result
|
|
},
|
|
|
|
async update(tripId: number | string, id: number, data: FileUpdateRequest): Promise<{ file: TripFile }> {
|
|
if (!(await mutationQueue.mustQueue('tripFiles', id))) {
|
|
const result: { file: TripFile } = await filesApi.update(tripId, id, data)
|
|
await keepLinks(result.file)
|
|
return result
|
|
}
|
|
const file = await patchLocal(tripId, id, () => ({
|
|
...(data.description === undefined ? {} : { description: data.description || null }),
|
|
...(data.place_id === undefined ? {} : { place_id: asId(data.place_id) }),
|
|
...(data.reservation_id === undefined ? {} : { reservation_id: asId(data.reservation_id) }),
|
|
}))
|
|
await enqueueFileWrite(tripId, id, 'PUT', '', data)
|
|
return { file }
|
|
},
|
|
|
|
async toggleStar(tripId: number | string, id: number): Promise<{ file: TripFile }> {
|
|
if (!(await mutationQueue.mustQueue('tripFiles', id))) {
|
|
const result: { file: TripFile } = await filesApi.toggleStar(tripId, id)
|
|
await keepLinks(result.file)
|
|
return result
|
|
}
|
|
// The server flips the star, so each queued toggle flips it once more.
|
|
const file = await patchLocal(tripId, id, before => ({ starred: before.starred ? 0 : 1 }))
|
|
await enqueueFileWrite(tripId, id, 'PATCH', '/star', undefined)
|
|
return { file }
|
|
},
|
|
|
|
/** Moves a file to the trash. */
|
|
async delete(tripId: number | string, id: number): Promise<void> {
|
|
if (!(await mutationQueue.mustQueue('tripFiles', id))) {
|
|
await filesApi.delete(tripId, id)
|
|
await offlineDb.tripFiles.delete(id)
|
|
return
|
|
}
|
|
await offlineDb.tripFiles.delete(id)
|
|
await enqueueFileWrite(tripId, id, 'DELETE', '', undefined)
|
|
},
|
|
|
|
/**
|
|
* Brings a trashed file back. Offline the trash row the view shows, when it
|
|
* has one, is what the list gets until the server answers.
|
|
*/
|
|
async restore(tripId: number | string, id: number, trashed?: TripFile): Promise<void> {
|
|
if (!(await mutationQueue.mustQueue('tripFiles', id))) {
|
|
const result: { file: TripFile } = await filesApi.restore(tripId, id)
|
|
await keepLinks(result.file)
|
|
return
|
|
}
|
|
if (trashed) await offlineDb.tripFiles.put({ ...trashed, deleted_at: null })
|
|
await enqueueFileWrite(tripId, id, 'POST', '/restore', undefined)
|
|
},
|
|
|
|
async permanentDelete(tripId: number | string, id: number): Promise<void> {
|
|
if (!(await mutationQueue.mustQueue('tripFiles', id))) {
|
|
await filesApi.permanentDelete(tripId, id)
|
|
return
|
|
}
|
|
await enqueueFileWrite(tripId, id, 'DELETE', '/permanent', undefined)
|
|
},
|
|
|
|
/** Empties the trash. Offline it waits in the queue behind the files moved there before. */
|
|
async emptyTrash(tripId: number | string): Promise<void> {
|
|
if (!isEffectivelyOffline()) {
|
|
await filesApi.emptyTrash(tripId)
|
|
return
|
|
}
|
|
await mutationQueue.enqueue({
|
|
id: generateUUID(),
|
|
tripId: Number(tripId),
|
|
method: 'DELETE',
|
|
url: `/trips/${tripId}/files/trash/empty`,
|
|
body: undefined,
|
|
})
|
|
},
|
|
|
|
/** Links a file to one more place or booking, beside the one in its own column. */
|
|
async addLink(tripId: number | string, id: number, link: FileLinkRequest): Promise<void> {
|
|
if (!(await mutationQueue.mustQueue('tripFiles', id))) {
|
|
await filesApi.addLink(tripId, id, link)
|
|
return
|
|
}
|
|
const placeId = asId(link.place_id)
|
|
const reservationId = asId(link.reservation_id)
|
|
await patchLocal(tripId, id, before => ({
|
|
...(placeId === null ? {} : { linked_place_ids: [...(before.linked_place_ids || []), placeId] }),
|
|
...(reservationId === null ? {} : { linked_reservation_ids: [...(before.linked_reservation_ids || []), reservationId] }),
|
|
}))
|
|
await enqueueFileWrite(tripId, id, 'POST', '/link', link)
|
|
},
|
|
|
|
/**
|
|
* Removes the further link `matches` picks. The route names the link row by its
|
|
* own id, which only the server knows, so this needs a connection; online it
|
|
* first lets the older queued writes to the file through, a link added offline
|
|
* among them.
|
|
*/
|
|
async removeLinkTo(tripId: number | string, id: number, matches: (link: FileLink) => boolean): Promise<void> {
|
|
if (isEffectivelyOffline()) throw new Error(NEEDS_CONNECTION.linkKey)
|
|
if (await mutationQueue.mustQueue('tripFiles', id)) await mutationQueue.flush()
|
|
const { links = [] } = (await filesApi.getLinks(tripId, id)) as { links?: FileLink[] }
|
|
const link = links.find(matches)
|
|
if (link) await filesApi.removeLink(tripId, id, link.id)
|
|
},
|
|
|
|
/**
|
|
* A file's address signed for showing it in place (an image tile, a document
|
|
* preview), good for one download. Online only: offline it answers the plain
|
|
* address, which the service worker serves from its cache when it has the file.
|
|
*/
|
|
async previewUrl(url: string): Promise<string> {
|
|
if (isEffectivelyOffline()) return url
|
|
return getAuthUrl(url, 'download')
|
|
},
|
|
}
|