mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
2d7cb124b3
A trip can be bound to a folder, tag or space in a self-hosted document store, and documents then move in both directions: what someone uploads in TREK appears there, and what someone files there appears in TREK. Three decisions shape the rest. The connection belongs to the TRIP, not to a person. Every other integration here is per user, and photos are shared only through an opt-in flag; neither can deliver "everyone on the trip sees the same documents", because visibility would depend on whose credentials fetched a file, and a member without their own Paperless account would see nothing. So the trip owner binds the trip once, the server talks to the provider under that one identity, and TREK's own membership decides who sees what. The provider never learns that TREK has members. When the credential holder leaves the trip the binding goes to `orphaned` rather than quietly carrying on with an ex-member's token. TREK keeps the bytes. A synced document is an ordinary trip_files row, so download, trash, offline cache, PDF export and backup keep working untouched, and a provider outage does not blank out a trip that starts tomorrow. Deletion never crosses the boundary on its own. A document that disappears upstream is recorded as missing and waits for a human, and a run that sees more than 40 percent of its known documents vanish at once refuses the whole listing rather than acting on it: an unmounted share answers exactly like a mass delete. The core reconciles by enumerating both sides, because the weakest provider sets the model. A changed-since query cannot see a deletion, Papra does not move updatedAt when a tag changes, a Paperless bulk edit leaves `modified` alone, and Synology FileStation has no change feed at all. A webhook is therefore only ever "look now", never a source of truth. Echo is caught by comparing content, not timestamps: pushed_sha256 records what TREK itself wrote, so a provider reporting TREK's own upload is not mistaken for a stranger's edit. Provider differences are answered by capability data rather than by branching on an id: stable ids where they exist (Paperless document id, oc:fileid, driveId) and a normalised path where they do not, replace-in-place where the API offers it, and a content hash from the listing only where the provider really supplies one. Admins switch providers on under Admin, Addons, Documents, mirroring how photo providers hang off Journey, including the cascade and the 409 when the parent addon is off. The per-trip setup lives in the file manager, where the person has the context that makes the folder choice obvious.
231 lines
8.2 KiB
TypeScript
231 lines
8.2 KiB
TypeScript
import { useState, useCallback, useEffect } from 'react'
|
|
import { useDropzone } from 'react-dropzone'
|
|
import { useToast } from '../shared/Toast'
|
|
import { useTranslation, translateApiError } from '../../i18n'
|
|
import { filesApi } from '../../api/client'
|
|
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
|
import { useCanDo } from '../../store/permissionsStore'
|
|
import { useTripStore } from '../../store/tripStore'
|
|
import { useAuthStore } from '../../store/authStore'
|
|
import { getAuthUrl } from '../../api/authUrl'
|
|
import { isImage, isMedia, isWalletPass } from './FileManager.helpers'
|
|
import { openFile as openFileInTab } from '../../utils/fileDownload'
|
|
|
|
export interface FileManagerProps {
|
|
files?: TripFile[]
|
|
onUpload: (fd: FormData) => Promise<any>
|
|
onDelete: (fileId: number) => Promise<void>
|
|
onUpdate?: (fileId: number, data: Partial<TripFile>) => Promise<void>
|
|
places: Place[]
|
|
days?: Day[]
|
|
assignments?: AssignmentsMap
|
|
reservations?: Reservation[]
|
|
tripId: number
|
|
allowedFileTypes?: string | null
|
|
}
|
|
|
|
/**
|
|
* File manager state: upload (dropzone + paste), star/trash/restore, the
|
|
* filter tabs, lightbox + PDF preview and the assign-to-place/reservation
|
|
* modal. Kept in one hook so FileManager renders as thin layout sections.
|
|
*/
|
|
export function useFileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
|
|
const [uploading, setUploading] = useState(false)
|
|
const [showDocSync, setShowDocSync] = useState(false)
|
|
const [filterType, setFilterType] = useState('all')
|
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null)
|
|
const [showTrash, setShowTrash] = useState(false)
|
|
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
|
|
const [loadingTrash, setLoadingTrash] = useState(false)
|
|
const toast = useToast()
|
|
const can = useCanDo()
|
|
const trip = useTripStore((s) => s.trip)
|
|
const currentUser = useAuthStore((s) => s.user)
|
|
/**
|
|
* Only the trip owner may change a sync binding: the credential it stores
|
|
* usually reaches that person's entire document archive, so letting any
|
|
* member repoint it would share a folder the owner never chose to share.
|
|
* Instance admins are included because they already override every other
|
|
* permission check in the client.
|
|
*/
|
|
const isTripOwner = !!currentUser && (
|
|
currentUser.role === 'admin' || Number(trip?.user_id) === Number(currentUser.id)
|
|
)
|
|
const { t, locale } = useTranslation()
|
|
|
|
const loadTrash = useCallback(async () => {
|
|
setLoadingTrash(true)
|
|
try {
|
|
const data = await filesApi.list(tripId, true)
|
|
setTrashFiles(data.files || [])
|
|
} catch { /* */ }
|
|
setLoadingTrash(false)
|
|
}, [tripId])
|
|
|
|
const toggleTrash = useCallback(() => {
|
|
if (!showTrash) loadTrash()
|
|
setShowTrash(v => !v)
|
|
}, [showTrash, loadTrash])
|
|
|
|
// onUpdate doubles as the "files changed" signal towards the parent; the arguments carry no payload.
|
|
const refreshFiles = useCallback(async () => {
|
|
if (onUpdate) onUpdate(0, {} as any)
|
|
}, [onUpdate])
|
|
|
|
const handleStar = async (fileId: number) => {
|
|
try {
|
|
await filesApi.toggleStar(tripId, fileId)
|
|
refreshFiles()
|
|
} catch { /* */ }
|
|
}
|
|
|
|
const handleRestore = async (fileId: number) => {
|
|
try {
|
|
await filesApi.restore(tripId, fileId)
|
|
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
|
|
refreshFiles()
|
|
toast.success(t('files.toast.restored'))
|
|
} catch {
|
|
toast.error(t('files.toast.restoreError'))
|
|
}
|
|
}
|
|
|
|
const handlePermanentDelete = async (fileId: number) => {
|
|
if (!confirm(t('files.confirm.permanentDelete'))) return
|
|
try {
|
|
await filesApi.permanentDelete(tripId, fileId)
|
|
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
|
|
toast.success(t('files.toast.deleted'))
|
|
} catch {
|
|
toast.error(t('files.toast.deleteError'))
|
|
}
|
|
}
|
|
|
|
const handleEmptyTrash = async () => {
|
|
if (!confirm(t('files.confirm.emptyTrash'))) return
|
|
try {
|
|
await filesApi.emptyTrash(tripId)
|
|
setTrashFiles([])
|
|
toast.success(t('files.toast.trashEmptied') || 'Trash emptied')
|
|
} catch {
|
|
toast.error(t('files.toast.deleteError'))
|
|
}
|
|
}
|
|
|
|
const [previewFile, setPreviewFile] = useState(null)
|
|
const [previewFileUrl, setPreviewFileUrl] = useState('')
|
|
const [assignFileId, setAssignFileId] = useState<number | null>(null)
|
|
|
|
const onDrop = useCallback(async (acceptedFiles) => {
|
|
if (acceptedFiles.length === 0) return
|
|
setUploading(true)
|
|
const uploadedIds: number[] = []
|
|
try {
|
|
for (const file of acceptedFiles) {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
const result = await onUpload(formData)
|
|
const fileObj = result?.file || result
|
|
if (fileObj?.id) uploadedIds.push(fileObj.id)
|
|
}
|
|
toast.success(t('files.uploaded', { count: acceptedFiles.length }))
|
|
// Open assign modal for the last uploaded file
|
|
const lastId = uploadedIds[uploadedIds.length - 1]
|
|
if (lastId && (places.length > 0 || reservations.length > 0)) {
|
|
setAssignFileId(lastId)
|
|
}
|
|
} catch (err) {
|
|
toast.error(translateApiError(t, err, 'files.uploadError'))
|
|
} finally {
|
|
setUploading(false)
|
|
}
|
|
}, [onUpload, toast, t, places, reservations])
|
|
|
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
onDrop,
|
|
maxSize: 50 * 1024 * 1024,
|
|
noClick: false,
|
|
})
|
|
|
|
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
|
if (!can('file_upload', trip)) return
|
|
const items = e.clipboardData?.items
|
|
if (!items) return
|
|
const pastedFiles: File[] = []
|
|
for (const item of Array.from(items)) {
|
|
if (item.kind === 'file') {
|
|
const file = item.getAsFile()
|
|
if (file) pastedFiles.push(file)
|
|
}
|
|
}
|
|
if (pastedFiles.length > 0) {
|
|
e.preventDefault()
|
|
onDrop(pastedFiles)
|
|
}
|
|
}, [onDrop])
|
|
|
|
const filteredFiles = files.filter(f => {
|
|
if (filterType === 'starred') return !!f.starred
|
|
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
|
|
if (filterType === 'image') return isImage(f.mime_type)
|
|
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
|
|
if (filterType === 'collab') return !!f.note_id
|
|
return true
|
|
})
|
|
|
|
const handleDelete = async (id) => {
|
|
try {
|
|
await onDelete(id)
|
|
toast.success(t('files.toast.trashed') || 'Moved to trash')
|
|
} catch {
|
|
toast.error(t('files.toast.deleteError'))
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (previewFile) {
|
|
getAuthUrl(previewFile.url, 'download').then(setPreviewFileUrl)
|
|
} else {
|
|
setPreviewFileUrl('')
|
|
}
|
|
}, [previewFile?.url])
|
|
|
|
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
|
|
try {
|
|
await filesApi.update(tripId, fileId, data)
|
|
refreshFiles()
|
|
} catch {
|
|
toast.error(t('files.toast.assignError'))
|
|
}
|
|
}
|
|
|
|
// Image OR video — both open in the lightbox; videos play there (#823).
|
|
const mediaFiles = filteredFiles.filter(f => isMedia(f.mime_type))
|
|
|
|
const openFile = (file) => {
|
|
if (isMedia(file.mime_type)) {
|
|
const idx = mediaFiles.findIndex(f => f.id === file.id)
|
|
setLightboxIndex(idx >= 0 ? idx : 0)
|
|
} else if (isWalletPass(file.mime_type, file.original_name)) {
|
|
// Download so the OS hands the pass to Apple Wallet (#1447) rather than
|
|
// forcing it into the in-app PDF preview.
|
|
openFileInTab(file.url, file.original_name).catch(() => {})
|
|
} else {
|
|
setPreviewFile(file)
|
|
}
|
|
}
|
|
|
|
return {
|
|
files, places, days, assignments, reservations, tripId, allowedFileTypes,
|
|
uploading, filterType, setFilterType, lightboxIndex, setLightboxIndex,
|
|
showTrash, trashFiles, loadingTrash, toast, can, trip, t, locale,
|
|
toggleTrash, refreshFiles, handleStar, handleRestore, handlePermanentDelete, handleEmptyTrash,
|
|
previewFile, setPreviewFile, previewFileUrl, assignFileId, setAssignFileId,
|
|
getRootProps, getInputProps, isDragActive, handlePaste, filteredFiles, handleDelete,
|
|
handleAssign, mediaFiles, openFile,
|
|
showDocSync, setShowDocSync, isTripOwner,
|
|
}
|
|
}
|
|
|
|
export type FileManagerState = ReturnType<typeof useFileManager>
|