diff --git a/apps/infoalloggi/TODO b/apps/infoalloggi/TODO index 2ad19f7..1f5ff5f 100644 --- a/apps/infoalloggi/TODO +++ b/apps/infoalloggi/TODO @@ -13,6 +13,7 @@ TODOS: - Modale conferma mostrare scorciatoia a doc lavoro o simile per facilitare - Post modale conferma, mostrare se transitorio , modale con informativa tipo:" il saldo del servizio e il periodo di permanenza veranno adeguati alle condizioni stabilite insieme al locatore nel momento della trattativa e alla documentazione presentata come giustificazione di transitorietร " (modificato) +- border radius immagini carousel annuncio NEW IDEA TODOS: diff --git a/apps/infoalloggi/src/components/allegato-iframe.tsx b/apps/infoalloggi/src/components/allegato-iframe.tsx index 6a1f06d..24a5d25 100644 --- a/apps/infoalloggi/src/components/allegato-iframe.tsx +++ b/apps/infoalloggi/src/components/allegato-iframe.tsx @@ -2,8 +2,7 @@ import dynamic from "next/dynamic"; import { useEffect, useRef, useState } from "react"; import { LoadingPage } from "~/components/loading"; import { cn } from "~/lib/utils"; -import type { TempTokensToken } from "~/schemas/public/TempTokens"; -import { api } from "~/utils/api"; +import type { FileMetadata } from "~/server/services/storage.service"; const PDFViewer = dynamic( () => import("./pdf-viewer").then((mod) => mod.PDFViewer), @@ -17,11 +16,10 @@ export const AllegatoIframe = ({ allegato, }: { className?: string; - allegato: string; + allegato: FileMetadata; }) => { const if_ref = useRef(null); - const { mutateAsync: getToken } = api.storage.getStorageToken.useMutation(); - const [token, setToken] = useState(null); + const [isMobile, setIsMobile] = useState(false); const [isIOS, setIsIOS] = useState(false); const [isPDF, setIsPDF] = useState(false); @@ -37,7 +35,7 @@ export const AllegatoIframe = ({ setIsIOS(/iphone|ipad|ipod/i.test(userAgent)); // Try to detect if file is PDF from extension - setIsPDF(allegato.toLowerCase().endsWith(".pdf")); + setIsPDF(allegato.mimeType === "application/pdf"); }, [allegato]); const handleIframeLoad = () => { @@ -52,17 +50,7 @@ export const AllegatoIframe = ({ } }; - useEffect(() => { - async function set() { - const token = await getToken(); - setToken(token); - } - set().catch((e) => console.error(e)); - }, []); - - if (!token) return ; - - const requestUrl = `/go-api/storage/get/${allegato}?token=${String(token)}`; + const requestUrl = `/storage-api/get/${allegato.id}?mode=inline`; // For PDF on iOS, provide download link instead if (isPDF && (isIOS || isMobile)) { return ( diff --git a/apps/infoalloggi/src/components/area-riservata/allegati.tsx b/apps/infoalloggi/src/components/area-riservata/allegati.tsx index 46939a6..ce81201 100644 --- a/apps/infoalloggi/src/components/area-riservata/allegati.tsx +++ b/apps/infoalloggi/src/components/area-riservata/allegati.tsx @@ -36,14 +36,11 @@ import { } from "~/components/ui/dropdown-menu"; import { Separator } from "~/components/ui/separator"; import { UploadModal } from "~/components/upload_modal"; -import { handleDownload } from "~/hooks/filesHooks"; + import { cn } from "~/lib/utils"; import { useTranslation } from "~/providers/I18nProvider"; -import type { - Storageindex, - StorageindexId, -} from "~/schemas/public/Storageindex"; import type { UsersId } from "~/schemas/public/Users"; +import type { FileMetadata } from "~/server/services/storage.service"; import { api } from "~/utils/api"; export const AllegatiComp = ({ @@ -55,27 +52,27 @@ export const AllegatiComp = ({ }) => { const { t } = useTranslation(); const { data: allegati, isLoading: isLoadingAllegati } = - api.storage.getUserStorage.useQuery({ userId: userId }); + api.storage.retrieveUserFileData.useQuery({ userId }); - const { mutateAsync: getToken } = api.storage.getStorageToken.useMutation(); - - const { mutate: deleteFile } = api.storage.deleteUserStorage.useMutation({ - onMutate: () => { - const toastId = toast.loading(t.caricamento, { - icon: "๐Ÿช›", - }); - return { toastId }; + const { mutate: deleteFile } = api.storage.deleteUserStorageEntry.useMutation( + { + onMutate: () => { + const toastId = toast.loading(t.caricamento, { + icon: "๐Ÿช›", + }); + return { toastId }; + }, + onSuccess: async (_error, _variables, context) => { + await utils.storage.retrieveUserFileData.invalidate({ + userId: userId, + }); + toast.success(t.allegati.allegato_rimosso, { + icon: "๐Ÿ—‘๏ธ", + id: context?.toastId, + }); + }, }, - onSuccess: async (_error, _variables, context) => { - await utils.storage.getUserStorage.invalidate({ - userId: userId, - }); - toast.success(t.allegati.allegato_rimosso, { - icon: "๐Ÿ—‘๏ธ", - id: context?.toastId, - }); - }, - }); + ); const { mutate: renameFile } = api.storage.renameFile.useMutation({ onMutate: () => { @@ -86,7 +83,7 @@ export const AllegatiComp = ({ return { toastId }; }, onSuccess: async (_error, _variables, context) => { - await utils.storage.getUserStorage.invalidate({ + await utils.storage.retrieveUserFileData.invalidate({ userId: userId, }); toast.success(t.successo, { @@ -96,7 +93,7 @@ export const AllegatiComp = ({ }, }); const [editData, setEditData] = useState<{ - id: StorageindexId; + id: string; filename: string; } | null>(null); const [editOpen, setEditOpen] = useState(false); @@ -109,7 +106,7 @@ export const AllegatiComp = ({ return; } renameFile({ - fileId: formData.get("id") as StorageindexId, + fileId: formData.get("id") as string, name: formData.get("filename") as string, }); }; @@ -128,13 +125,7 @@ export const AllegatiComp = ({

{t.allegati.da_info}

{from_admin && ( deleteFile({ fileId: id, userId })} - download_onClick={(file) => - handleDownload({ - file, - getToken: async () => await getToken(), - }) - } + delete_onClick={(id) => deleteFile({ storageId: id, userId })} files={from_admin} isAdmin={isAdmin} selectHandler={(id, filename) => { @@ -158,13 +149,7 @@ export const AllegatiComp = ({ {not_from_admin && ( deleteFile({ fileId: id, userId })} - download_onClick={(file) => - handleDownload({ - file, - getToken: async () => await getToken(), - }) - } + delete_onClick={(id) => deleteFile({ storageId: id, userId })} files={not_from_admin} isAdmin={isAdmin} selectHandler={(id, filename) => { @@ -184,45 +169,56 @@ export const AllegatiComp = ({ }; export const ExtIcon = ({ - ext, + mimeType, className, }: { - ext: string | null | undefined; + mimeType: string; className?: string; }) => { - if (!ext) return ; - if (ext.includes("pdf")) { + const lower = mimeType?.toLowerCase() || ""; + + if (!lower) return ; + + // PDF + if (lower.includes("pdf")) { return ; } + + // Images if ( - ext.includes("image") || - ["jpg", "jpeg", "png", "gif", "svg", "webp"].some((ext) => - ext.includes(ext), - ) + lower.startsWith("image/") || + /\b(jpg|jpeg|png|gif|svg|webp)\b/.test(lower) ) { return ; } - if ( - ["html", "css", "js", "jsx", "ts", "tsx", "json", "xml"].some((ext) => - ext.includes(ext), - ) - ) { + + // Code files + if (/\b(html|css|js|jsx|ts|tsx|json|xml)\b/.test(lower)) { return ; } - if (["xls", "xlsx", "csv"].some((ext) => ext.includes(ext))) { + + // Spreadsheets + if (/\b(xls|xlsx|csv)\b/.test(lower)) { return ( ); } - if (["zip", "rar", "7z", "tar", "gz"].some((ext) => ext.includes(ext))) { + + // Archives + if (/\b(zip|rar|7z|tar|gz)\b/.test(lower)) { return ; } - if (["mp3", "wav", "ogg", "flac"].some((ext) => ext.includes(ext))) { + + // Audio + if (/\b(mp3|wav|ogg|flac)\b/.test(lower)) { return ; } - if (["mp4", "avi", "mov", "wmv", "webm"].some((ext) => ext.includes(ext))) { + + // Video + if (/\b(mp4|avi|mov|wmv|webm)\b/.test(lower)) { return ; } + return ; }; @@ -235,7 +231,7 @@ const EditFileDialog = ({ open: boolean; onOpenChange: (status: boolean) => void; data: { - id: StorageindexId; + id: string; filename: string; } | null; handleEditSubmit: (e: FormEvent) => void; @@ -282,17 +278,15 @@ const EditFileDialog = ({ }; interface FileListProps { - files: Storageindex[]; - delete_onClick: (id: StorageindexId) => void; - download_onClick: (file: Storageindex) => void; + files: FileMetadata[]; + delete_onClick: (id: string) => void; isAdmin: boolean; - selectHandler: (id: StorageindexId, filename: string) => void; + selectHandler: (id: string, filename: string) => void; } function FileList({ files, delete_onClick, - download_onClick, isAdmin, selectHandler, }: FileListProps) { @@ -312,7 +306,7 @@ function FileList({ >
- +
- {file.filename} + {file.originalName}
- {format(file.created_at, "d MMM yyyy")} + {format(new Date(file.uploadedAt), "d MMM yyyy")}
- - + {isAdmin && ( @@ -357,7 +344,7 @@ function FileList({ - selectHandler(file.id, file.filename || "") + selectHandler(file.id, file.originalName || "") } > diff --git a/apps/infoalloggi/src/components/chat/chat-attachments.tsx b/apps/infoalloggi/src/components/chat/chat-attachments.tsx index 048d7aa..2bdc575 100644 --- a/apps/infoalloggi/src/components/chat/chat-attachments.tsx +++ b/apps/infoalloggi/src/components/chat/chat-attachments.tsx @@ -36,7 +36,7 @@ export const ChatAttachments = ({ data: attachments, isLoading: attachmentsLoading, refetch, - } = api.storage.getUserStorage.useQuery( + } = api.storage.retrieveUserFileData.useQuery( { userId: chatUserData.id }, { refetchOnMount: true }, ); @@ -58,20 +58,20 @@ export const ChatAttachments = ({ - + Allegati Chat Attachments - + -
+
{attachmentsLoading ? ( ) : ( @@ -84,7 +84,7 @@ export const ChatAttachments = ({
) : (
    -

    +

    {t.allegati.allegati_recenti}

    {attachments?.map((file, idx) => { @@ -92,28 +92,30 @@ export const ChatAttachments = ({ return (
  • console.log("touching")} target="_blank" > - {file.ext && } - {file.filename} + - {TimeSince(file.created_at)} + {TimeSince(new Date(file.uploadedAt))}
  • ); @@ -123,8 +125,6 @@ export const ChatAttachments = ({ )}
-
- )} +
+ diff --git a/apps/infoalloggi/src/components/custom_ui/fileUpload.tsx b/apps/infoalloggi/src/components/custom_ui/fileUpload.tsx index 5387dfe..8f21d41 100644 --- a/apps/infoalloggi/src/components/custom_ui/fileUpload.tsx +++ b/apps/infoalloggi/src/components/custom_ui/fileUpload.tsx @@ -10,25 +10,7 @@ import { FileTextIcon, FileVideoIcon, } from "lucide-react"; -import { - type ChangeEvent, - type ComponentPropsWithoutRef, - createContext, - type DragEvent, - forwardRef, - type KeyboardEvent, - type MouseEvent, - type ReactNode, - type RefObject, - useCallback, - useContext, - useEffect, - useId, - useLayoutEffect, - useMemo, - useRef, - useSyncExternalStore, -} from "react"; +import * as React from "react"; import { cn } from "~/lib/utils"; const ROOT_NAME = "FileUpload"; @@ -42,44 +24,22 @@ const ITEM_PROGRESS_NAME = "FileUploadItemProgress"; const ITEM_DELETE_NAME = "FileUploadItemDelete"; const CLEAR_NAME = "FileUploadClear"; -const FILE_UPLOAD_ERRORS = { - [ROOT_NAME]: `\`${ROOT_NAME}\` must be used as root component`, - [DROPZONE_NAME]: `\`${DROPZONE_NAME}\` must be within \`${ROOT_NAME}\``, - [TRIGGER_NAME]: `\`${TRIGGER_NAME}\` must be within \`${ROOT_NAME}\``, - [LIST_NAME]: `\`${LIST_NAME}\` must be within \`${ROOT_NAME}\``, - [ITEM_NAME]: `\`${ITEM_NAME}\` must be within \`${ROOT_NAME}\``, - [ITEM_PREVIEW_NAME]: `\`${ITEM_PREVIEW_NAME}\` must be within \`${ITEM_NAME}\``, - [ITEM_METADATA_NAME]: `\`${ITEM_METADATA_NAME}\` must be within \`${ITEM_NAME}\``, - [ITEM_PROGRESS_NAME]: `\`${ITEM_PROGRESS_NAME}\` must be within \`${ITEM_NAME}\``, - [ITEM_DELETE_NAME]: `\`${ITEM_DELETE_NAME}\` must be within \`${ITEM_NAME}\``, - [CLEAR_NAME]: `\`${CLEAR_NAME}\` must be within \`${ROOT_NAME}\``, -} as const; - -const useIsomorphicLayoutEffect = - typeof window !== "undefined" ? useLayoutEffect : useEffect; - -function useAsRef(data: T) { - const ref = useRef(data); - useIsomorphicLayoutEffect(() => { - ref.current = data; - }); - return ref; -} - function useLazyRef(fn: () => T) { - const ref = useRef(null); + const ref = React.useRef(null); + if (ref.current === null) { ref.current = fn(); } - return ref as RefObject; + + return ref as React.RefObject; } type Direction = "ltr" | "rtl"; -const DirectionContext = createContext(undefined); +const DirectionContext = React.createContext(undefined); function useDirection(dirProp?: Direction): Direction { - const contextDir = useContext(DirectionContext); + const contextDir = React.useContext(DirectionContext); return dirProp ?? contextDir ?? "ltr"; } @@ -97,32 +57,31 @@ interface StoreState { } type StoreAction = - | { variant: "ADD_FILES"; files: File[] } - | { variant: "SET_FILES"; files: File[] } - | { variant: "SET_PROGRESS"; file: File; progress: number } - | { variant: "SET_SUCCESS"; file: File } - | { variant: "SET_ERROR"; file: File; error: string } - | { variant: "REMOVE_FILE"; file: File } - | { variant: "SET_DRAG_OVER"; dragOver: boolean } - | { variant: "SET_INVALID"; invalid: boolean } - | { variant: "CLEAR" }; + | { type: "ADD_FILES"; files: File[] } + | { type: "SET_FILES"; files: File[] } + | { type: "SET_PROGRESS"; file: File; progress: number } + | { type: "SET_SUCCESS"; file: File } + | { type: "SET_ERROR"; file: File; error: string } + | { type: "REMOVE_FILE"; file: File } + | { type: "SET_DRAG_OVER"; dragOver: boolean } + | { type: "SET_INVALID"; invalid: boolean } + | { type: "CLEAR" }; function createStore( listeners: Set<() => void>, files: Map, + urlCache: WeakMap, + invalid: boolean, onValueChange?: (files: File[]) => void, - invalid?: boolean, ) { - const initialState: StoreState = { - dragOver: false, + let state: StoreState = { files, - invalid: invalid ?? false, + dragOver: false, + invalid: invalid, }; - let state = initialState; - function reducer(state: StoreState, action: StoreAction): StoreState { - switch (action.variant) { + switch (action.type) { case "ADD_FILES": { for (const file of action.files) { files.set(file, { @@ -199,6 +158,14 @@ function createStore( } case "REMOVE_FILE": { + if (urlCache) { + const cachedUrl = urlCache.get(action.file); + if (cachedUrl) { + URL.revokeObjectURL(cachedUrl); + urlCache.delete(action.file); + } + } + files.delete(action.file); if (onValueChange) { @@ -219,6 +186,16 @@ function createStore( } case "CLEAR": { + if (urlCache) { + for (const file of files.keys()) { + const cachedUrl = urlCache.get(file); + if (cachedUrl) { + URL.revokeObjectURL(cachedUrl); + urlCache.delete(file); + } + } + } + files.clear(); if (onValueChange) { onValueChange([]); @@ -247,28 +224,29 @@ function createStore( return () => listeners.delete(listener); } - return { dispatch, getState, subscribe }; + return { getState, dispatch, subscribe }; } -const StoreContext = createContext | null>(null); -StoreContext.displayName = ROOT_NAME; +const StoreContext = React.createContext | null>( + null, +); -function useStoreContext(name: keyof typeof FILE_UPLOAD_ERRORS) { - const context = useContext(StoreContext); +function useStoreContext(consumerName: string) { + const context = React.useContext(StoreContext); if (!context) { - throw new Error(FILE_UPLOAD_ERRORS[name]); + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); } return context; } function useStore(selector: (state: StoreState) => T): T { - const store = useStoreContext(ROOT_NAME); + const store = useStoreContext("useStore"); const lastValueRef = useLazyRef<{ value: T; state: StoreState } | null>( () => null, ); - const getSnapshot = useCallback(() => { + const getSnapshot = React.useCallback(() => { const state = store.getState(); const prevValue = lastValueRef.current; @@ -277,11 +255,11 @@ function useStore(selector: (state: StoreState) => T): T { } const nextValue = selector(state); - lastValueRef.current = { state, value: nextValue }; + lastValueRef.current = { value: nextValue, state }; return nextValue; }, [store, selector, lastValueRef]); - return useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); + return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); } interface FileUploadContextValue { @@ -291,21 +269,24 @@ interface FileUploadContextValue { labelId: string; disabled: boolean; dir: Direction; - inputRef: RefObject; + inputRef: React.RefObject; + urlCache: WeakMap; } -const FileUploadContext = createContext(null); +const FileUploadContext = React.createContext( + null, +); -function useFileUploadContext(name: keyof typeof FILE_UPLOAD_ERRORS) { - const context = useContext(FileUploadContext); +function useFileUploadContext(consumerName: string) { + const context = React.useContext(FileUploadContext); if (!context) { - throw new Error(FILE_UPLOAD_ERRORS[name]); + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); } return context; } interface FileUploadRootProps - extends Omit, "defaultValue" | "onChange"> { + extends Omit, "defaultValue" | "onChange"> { value?: File[]; defaultValue?: File[]; onValueChange?: (files: File[]) => void; @@ -334,529 +315,597 @@ interface FileUploadRootProps required?: boolean; } -const FileUploadRoot = forwardRef( - (props, forwardedRef) => { - const { - value, - defaultValue, - onValueChange, - //onAccept, - //onFileAccept, - //onFileReject, - //onFileValidate, - //onUpload, - accept, - //maxFiles, - //maxSize, - dir: dirProp, - label, - name, - asChild, - disabled = false, - invalid = false, - multiple = false, - required = false, - children, - className, - ...rootProps - } = props; +function FileUploadRoot(props: FileUploadRootProps) { + const { + value, + defaultValue, + onValueChange, + onAccept, + onFileAccept, + onFileReject, + onFileValidate, + onUpload, + accept, + maxFiles, + maxSize, + dir: dirProp, + label, + name, + asChild, + disabled = false, + invalid = false, + multiple = false, + required = false, + children, + className, + ...rootProps + } = props; - const inputId = useId(); - const dropzoneId = useId(); - const listId = useId(); - const labelId = useId(); + const inputId = React.useId(); + const dropzoneId = React.useId(); + const listId = React.useId(); + const labelId = React.useId(); - const dir = useDirection(dirProp); - const propsRef = useAsRef(props); - const listeners = useLazyRef(() => new Set<() => void>()).current; - const files = useLazyRef>(() => new Map()).current; - const inputRef = useRef(null); - const isControlled = value !== undefined; + const dir = useDirection(dirProp); + const listeners = useLazyRef(() => new Set<() => void>()).current; + const files = useLazyRef>(() => new Map()).current; + const urlCache = useLazyRef(() => new WeakMap()).current; + const inputRef = React.useRef(null); + const isControlled = value !== undefined; - const store = useMemo( - () => createStore(listeners, files, onValueChange, invalid), - [listeners, files, onValueChange, invalid], - ); + const store = React.useMemo( + () => createStore(listeners, files, urlCache, invalid, onValueChange), + [listeners, files, invalid, onValueChange, urlCache], + ); - const contextValue = useMemo( - () => ({ - dir, - disabled, - dropzoneId, - inputId, - inputRef, - labelId, - listId, - }), - [dropzoneId, inputId, listId, labelId, dir, disabled], - ); + const acceptTypes = React.useMemo( + () => accept?.split(",").map((t) => t.trim()) ?? null, + [accept], + ); - useEffect(() => { - if (isControlled) { - store.dispatch({ files: value, variant: "SET_FILES" }); - } else if ( - defaultValue && - defaultValue.length > 0 && - !store.getState().files.size - ) { - store.dispatch({ files: defaultValue, variant: "SET_FILES" }); + const onProgress = useLazyRef(() => { + let frame = 0; + return (file: File, progress: number) => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + store.dispatch({ + type: "SET_PROGRESS", + file, + progress: Math.min(Math.max(0, progress), 100), + }); + }); + }; + }).current; + + React.useEffect(() => { + if (isControlled) { + store.dispatch({ type: "SET_FILES", files: value }); + } else if ( + defaultValue && + defaultValue.length > 0 && + !store.getState().files.size + ) { + store.dispatch({ type: "SET_FILES", files: defaultValue }); + } + }, [value, defaultValue, isControlled, store]); + + React.useEffect(() => { + return () => { + for (const file of files.keys()) { + const cachedUrl = urlCache.get(file); + if (cachedUrl) { + URL.revokeObjectURL(cachedUrl); + } } - }, [value, defaultValue, isControlled, store]); + }; + }, [files, urlCache]); - const onFilesChange = useCallback( - (originalFiles: File[]) => { - if (propsRef.current.disabled) return; + const onFilesUpload = React.useCallback( + async (files: File[]) => { + try { + for (const file of files) { + store.dispatch({ type: "SET_PROGRESS", file, progress: 0 }); + } - let filesToProcess = [...originalFiles]; - let invalid = false; + if (onUpload) { + await onUpload(files, { + onProgress, + onSuccess: (file) => { + store.dispatch({ type: "SET_SUCCESS", file }); + }, + onError: (file, error) => { + store.dispatch({ + type: "SET_ERROR", + file, + error: error.message ?? "Upload failed", + }); + }, + }); + } else { + for (const file of files) { + store.dispatch({ type: "SET_SUCCESS", file }); + } + } + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Upload failed"; + for (const file of files) { + store.dispatch({ + type: "SET_ERROR", + file, + error: errorMessage, + }); + } + } + }, + [store, onUpload, onProgress], + ); - if (propsRef.current.maxFiles) { - const currentCount = store.getState().files.size; - const remainingSlotCount = Math.max( - 0, - propsRef.current.maxFiles - currentCount, - ); + const onFilesChange = React.useCallback( + (originalFiles: File[]) => { + if (disabled) return; - if (remainingSlotCount < filesToProcess.length) { - const rejectedFiles = filesToProcess.slice(remainingSlotCount); - invalid = true; + let filesToProcess = [...originalFiles]; + let invalid = false; - filesToProcess = filesToProcess.slice(0, remainingSlotCount); + if (maxFiles) { + const currentCount = store.getState().files.size; + const remainingSlotCount = Math.max(0, maxFiles - currentCount); - for (const file of rejectedFiles) { - let rejectionMessage = `Maximum ${propsRef.current.maxFiles} files allowed`; + if (remainingSlotCount < filesToProcess.length) { + const rejectedFiles = filesToProcess.slice(remainingSlotCount); + invalid = true; - if (propsRef.current.onFileValidate) { - const validationMessage = propsRef.current.onFileValidate(file); - if (validationMessage) { - rejectionMessage = validationMessage; - } + filesToProcess = filesToProcess.slice(0, remainingSlotCount); + + for (const file of rejectedFiles) { + let rejectionMessage = `Maximum ${maxFiles} files allowed`; + + if (onFileValidate) { + const validationMessage = onFileValidate(file); + if (validationMessage) { + rejectionMessage = validationMessage; } - - propsRef.current.onFileReject?.(file, rejectionMessage); } + + onFileReject?.(file, rejectionMessage); + } + } + } + + const acceptedFiles: File[] = []; + const rejectedFiles: { file: File; message: string }[] = []; + + for (const file of filesToProcess) { + let rejected = false; + let rejectionMessage = ""; + + if (onFileValidate) { + const validationMessage = onFileValidate(file); + if (validationMessage) { + rejectionMessage = validationMessage; + onFileReject?.(file, rejectionMessage); + rejected = true; + invalid = true; + continue; } } - const acceptedFiles: File[] = []; - const rejectedFiles: { file: File; message: string }[] = []; - - for (const file of filesToProcess) { - let rejected = false; - let rejectionMessage = ""; - - if (propsRef.current.onFileValidate) { - const validationMessage = propsRef.current.onFileValidate(file); - if (validationMessage) { - rejectionMessage = validationMessage; - propsRef.current.onFileReject?.(file, rejectionMessage); - rejected = true; - invalid = true; - continue; - } - } - - if (propsRef.current.accept) { - const acceptTypes = propsRef.current.accept - .split(",") - .map((t) => t.trim()); - const fileType = file.type; - const fileExtension = `.${file.name.split(".").pop()}`; - - if ( - !acceptTypes.some( - (type) => - type === fileType || - type === fileExtension || - (type.includes("/*") && - fileType.startsWith(type.replace("/*", "/"))), - ) - ) { - rejectionMessage = "File type not accepted"; - propsRef.current.onFileReject?.(file, rejectionMessage); - rejected = true; - invalid = true; - } - } + if (acceptTypes) { + const fileType = file.type; + const fileExtension = `.${file.name.split(".").pop()}`; if ( - propsRef.current.maxSize && - file.size > propsRef.current.maxSize + !acceptTypes.some( + (type) => + type === fileType || + type === fileExtension || + (type.includes("/*") && + fileType.startsWith(type.replace("/*", "/"))), + ) ) { - rejectionMessage = "File too large"; - propsRef.current.onFileReject?.(file, rejectionMessage); + rejectionMessage = "File type not accepted"; + onFileReject?.(file, rejectionMessage); rejected = true; invalid = true; } - - if (!rejected) { - acceptedFiles.push(file); - } else { - rejectedFiles.push({ file, message: rejectionMessage }); - } } - if (invalid) { - store.dispatch({ invalid, variant: "SET_INVALID" }); - setTimeout(() => { - store.dispatch({ invalid: false, variant: "SET_INVALID" }); - }, 2000); + if (maxSize && file.size > maxSize) { + rejectionMessage = "File too large"; + onFileReject?.(file, rejectionMessage); + rejected = true; + invalid = true; } - if (acceptedFiles.length > 0) { - store.dispatch({ files: acceptedFiles, variant: "ADD_FILES" }); - - if (isControlled && propsRef.current.onValueChange) { - const currentFiles = Array.from( - store.getState().files.values(), - ).map((f) => f.file); - propsRef.current.onValueChange([...currentFiles]); - } - - if (propsRef.current.onAccept) { - propsRef.current.onAccept(acceptedFiles); - } - - for (const file of acceptedFiles) { - propsRef.current.onFileAccept?.(file); - } - - if (propsRef.current.onUpload) { - requestAnimationFrame(() => { - onFilesUpload(acceptedFiles).catch((e) => { - throw new Error((e as Error).message); - }); - }); - } + if (!rejected) { + acceptedFiles.push(file); + } else { + rejectedFiles.push({ file, message: rejectionMessage }); } - }, - [store, isControlled, propsRef], - ); + } - const onFilesUpload = useCallback( - async (files: File[]) => { - try { - for (const file of files) { - store.dispatch({ file, progress: 0, variant: "SET_PROGRESS" }); - } + if (invalid) { + store.dispatch({ type: "SET_INVALID", invalid }); + setTimeout(() => { + store.dispatch({ type: "SET_INVALID", invalid: false }); + }, 2000); + } - if (propsRef.current.onUpload) { - await propsRef.current.onUpload(files, { - onError: (file, error) => { - store.dispatch({ - error: error.message ?? "Upload failed", - file, - variant: "SET_ERROR", - }); - }, - onProgress: (file, progress) => { - store.dispatch({ - file, - progress: Math.min(Math.max(0, progress), 100), - variant: "SET_PROGRESS", - }); - }, - onSuccess: (file) => { - store.dispatch({ file, variant: "SET_SUCCESS" }); - }, - }); - } else { - for (const file of files) { - store.dispatch({ file, variant: "SET_SUCCESS" }); - } - } - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : "Upload failed"; - for (const file of files) { - store.dispatch({ - error: errorMessage, - file, - variant: "SET_ERROR", - }); - } + if (acceptedFiles.length > 0) { + store.dispatch({ type: "ADD_FILES", files: acceptedFiles }); + + if (isControlled && onValueChange) { + const currentFiles = Array.from(store.getState().files.values()).map( + (f) => f.file, + ); + onValueChange([...currentFiles]); } - }, - [store, propsRef.current.onUpload], - ); - const onInputChange = useCallback( - (event: ChangeEvent) => { - const files = Array.from(event.target.files ?? []); - onFilesChange(files); - event.target.value = ""; - }, - [onFilesChange], - ); + if (onAccept) { + onAccept(acceptedFiles); + } - const RootPrimitive = asChild ? Slot : "div"; + for (const file of acceptedFiles) { + onFileAccept?.(file); + } - return ( - - - - - {children} - - - {label ?? "File upload"} - - - - - - ); - }, -); -FileUploadRoot.displayName = ROOT_NAME; + if (onUpload) { + requestAnimationFrame(() => { + onFilesUpload(acceptedFiles); + }); + } + } + }, + [ + store, + isControlled, + onValueChange, + onAccept, + onFileAccept, + onUpload, + onFilesUpload, + maxFiles, + onFileValidate, + onFileReject, + acceptTypes, + maxSize, + disabled, + ], + ); -interface FileUploadDropzoneProps extends ComponentPropsWithoutRef<"div"> { + const onInputChange = React.useCallback( + (event: React.ChangeEvent) => { + const files = Array.from(event.target.files ?? []); + onFilesChange(files); + event.target.value = ""; + }, + [onFilesChange], + ); + + const contextValue = React.useMemo( + () => ({ + dropzoneId, + inputId, + listId, + labelId, + dir, + disabled, + inputRef, + urlCache, + }), + [dropzoneId, inputId, listId, labelId, dir, disabled, urlCache], + ); + + const RootPrimitive = asChild ? Slot : "div"; + + return ( + + + + {children} + + + {label ?? "File upload"} + + + + + ); +} + +interface FileUploadDropzoneProps extends React.ComponentProps<"div"> { asChild?: boolean; } -const FileUploadDropzone = forwardRef( - (props, forwardedRef) => { - const { asChild, className, ...dropzoneProps } = props; +function FileUploadDropzone(props: FileUploadDropzoneProps) { + const { + asChild, + className, + onClick: onClickProp, + onDragOver: onDragOverProp, + onDragEnter: onDragEnterProp, + onDragLeave: onDragLeaveProp, + onDrop: onDropProp, + onPaste: onPasteProp, + onKeyDown: onKeyDownProp, + ...dropzoneProps + } = props; - const context = useFileUploadContext(DROPZONE_NAME); - const store = useStoreContext(DROPZONE_NAME); - const dragOver = useStore((state) => state.dragOver); - const invalid = useStore((state) => state.invalid); - const propsRef = useAsRef(dropzoneProps); + const context = useFileUploadContext(DROPZONE_NAME); + const store = useStoreContext(DROPZONE_NAME); + const dragOver = useStore((state) => state.dragOver); + const invalid = useStore((state) => state.invalid); - const onClick = useCallback( - (event: MouseEvent) => { - propsRef.current?.onClick?.(event); + const onClick = React.useCallback( + (event: React.MouseEvent) => { + onClickProp?.(event); - if (event.defaultPrevented) return; + if (event.defaultPrevented) return; - const target = event.target; + const target = event.target; - const isFromTrigger = - target instanceof HTMLElement && - target.closest('[data-slot="file-upload-trigger"]'); - - if (!isFromTrigger) { - context.inputRef.current?.click(); - } - }, - [context.inputRef, propsRef], - ); - - const onDragOver = useCallback( - (event: DragEvent) => { - propsRef.current?.onDragOver?.(event); - - if (event.defaultPrevented) return; - - event.preventDefault(); - store.dispatch({ dragOver: true, variant: "SET_DRAG_OVER" }); - }, - [store, propsRef.current.onDragOver], - ); - - const onDragEnter = useCallback( - (event: DragEvent) => { - propsRef.current?.onDragEnter?.(event); - - if (event.defaultPrevented) return; - - event.preventDefault(); - store.dispatch({ dragOver: true, variant: "SET_DRAG_OVER" }); - }, - [store, propsRef.current.onDragEnter], - ); - - const onDragLeave = useCallback( - (event: DragEvent) => { - propsRef.current?.onDragLeave?.(event); - - if (event.defaultPrevented) return; - - event.preventDefault(); - store.dispatch({ dragOver: false, variant: "SET_DRAG_OVER" }); - }, - [store, propsRef.current.onDragLeave], - ); - - const onDrop = useCallback( - (event: DragEvent) => { - propsRef.current?.onDrop?.(event); - - if (event.defaultPrevented) return; - - event.preventDefault(); - store.dispatch({ dragOver: false, variant: "SET_DRAG_OVER" }); - - const files = Array.from(event.dataTransfer.files); - const inputElement = context.inputRef.current; - if (!inputElement) return; - - const dataTransfer = new DataTransfer(); - for (const file of files) { - dataTransfer.items.add(file); - } - - inputElement.files = dataTransfer.files; - inputElement.dispatchEvent(new Event("change", { bubbles: true })); - }, - [store, context.inputRef, propsRef.current.onDrop], - ); - - const onKeyDown = useCallback( - (event: KeyboardEvent) => { - propsRef.current?.onKeyDown?.(event); - - if ( - !event.defaultPrevented && - (event.key === "Enter" || event.key === " ") - ) { - event.preventDefault(); - context.inputRef.current?.click(); - } - }, - [context.inputRef, propsRef.current.onKeyDown], - ); - - const DropzonePrimitive = asChild ? Slot : "div"; - - return ( - - ); - }, -); -FileUploadDropzone.displayName = DROPZONE_NAME; - -interface FileUploadTriggerProps extends ComponentPropsWithoutRef<"button"> { - asChild?: boolean; -} - -const FileUploadTrigger = forwardRef( - (props, forwardedRef) => { - const { asChild, ...triggerProps } = props; - const context = useFileUploadContext(TRIGGER_NAME); - const propsRef = useAsRef(triggerProps); - - const onClick = useCallback( - (event: MouseEvent) => { - propsRef.current?.onClick?.(event); - - if (event.defaultPrevented) return; + const isFromTrigger = + target instanceof HTMLElement && + target.closest('[data-slot="file-upload-trigger"]'); + if (!isFromTrigger) { context.inputRef.current?.click(); - }, - [context.inputRef, propsRef.current], - ); + } + }, + [context.inputRef, onClickProp], + ); - const TriggerPrimitive = asChild ? Slot : "button"; + const onDragOver = React.useCallback( + (event: React.DragEvent) => { + onDragOverProp?.(event); - return ( - - ); - }, -); -FileUploadTrigger.displayName = TRIGGER_NAME; + if (event.defaultPrevented) return; -interface FileUploadListProps extends ComponentPropsWithoutRef<"div"> { + event.preventDefault(); + store.dispatch({ type: "SET_DRAG_OVER", dragOver: true }); + }, + [store, onDragOverProp], + ); + + const onDragEnter = React.useCallback( + (event: React.DragEvent) => { + onDragEnterProp?.(event); + + if (event.defaultPrevented) return; + + event.preventDefault(); + store.dispatch({ type: "SET_DRAG_OVER", dragOver: true }); + }, + [store, onDragEnterProp], + ); + + const onDragLeave = React.useCallback( + (event: React.DragEvent) => { + onDragLeaveProp?.(event); + + if (event.defaultPrevented) return; + + const relatedTarget = event.relatedTarget; + if ( + relatedTarget && + relatedTarget instanceof Node && + event.currentTarget.contains(relatedTarget) + ) { + return; + } + + event.preventDefault(); + store.dispatch({ type: "SET_DRAG_OVER", dragOver: false }); + }, + [store, onDragLeaveProp], + ); + + const onDrop = React.useCallback( + (event: React.DragEvent) => { + onDropProp?.(event); + + if (event.defaultPrevented) return; + + event.preventDefault(); + store.dispatch({ type: "SET_DRAG_OVER", dragOver: false }); + + const files = Array.from(event.dataTransfer.files); + const inputElement = context.inputRef.current; + if (!inputElement) return; + + const dataTransfer = new DataTransfer(); + for (const file of files) { + dataTransfer.items.add(file); + } + + inputElement.files = dataTransfer.files; + inputElement.dispatchEvent(new Event("change", { bubbles: true })); + }, + [store, context.inputRef, onDropProp], + ); + + const onPaste = React.useCallback( + (event: React.ClipboardEvent) => { + onPasteProp?.(event); + + if (event.defaultPrevented) return; + + event.preventDefault(); + store.dispatch({ type: "SET_DRAG_OVER", dragOver: false }); + + const items = event.clipboardData?.items; + if (!items) return; + + const files: File[] = []; + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item?.kind === "file") { + const file = item.getAsFile(); + if (file) { + files.push(file); + } + } + } + + if (files.length === 0) return; + + const inputElement = context.inputRef.current; + if (!inputElement) return; + + const dataTransfer = new DataTransfer(); + for (const file of files) { + dataTransfer.items.add(file); + } + + inputElement.files = dataTransfer.files; + inputElement.dispatchEvent(new Event("change", { bubbles: true })); + }, + [store, context.inputRef, onPasteProp], + ); + + const onKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + onKeyDownProp?.(event); + + if ( + !event.defaultPrevented && + (event.key === "Enter" || event.key === " ") + ) { + event.preventDefault(); + context.inputRef.current?.click(); + } + }, + [context.inputRef, onKeyDownProp], + ); + + const DropzonePrimitive = asChild ? Slot : "div"; + + return ( + + ); +} + +interface FileUploadTriggerProps extends React.ComponentProps<"button"> { + asChild?: boolean; +} + +function FileUploadTrigger(props: FileUploadTriggerProps) { + const { asChild, onClick: onClickProp, ...triggerProps } = props; + const context = useFileUploadContext(TRIGGER_NAME); + + const onClick = React.useCallback( + (event: React.MouseEvent) => { + onClickProp?.(event); + + if (event.defaultPrevented) return; + + context.inputRef.current?.click(); + }, + [context.inputRef, onClickProp], + ); + + const TriggerPrimitive = asChild ? Slot : "button"; + + return ( + + ); +} + +interface FileUploadListProps extends React.ComponentProps<"div"> { orientation?: "horizontal" | "vertical"; asChild?: boolean; forceMount?: boolean; } -const FileUploadList = forwardRef( - (props, forwardedRef) => { - const { - className, - orientation = "vertical", - asChild, - forceMount, - ...listProps - } = props; +function FileUploadList(props: FileUploadListProps) { + const { + className, + orientation = "vertical", + asChild, + forceMount, + ...listProps + } = props; - const context = useFileUploadContext(LIST_NAME); + const context = useFileUploadContext(LIST_NAME); + const fileCount = useStore((state) => state.files.size); + const shouldRender = forceMount || fileCount > 0; - const shouldRender = - forceMount || useStore((state) => state.files.size > 0); + if (!shouldRender) return null; - if (!shouldRender) return null; + const ListPrimitive = asChild ? Slot : "div"; - const ListPrimitive = asChild ? Slot : "div"; - - return ( - - ); - }, -); -FileUploadList.displayName = LIST_NAME; + return ( + + ); +} interface FileUploadItemContextValue { id: string; @@ -867,95 +916,92 @@ interface FileUploadItemContextValue { messageId: string; } -const FileUploadItemContext = createContext( - null, -); +const FileUploadItemContext = + React.createContext(null); -function useFileUploadItemContext(name: keyof typeof FILE_UPLOAD_ERRORS) { - const context = useContext(FileUploadItemContext); +function useFileUploadItemContext(consumerName: string) { + const context = React.useContext(FileUploadItemContext); if (!context) { - throw new Error(FILE_UPLOAD_ERRORS[name]); + throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``); } return context; } -interface FileUploadItemProps extends ComponentPropsWithoutRef<"div"> { +interface FileUploadItemProps extends React.ComponentProps<"div"> { value: File; asChild?: boolean; } -const FileUploadItem = forwardRef( - (props, forwardedRef) => { - const { value, asChild, className, ...itemProps } = props; +function getStatusText(fileState: FileState) { + if (fileState.error) return `Error: ${fileState.error}`; + if (fileState.status === "uploading") + return `Uploading: ${fileState.progress}% complete`; + if (fileState.status === "success") return "Upload complete"; + return "Ready to upload"; +} - const id = useId(); - const statusId = `${id}-status`; - const nameId = `${id}-name`; - const sizeId = `${id}-size`; - const messageId = `${id}-message`; +function FileUploadItem(props: FileUploadItemProps) { + const { value, asChild, className, ...itemProps } = props; - const context = useFileUploadContext(ITEM_NAME); - const fileState = useStore((state) => state.files.get(value)); - const fileCount = useStore((state) => state.files.size); - const fileIndex = useStore((state) => { - const files = Array.from(state.files.keys()); - return files.indexOf(value) + 1; - }); + const id = React.useId(); + const statusId = `${id}-status`; + const nameId = `${id}-name`; + const sizeId = `${id}-size`; + const messageId = `${id}-message`; - const itemContext = useMemo( - () => ({ - fileState, - id, - messageId, - nameId, - sizeId, - statusId, - }), - [id, fileState, statusId, nameId, sizeId, messageId], - ); + const context = useFileUploadContext(ITEM_NAME); + const fileState = useStore((state) => state.files.get(value)); + const fileCount = useStore((state) => state.files.size); + const fileIndex = useStore((state) => { + const files = Array.from(state.files.keys()); + return files.indexOf(value) + 1; + }); - if (!fileState) return null; + const itemContext = React.useMemo( + () => ({ + id, + fileState, + nameId, + sizeId, + statusId, + messageId, + }), + [id, fileState, statusId, nameId, sizeId, messageId], + ); - const statusText = (() => { - if (fileState.error) return `Error: ${fileState.error}`; - if (fileState.status === "uploading") - return `Uploading: ${fileState.progress}% complete`; - if (fileState.status === "success") return "Upload complete"; - return "Ready to upload"; - })(); + if (!fileState) return null; - const ItemPrimitive = asChild ? Slot : "div"; + const statusText = getStatusText(fileState); - return ( - - - {props.children} - - {statusText} - - - - ); - }, -); -FileUploadItem.displayName = ITEM_NAME; + const ItemPrimitive = asChild ? Slot : "div"; + + return ( + + + {props.children} + + {statusText} + + + + ); +} function formatBytes(bytes: number) { if (bytes === 0) return "0 B"; @@ -1019,43 +1065,45 @@ function getFileIcon(file: File) { return ; } -interface FileUploadItemPreviewProps extends ComponentPropsWithoutRef<"div"> { - render?: (file: File) => ReactNode; +interface FileUploadItemPreviewProps extends React.ComponentProps<"div"> { + render?: (file: File, fallback: () => React.ReactNode) => React.ReactNode; asChild?: boolean; } -const FileUploadItemPreview = forwardRef< - HTMLDivElement, - FileUploadItemPreviewProps ->((props, forwardedRef) => { +function FileUploadItemPreview(props: FileUploadItemPreviewProps) { const { render, asChild, children, className, ...previewProps } = props; const itemContext = useFileUploadItemContext(ITEM_PREVIEW_NAME); + const context = useFileUploadContext(ITEM_PREVIEW_NAME); - const isImage = itemContext.fileState?.file.type.startsWith("image/"); - - const onPreviewRender = useCallback( + const getDefaultRender = React.useCallback( (file: File) => { - if (render) return render(file); + if (itemContext.fileState?.file.type.startsWith("image/")) { + let url = context.urlCache.get(file); + if (!url) { + url = URL.createObjectURL(file); + context.urlCache.set(file, url); + } - if (isImage) { return ( - // biome-ignore lint/a11y/noNoninteractiveElementInteractions: - {file.name} { - if (!(event.target instanceof HTMLImageElement)) return; - URL.revokeObjectURL(event.target.src); - }} - src={URL.createObjectURL(file)} - /> + {file.name} ); } return getFileIcon(file); }, - [isImage, render], + [itemContext.fileState?.file.type, context.urlCache], + ); + + const onPreviewRender = React.useCallback( + (file: File) => { + if (render) { + return render(file, () => getDefaultRender(file)); + } + + return getDefaultRender(file); + }, + [render, getDefaultRender], ); if (!itemContext.fileState) return null; @@ -1068,28 +1116,29 @@ const FileUploadItemPreview = forwardRef< data-slot="file-upload-preview" {...previewProps} className={cn( - "relative flex size-10 shrink-0 items-center justify-center rounded-md", - isImage ? "object-cover" : "bg-accent/50 [&>svg]:size-7", + "relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded border bg-accent/50 [&>svg]:size-10", className, )} - ref={forwardedRef} > {onPreviewRender(itemContext.fileState.file)} {children} ); -}); -FileUploadItemPreview.displayName = ITEM_PREVIEW_NAME; - -interface FileUploadItemMetadataProps extends ComponentPropsWithoutRef<"div"> { - asChild?: boolean; } -const FileUploadItemMetadata = forwardRef< - HTMLDivElement, - FileUploadItemMetadataProps ->((props, forwardedRef) => { - const { asChild, children, className, ...metadataProps } = props; +interface FileUploadItemMetadataProps extends React.ComponentProps<"div"> { + asChild?: boolean; + size?: "default" | "sm"; +} + +function FileUploadItemMetadata(props: FileUploadItemMetadataProps) { + const { + asChild, + size = "default", + children, + className, + ...metadataProps + } = props; const context = useFileUploadContext(ITEM_METADATA_NAME); const itemContext = useFileUploadItemContext(ITEM_METADATA_NAME); @@ -1104,18 +1153,23 @@ const FileUploadItemMetadata = forwardRef< dir={context.dir} {...metadataProps} className={cn("flex min-w-0 flex-1 flex-col", className)} - ref={forwardedRef} > {children ?? ( <> {itemContext.fileState.file.name} {formatBytes(itemContext.fileState.file.size)} @@ -1132,134 +1186,160 @@ const FileUploadItemMetadata = forwardRef< )} ); -}); -FileUploadItemMetadata.displayName = ITEM_METADATA_NAME; - -interface FileUploadItemProgressProps extends ComponentPropsWithoutRef<"div"> { - asChild?: boolean; - circular?: boolean; +} +interface FileUploadItemProgressProps extends React.ComponentProps<"div"> { + variant?: "linear" | "circular" | "fill"; size?: number; + asChild?: boolean; + forceMount?: boolean; } -const FileUploadItemProgress = forwardRef< - HTMLDivElement, - FileUploadItemProgressProps ->((props, forwardedRef) => { - const { circular, size = 40, asChild, className, ...progressProps } = props; +function FileUploadItemProgress(props: FileUploadItemProgressProps) { + const { + variant = "linear", + size = 40, + asChild, + forceMount, + className, + ...progressProps + } = props; const itemContext = useFileUploadItemContext(ITEM_PROGRESS_NAME); if (!itemContext.fileState) return null; + const shouldRender = forceMount || itemContext.fileState.progress !== 100; + + if (!shouldRender) return null; + const ItemProgressPrimitive = asChild ? Slot : "div"; - if (circular) { - if (itemContext.fileState.status === "success") return null; + switch (variant) { + case "circular": { + const circumference = 2 * Math.PI * ((size - 4) / 2); + const strokeDashoffset = + circumference - (itemContext.fileState.progress / 100) * circumference; - const circumference = 2 * Math.PI * ((size - 4) / 2); - const strokeDashoffset = - circumference - (itemContext.fileState.progress / 100) * circumference; - - return ( - - - Progress - + progress + + + + + ); + } + + case "fill": { + const progressPercentage = itemContext.fileState.progress; + const topInset = 100 - progressPercentage; + + return ( + + ); + } + + default: + return ( + +
- - - - ); + + ); } +} - return ( - -
- - ); -}); -FileUploadItemProgress.displayName = ITEM_PROGRESS_NAME; - -interface FileUploadItemDeleteProps extends ComponentPropsWithoutRef<"button"> { +interface FileUploadItemDeleteProps extends React.ComponentProps<"button"> { asChild?: boolean; } -const FileUploadItemDelete = forwardRef< - HTMLButtonElement, - FileUploadItemDeleteProps ->((props, forwardedRef) => { - const { asChild, ...deleteProps } = props; +function FileUploadItemDelete(props: FileUploadItemDeleteProps) { + const { asChild, onClick: onClickProp, ...deleteProps } = props; const store = useStoreContext(ITEM_DELETE_NAME); const itemContext = useFileUploadItemContext(ITEM_DELETE_NAME); - const propsRef = useAsRef(deleteProps); - const onClick = useCallback( - (event: MouseEvent) => { - propsRef.current?.onClick?.(event); + const onClick = React.useCallback( + (event: React.MouseEvent) => { + onClickProp?.(event); if (!itemContext.fileState || event.defaultPrevented) return; store.dispatch({ + type: "REMOVE_FILE", file: itemContext.fileState.file, - variant: "REMOVE_FILE", }); }, - [store, itemContext.fileState, propsRef.current?.onClick], + [store, itemContext.fileState, onClickProp], ); if (!itemContext.fileState) return null; @@ -1274,75 +1354,62 @@ const FileUploadItemDelete = forwardRef< type="button" {...deleteProps} onClick={onClick} - ref={forwardedRef} /> ); -}); -FileUploadItemDelete.displayName = ITEM_DELETE_NAME; +} -interface FileUploadClearProps extends ComponentPropsWithoutRef<"button"> { +interface FileUploadClearProps extends React.ComponentProps<"button"> { forceMount?: boolean; asChild?: boolean; } -const FileUploadClear = forwardRef( - (props, forwardedRef) => { - const { asChild, forceMount, disabled, ...clearProps } = props; +function FileUploadClear(props: FileUploadClearProps) { + const { + asChild, + forceMount, + disabled, + onClick: onClickProp, + ...clearProps + } = props; - const context = useFileUploadContext(CLEAR_NAME); - const store = useStoreContext(CLEAR_NAME); - const propsRef = useAsRef(clearProps); + const context = useFileUploadContext(CLEAR_NAME); + const store = useStoreContext(CLEAR_NAME); + const fileCount = useStore((state) => state.files.size); - const isDisabled = disabled || context.disabled; + const isDisabled = disabled || context.disabled; - const onClick = useCallback( - (event: MouseEvent) => { - propsRef.current?.onClick?.(event); + const onClick = React.useCallback( + (event: React.MouseEvent) => { + onClickProp?.(event); - if (event.defaultPrevented) return; + if (event.defaultPrevented) return; - store.dispatch({ variant: "CLEAR" }); - }, - [store, propsRef], - ); + store.dispatch({ type: "CLEAR" }); + }, + [store, onClickProp], + ); - const shouldRender = - forceMount || useStore((state) => state.files.size > 0); + const shouldRender = forceMount || fileCount > 0; - if (!shouldRender) return null; + if (!shouldRender) return null; - const ClearPrimitive = asChild ? Slot : "button"; + const ClearPrimitive = asChild ? Slot : "button"; - return ( - - ); - }, -); -FileUploadClear.displayName = CLEAR_NAME; - -const FileUpload = FileUploadRoot; -// const Root = FileUploadRoot; -// const Trigger = FileUploadTrigger; -// const Dropzone = FileUploadDropzone; -// const List = FileUploadList; -// const Item = FileUploadItem; -// const ItemPreview = FileUploadItemPreview; -// const ItemMetadata = FileUploadItemMetadata; -// const ItemProgress = FileUploadItemProgress; -// const ItemDelete = FileUploadItemDelete; -// const Clear = FileUploadClear; + return ( + + ); +} export { - FileUpload, + FileUploadRoot as FileUpload, FileUploadDropzone, FileUploadTrigger, FileUploadList, @@ -1351,18 +1418,20 @@ export { FileUploadItemMetadata, FileUploadItemProgress, FileUploadItemDelete, - //FileUploadClear, - /* - Root, - Dropzone, - Trigger, - List, - Item, - ItemPreview, - ItemMetadata, - ItemProgress, - ItemDelete, - Clear, - - useStore as useFileUpload,*/ + FileUploadClear, + // + FileUploadRoot as Root, + FileUploadDropzone as Dropzone, + FileUploadTrigger as Trigger, + FileUploadList as List, + FileUploadItem as Item, + FileUploadItemPreview as ItemPreview, + FileUploadItemMetadata as ItemMetadata, + FileUploadItemProgress as ItemProgress, + FileUploadItemDelete as ItemDelete, + FileUploadClear as Clear, + // + useStore as useFileUpload, + // + type FileUploadRootProps as FileUploadProps, }; diff --git a/apps/infoalloggi/src/components/servizio/annuncio_actions.tsx b/apps/infoalloggi/src/components/servizio/annuncio_actions.tsx index 29decff..7275601 100644 --- a/apps/infoalloggi/src/components/servizio/annuncio_actions.tsx +++ b/apps/infoalloggi/src/components/servizio/annuncio_actions.tsx @@ -63,11 +63,8 @@ import { useTranslation } from "~/providers/I18nProvider"; import { useServizio, useServizioAnnuncio } from "~/providers/ServizioProvider"; import type { AnnunciId } from "~/schemas/public/Annunci"; import type { ServizioAnnunciUpdate } from "~/schemas/public/ServizioAnnunci"; -import type { - Storageindex, - StorageindexId, -} from "~/schemas/public/Storageindex"; import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum"; +import type { FileMetadataWithAdmin } from "~/server/services/storage.service"; import { api } from "~/utils/api"; export const AnnuncioActions = () => { @@ -495,7 +492,7 @@ const ContrattoSection = ({ }) => { const { userId } = useServizio(); const { data } = useServizioAnnuncio(); - const [selectedDoc, setSelectedDoc] = useState( + const [selectedDoc, setSelectedDoc] = useState( data.doc_contratto_ref, ); @@ -505,7 +502,7 @@ const ContrattoSection = ({ const { mutate: setUserStorage } = api.storage.addUserStorage.useMutation({ onSuccess: async () => { - await utils.storage.getUserStorage.invalidate({ + await utils.storage.retrieveUserFileData.invalidate({ userId, }); await utils.storage.getAll.invalidate(); @@ -519,7 +516,7 @@ const ContrattoSection = ({ }); if (selectedDoc) { setUserStorage({ - fileId: selectedDoc, + storageId: selectedDoc, userId, }); } @@ -587,7 +584,7 @@ const RicevutaSection = ({ }) => { const { userId } = useServizio(); const { data } = useServizioAnnuncio(); - const [selectedDoc, setSelectedDoc] = useState( + const [selectedDoc, setSelectedDoc] = useState( data.doc_registrazione_ref, ); @@ -597,7 +594,7 @@ const RicevutaSection = ({ const { mutate: setUserStorage } = api.storage.addUserStorage.useMutation({ onSuccess: async () => { - await utils.storage.getUserStorage.invalidate({ + await utils.storage.retrieveUserFileData.invalidate({ userId, }); await utils.storage.getAll.invalidate(); @@ -611,7 +608,7 @@ const RicevutaSection = ({ }); if (selectedDoc) { setUserStorage({ - fileId: selectedDoc, + storageId: selectedDoc, userId, }); } @@ -762,7 +759,7 @@ const DocSection = ({ const { mutate: setUserStorage } = api.storage.addUserStorage.useMutation({ onSuccess: async () => { - await utils.storage.getUserStorage.invalidate({ + await utils.storage.retrieveUserFileData.invalidate({ userId, }); await utils.storage.getAll.invalidate(); @@ -812,7 +809,7 @@ const DocSection = ({ }); setUserStorage({ - fileId: f.id, + storageId: f.id, userId, }); }} @@ -828,10 +825,10 @@ const DocInfo = ({ storageId, children, }: { - storageId: StorageindexId; + storageId: string; children: React.ReactNode; }) => { - const { data: file_info } = api.storage.getStorageFromId.useQuery({ + const { data: file_info } = api.storage.getFileInfos.useQuery({ storageId, }); if (!file_info) return null; @@ -852,9 +849,9 @@ const DocInfo = ({ id="selected-file" target="_blank" > - {file_info.ext && } + - {file_info.filename} + {file_info.originalName} @@ -870,9 +867,9 @@ const DocCombo = ({ onSelect, current, }: { - files: Storageindex[]; - onSelect: (file: Storageindex) => void; - current: StorageindexId | null; + files: FileMetadataWithAdmin[]; + onSelect: (file: FileMetadataWithAdmin) => void; + current: string | null; }) => { const [open, setOpen] = useState(false); @@ -909,8 +906,10 @@ const DocCombo = ({ value={file.id} > - {file.ext && } - {file.filename} + + + {file.originalName} + {current === file.id && ( )} diff --git a/apps/infoalloggi/src/components/servizio/conferma.tsx b/apps/infoalloggi/src/components/servizio/conferma.tsx index 64ae73b..6711c46 100644 --- a/apps/infoalloggi/src/components/servizio/conferma.tsx +++ b/apps/infoalloggi/src/components/servizio/conferma.tsx @@ -1,38 +1,29 @@ +import Link from "next/link"; import { AllegatoIframe } from "~/components/allegato-iframe"; import { LoadingPage } from "~/components/loading"; -import { Button } from "~/components/ui/button"; -import { handleDownload } from "~/hooks/filesHooks"; import { useTranslation } from "~/providers/I18nProvider"; -import type { StorageindexId } from "~/schemas/public/Storageindex"; import { api } from "~/utils/api"; -export const FileSection = ({ storageId }: { storageId: StorageindexId }) => { - const { data: fileInfos, isLoading } = api.storage.getStorageFromId.useQuery({ +export const FileSection = ({ storageId }: { storageId: string }) => { + const { data: fileInfos, isLoading } = api.storage.getFileInfos.useQuery({ storageId, }); - const { mutateAsync: getToken } = api.storage.getStorageToken.useMutation(); const { t } = useTranslation(); if (isLoading) return ; if (!fileInfos) return

{t.file_section.error}

; return ( <> - + ); }; diff --git a/apps/infoalloggi/src/components/tables/storage-table.tsx b/apps/infoalloggi/src/components/tables/storage-table.tsx index 54be644..10d106d 100644 --- a/apps/infoalloggi/src/components/tables/storage-table.tsx +++ b/apps/infoalloggi/src/components/tables/storage-table.tsx @@ -38,28 +38,27 @@ import { Input } from "~/components/ui/input"; import { cn } from "~/lib/utils"; import { useTranslation } from "~/providers/I18nProvider"; import { useStorageTable } from "~/providers/StorageTableProvider"; -import type { - Storageindex, - StorageindexId, -} from "~/schemas/public/Storageindex"; +import { + editFile, + type FileMetadataWithAdmin, +} from "~/server/services/storage.service"; + import { api } from "~/utils/api"; export const StorageTable = ({ data, handleDelete, }: { - data: Storageindex[]; + data: FileMetadataWithAdmin[]; - handleDelete: (id: StorageindexId) => void; + handleDelete: (id: string) => void; }) => { const { setTable, cb_onStateChange } = useStorageTable(); - const [deleteConfirmId, setDeleteConfirmId] = useState( - null, - ); + const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [renameTarget, setRenameTarget] = useState<{ - allegatoId: StorageindexId; + allegatoId: string; filename: string | null; } | null>(null); @@ -74,23 +73,13 @@ export const StorageTable = ({ } }, [tableInstance, setTable]); - const tabledata = data.map((row) => { - return { - actions: "Azioni", - created_at: row.created_at, - expires_at: row.expires_at, - ext: row.ext, - filename: row.filename, - from_admin: row.from_admin, - id: row.id, - }; - }); + const tabledata = data; const columns_titles = { actions: "Azioni", - created_at: "Data creazione", - ext: "Estensione", - filename: "Nome file", + uploadedAt: "Data creazione", + type: "Tipo file", + originalName: "Nome file", from_admin: "Caricato da", id: "Codice", }; @@ -120,7 +109,7 @@ export const StorageTable = ({ id: "select", }, { - accessorKey: "filename", + accessorKey: "originalName", cell: ({ row }) => { return ( - {row.original.filename} + {row.original.originalName} ); }, @@ -137,14 +126,14 @@ export const StorageTable = ({ header: ({ column }) => ( ), }, { - accessorKey: "created_at", + accessorKey: "uploadedAt", cell: ({ row }) => { - const date = row.getValue("created_at"); + const date = row.getValue("uploadedAt"); return new Date(date as Date).toLocaleDateString(); }, @@ -152,7 +141,7 @@ export const StorageTable = ({ header: ({ column }) => ( ), }, @@ -203,7 +192,7 @@ export const StorageTable = ({ onClick={() => setRenameTarget({ allegatoId: data.id, - filename: data.filename, + filename: data.originalName, }) } role="button" @@ -231,7 +220,7 @@ export const StorageTable = ({ ]; const searchFiltro: SearchFiltro = { - columnName: "filename", + columnName: "originalName", placeholder: "Cerca file ...", }; return ( @@ -240,7 +229,7 @@ export const StorageTable = ({ columns={columns} columns_titles={columns_titles} data={tabledata} - defaultSort={[{ desc: true, id: "created_at" }]} + defaultSort={[{ desc: true, id: "uploadedAt" }]} onStateChange={cb_onStateChange} onTableInit={setTableInstance} pinnedFiltri={undefined} @@ -289,42 +278,32 @@ const RinominaDialog = ({ renameTarget, setRenameTarget, }: { - renameTarget: { allegatoId: StorageindexId; filename: string | null }; + renameTarget: { allegatoId: string; filename: string | null }; setRenameTarget: ( - target: { allegatoId: StorageindexId; filename: string | null } | null, + target: { allegatoId: string; filename: string | null } | null, ) => void; }) => { const { t } = useTranslation(); const utils = api.useUtils(); - const { mutate: renameFile } = api.storage.renameFile.useMutation({ - onMutate: () => { - setRenameTarget(null); - const toastId = toast.loading(t.caricamento, { - icon: "๐Ÿช›", - }); - return { toastId }; - }, - onSuccess: async (_error, _variables, context) => { - await utils.storage.getAll.invalidate(); - toast.success(t.successo, { - icon: "๐Ÿ‘", - id: context?.toastId, - }); - }, - }); - - const handleEditSubmit = (e: FormEvent) => { + const handleEditSubmit = async (e: FormEvent) => { e.preventDefault(); const form = e.currentTarget; const formData = new FormData(form); if (!formData.get("filename")) { return; } - renameFile({ - fileId: renameTarget.allegatoId, - name: formData.get("filename") as string, - }); + const rename = await editFile( + renameTarget.allegatoId, + formData.get("filename") as string, + ); + if (rename.success) { + setRenameTarget(null); + toast.success("File rinominato con successo", { icon: "๐Ÿ‘" }); + await utils.storage.getAll.invalidate(); + } else { + toast.error(rename.error); + } }; return ( ([]); const [isUploading, setIsUploading] = useState(false); - const { mutateAsync: getToken } = api.storage.getStorageTokenM.useMutation(); - - const { mutate: setUserStorage } = api.storage.addUserStorage.useMutation({ - onMutate: () => { - toast(t.allegati.allegato_caricato, { icon: "๐Ÿ“ค" }); - }, - onSettled: async () => { - await utils.storage.getAll.invalidate(); - await utils.storage.getUserStorage.invalidate({ - userId, - }); - }, - }); + const { mutateAsync: setUserStorage } = + api.storage.addUserStorage.useMutation({ + onMutate: () => { + toast(t.allegati.allegato_caricato, { icon: "๐Ÿ“ค" }); + }, + onSettled: async () => { + await utils.storage.getAll.invalidate(); + await utils.storage.retrieveUserFileData.invalidate({ + userId, + }); + }, + }); const acceptedMimeTypes = [ "image/jpeg", // jpg, jpeg @@ -159,19 +157,24 @@ export const UploadComponent = ({ onProgress: (file: File, progress: number) => void; }, ) => { + if (files.length === 0) { + return; + } try { + const from_admin = isAdmin ?? true; setIsUploading(true); - const { status, attachments } = await Upload({ - files: files, - from_admin: isAdmin ?? true, - getToken, + + const { status, attachments } = await uploadHandler({ + files, + from_admin, }); if (userId) { for (const storageId of attachments) { - setUserStorage({ - fileId: storageId as StorageindexId, + await setUserStorage({ + storageId, userId, + from_admin, }); } } diff --git a/apps/infoalloggi/src/env.ts b/apps/infoalloggi/src/env.ts index 1a07d54..93c782b 100644 --- a/apps/infoalloggi/src/env.ts +++ b/apps/infoalloggi/src/env.ts @@ -28,6 +28,7 @@ export const env = createEnv({ SKEBBY_USER: z.string(), SKEBBY_PASS: z.string(), REVALIDATION_SECRET: z.string(), + STORAGE_URL: z.string(), STORAGE_TOKEN: z.string(), }, client: { diff --git a/apps/infoalloggi/src/hooks/filesHooks.ts b/apps/infoalloggi/src/hooks/filesHooks.ts deleted file mode 100644 index 9caff6e..0000000 --- a/apps/infoalloggi/src/hooks/filesHooks.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Storageindex } from "~/schemas/public/Storageindex"; -import type { TempTokensToken } from "~/schemas/public/TempTokens"; - -export const handleDownload = async ({ - file, - getToken, -}: { - file: Storageindex; - getToken: () => Promise; -}) => { - const auth_token = (await getToken()) as TempTokensToken; - try { - const response = await fetch( - `/go-api/storage/get/${file.id}${file.ext}?token=${auth_token}`, - ); - - if (!response.ok) { - throw new Error("File fetch failed"); - } - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - // biome-ignore lint/style/noNonNullAssertion: - a.download = file.filename!; - document.body.appendChild(a); - a.click(); - a.remove(); - window.URL.revokeObjectURL(url); - } catch { - throw new Error("File download failed"); - } -}; diff --git a/apps/infoalloggi/src/hooks/storageClienSideHooks.ts b/apps/infoalloggi/src/hooks/storageClienSideHooks.ts new file mode 100644 index 0000000..fb23191 --- /dev/null +++ b/apps/infoalloggi/src/hooks/storageClienSideHooks.ts @@ -0,0 +1,83 @@ +import { add } from "date-fns"; +import z from "zod"; + +type UploadResponse = + | { + success: true; + id: string; + } + | { + success: false; + error: string; + }; +// Helper to handle file uploads +export async function uploadFile( + file: File, + expiresAt: string | undefined, +): Promise { + const formData = new FormData(); + formData.append("file", file); + if (expiresAt) { + formData.append("expires_at", expiresAt); + } + + try { + const response = await fetch(`/storage-api/upload`, { + method: "POST", + body: formData, + }); + + if (response.ok) { + const res = await response.json(); + const parsed = z + .object({ + id: z.string(), + }) + .parse(res); + const fileID = parsed.id; + console.log("File uploaded successfully."); + return { success: true, id: fileID }; + } + + return { + success: false, + error: `Caricamento fallito con status: ${response.status}`, + }; + } catch (e) { + return { + success: false, + error: `Errore caricamento file: ${(e as Error).message}`, + }; + } +} + +export const uploadHandler = async ({ + files, + from_admin, +}: { + files: File[]; + from_admin: boolean; +}) => { + const attachments = []; + const failed: string[] = []; + const expirationDate = add(new Date(), { days: 60 }).toISOString(); + if (files.length === 0) { + console.error("No files provided"); + return { attachments: [], error: "No files provided", status: false }; + } + + for (const file of files) { + const upload = await uploadFile( + file, + from_admin ? expirationDate : undefined, + ); + if (upload.success) { + attachments.push(upload.id); + } else { + console.error(`Error uploading file ${file.name}: ${upload.error}`); + failed.push(file.name); + } + } + + return { attachments, failed, status: true }; +}; diff --git a/apps/infoalloggi/src/lib/storage_manager.ts b/apps/infoalloggi/src/lib/storage_manager.ts deleted file mode 100644 index a6c18a5..0000000 --- a/apps/infoalloggi/src/lib/storage_manager.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { TempTokensToken } from "~/schemas/public/TempTokens"; - -export const Upload = async ({ - files, - getToken, - from_admin, -}: { - files: File[]; - getToken: ({ num }: { num: number }) => Promise; - from_admin: boolean; -}) => { - const attachments = []; - if (files.length === 0) { - console.error("No files provided"); - return { attachments: [], error: "No files provided", status: false }; - } - if (files.length > 1) { - const authTokens = await getToken({ - num: files.length, - }); - - if (authTokens.length !== files.length) { - console.error( - "Error: number of tokens does not match number of files", - authTokens.length, - files.length, - ); - return { - attachments: [], - error: "Error: number of tokens does not match number of files", - status: false, - }; - } - for (const file of files) { - const attach = await uploadFile({ - file, - from_admin, - // biome-ignore lint/style/noNonNullAssertion: - token: authTokens.pop()!, - }); - attachments.push(attach); - } - } else { - const authTokens = ( - await getToken({ - num: 1, - }) - )[0]; - if (!authTokens) { - console.error("Error: no token received"); - return { - attachments: [], - error: "Error: no token received", - status: false, - }; - } - - const attach = await uploadFile({ - // biome-ignore lint/style/noNonNullAssertion: - file: files[0]!, - from_admin, - token: authTokens, - }); - attachments.push(attach); - } - - return { attachments, status: true }; -}; - -const uploadFile = async ({ - file, - token, - from_admin, -}: { - file: File; - token: TempTokensToken; - from_admin: boolean; -}) => { - const formData = new FormData(); - formData.append("file", file); - - const response = await fetch( - `/go-api/storage/upload?token=${token}&from_admin=${from_admin}`, - { - body: formData, - method: "POST", - }, - ); - - if (!response.ok) { - throw new Error( - `Error uploading file: ${response.status} ${response.statusText}`, - ); - } - return response.text(); -}; diff --git a/apps/infoalloggi/src/pages/area-riservata/admin/storage.tsx b/apps/infoalloggi/src/pages/area-riservata/admin/storage.tsx index be91a87..a437c78 100644 --- a/apps/infoalloggi/src/pages/area-riservata/admin/storage.tsx +++ b/apps/infoalloggi/src/pages/area-riservata/admin/storage.tsx @@ -38,10 +38,7 @@ import { type StorageTable as StorageTableType, useStorageTable, } from "~/providers/StorageTableProvider"; -import type { - Storageindex, - StorageindexId, -} from "~/schemas/public/Storageindex"; + import type { Users } from "~/schemas/public/Users"; import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper"; import { api } from "~/utils/api"; @@ -50,7 +47,7 @@ const AdminStorage: NextPageWithLayout = () => { const { data, isLoading, refetch } = api.storage.getAll.useQuery(); const { t } = useTranslation(); const utils = api.useUtils(); - const { mutate: deleteFile } = api.storage.deleteStorage.useMutation({ + const { mutate: deleteFiles } = api.storage.deleteFile.useMutation({ onMutate: () => { const toastId = toast.loading(t.caricamento, { icon: "๐Ÿช›", @@ -67,8 +64,8 @@ const AdminStorage: NextPageWithLayout = () => { }, }); - const handleDelete = (id: StorageindexId) => { - deleteFile({ storageId: id }); + const handleDelete = (id: string) => { + deleteFiles({ storageId: id }); }; const handleRefetch = async () => { @@ -117,7 +114,7 @@ const StorageTableHeader = ({ handleDelete, }: { handleRefetch: () => Promise; - handleDelete: (id: StorageindexId) => void; + handleDelete: (id: string) => void; }) => { const { table } = useStorageTable(); if (!table) return null; @@ -158,7 +155,7 @@ const Actions = ({ handleDelete, table, }: { - handleDelete: (id: StorageindexId) => void; + handleDelete: (id: string) => void; table: StorageTableType; }) => { const { selectedRows } = useStorageTable(); @@ -190,9 +187,9 @@ const Actions = ({ {visbOpen && ( r.id)} /> )}
@@ -200,21 +197,21 @@ const Actions = ({ }; const VisibComponent = ({ - allegati, + storageIds, open, setOpen, }: { - allegati: Storageindex[]; + storageIds: string[]; open: boolean; setOpen: (v: boolean) => void; }) => { const utils = api.useUtils(); const { data, isLoading } = api.storage.getStorageVisibility.useQuery({ - storageIds: allegati.map((a) => a.id), + storageIds, }); const { data: users } = api.users.getUsers.useQuery(); - const { mutate: remove } = api.storage.removeStorageVisibility.useMutation({ + const { mutate: remove } = api.storage.deleteUserStorageEntry.useMutation({ onMutate: () => { const toastId = toast.loading("Rimozione utente in corso", { icon: "๐Ÿช›", @@ -229,7 +226,7 @@ const VisibComponent = ({ }); }, }); - const { mutate: add } = api.storage.addStorageVisibility.useMutation({ + const { mutate: add } = api.storage.addUserStorage.useMutation({ onMutate: () => { const toastId = toast.loading("Aggiunta utente in corso", { icon: "๐Ÿช›", @@ -245,7 +242,7 @@ const VisibComponent = ({ }, }); - const { mutate: reset } = api.storage.resetStorageVisibility.useMutation({ + const { mutate: reset } = api.storage.deleteFilesFromUserStorage.useMutation({ onMutate: () => { const toastId = toast.loading("Reset visibilitร  in corso", { icon: "๐Ÿช›", @@ -280,17 +277,19 @@ const VisibComponent = ({

Seleziona utente:

{ - for (const allegato of allegati) { - add({ storageId: allegato.id, userId: user.id }); + for (const allegato of storageIds) { + add({ + storageId: allegato, + userId: user.id, + from_admin: true, + }); } }} users={users || []} /> + {fileInfos.originalName} + + caricato il {new Date(fileInfos.uploadedAt).toLocaleString("it")} + + + + {session.user.isAdmin && ( = ({ )}
- +
@@ -125,7 +119,7 @@ export const getServerSideProps = (async (context) => { }; } - const parsedStorageId = zStorageIndexId.safeParse(allegatoId); + const parsedStorageId = z.string().safeParse(allegatoId); if (!parsedStorageId.success) { return { @@ -137,7 +131,7 @@ export const getServerSideProps = (async (context) => { const helper = await TrpcAuthedFetchingIstance({ access_token }); if (helper) { - await helper.trpc.storage.getStorageFromId.prefetch({ + await helper.trpc.storage.getFileInfos.prefetch({ storageId: parsedStorageId.data, }); @@ -162,7 +156,7 @@ const RinominaDialog = ({ allegatoId, filename, }: { - allegatoId: StorageindexId; + allegatoId: string; filename: string | null; }) => { const { t } = useTranslation(); @@ -178,7 +172,7 @@ const RinominaDialog = ({ return { toastId }; }, onSuccess: async (_error, _variables, context) => { - await utils.storage.getStorageFromId.invalidate({ + await utils.storage.getFileInfos.invalidate({ storageId: allegatoId, }); toast.success(t.successo, { diff --git a/apps/infoalloggi/src/pages/servizio/conferma-immobile/[...slug].tsx b/apps/infoalloggi/src/pages/servizio/conferma-immobile/[...slug].tsx index 15058ee..ce07825 100644 --- a/apps/infoalloggi/src/pages/servizio/conferma-immobile/[...slug].tsx +++ b/apps/infoalloggi/src/pages/servizio/conferma-immobile/[...slug].tsx @@ -91,7 +91,7 @@ const ConfirmSection = ({ return { toastId }; }, onSuccess: async (_error, _variables, context) => { - await utils.storage.getUserStorage.invalidate(); + await utils.storage.retrieveUserFileData.invalidate(); toast.success("Confermato", { icon: "๐Ÿ‘", diff --git a/apps/infoalloggi/src/pages/servizio/contratto-registrazione/[...slug].tsx b/apps/infoalloggi/src/pages/servizio/contratto-registrazione/[...slug].tsx index b0eea0b..3ff3d97 100644 --- a/apps/infoalloggi/src/pages/servizio/contratto-registrazione/[...slug].tsx +++ b/apps/infoalloggi/src/pages/servizio/contratto-registrazione/[...slug].tsx @@ -1,16 +1,14 @@ import type { GetServerSideProps } from "next"; +import Link from "next/link"; import { AllegatoIframe } from "~/components/allegato-iframe"; import { DocNotFoundPage } from "~/components/doc_not_found"; import { AreaRiservataLayout } from "~/components/Layout"; import { LoadingPage } from "~/components/loading"; import { Status500 } from "~/components/status-page"; -import { Button } from "~/components/ui/button"; -import { handleDownload } from "~/hooks/filesHooks"; import type { NextPageWithLayout } from "~/pages/_app"; import { useTranslation } from "~/providers/I18nProvider"; import type { AnnunciId } from "~/schemas/public/Annunci"; import type { ServizioServizioId } from "~/schemas/public/Servizio"; -import type { StorageindexId } from "~/schemas/public/Storageindex"; import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper"; import { zAnnuncioId, zServizioId } from "~/server/utils/zod_types"; import { api } from "~/utils/api"; @@ -49,33 +47,23 @@ const ContrattoViewer: NextPageWithLayout = ({ ); }; -const FileSection = ({ storageId }: { storageId: StorageindexId }) => { - const { data: fileInfos, isLoading } = api.storage.getStorageFromId.useQuery({ +const FileSection = ({ storageId }: { storageId: string }) => { + const { data: fileInfos, isLoading } = api.storage.getFileInfos.useQuery({ storageId, }); - const { mutateAsync: getToken } = api.storage.getStorageToken.useMutation(); const { t } = useTranslation(); if (isLoading) return ; if (!fileInfos) return

{t.file_section.error}

; return ( <> - + - + ); }; diff --git a/apps/infoalloggi/src/pages/servizio/contratto/[...slug].tsx b/apps/infoalloggi/src/pages/servizio/contratto/[...slug].tsx index 552e0ec..8805f3d 100644 --- a/apps/infoalloggi/src/pages/servizio/contratto/[...slug].tsx +++ b/apps/infoalloggi/src/pages/servizio/contratto/[...slug].tsx @@ -1,16 +1,14 @@ import type { GetServerSideProps } from "next"; +import Link from "next/link"; import { AllegatoIframe } from "~/components/allegato-iframe"; import { DocNotFoundPage } from "~/components/doc_not_found"; import { AreaRiservataLayout } from "~/components/Layout"; import { LoadingPage } from "~/components/loading"; import { Status500 } from "~/components/status-page"; -import { Button } from "~/components/ui/button"; -import { handleDownload } from "~/hooks/filesHooks"; import type { NextPageWithLayout } from "~/pages/_app"; import { useTranslation } from "~/providers/I18nProvider"; import type { AnnunciId } from "~/schemas/public/Annunci"; import type { ServizioServizioId } from "~/schemas/public/Servizio"; -import type { StorageindexId } from "~/schemas/public/Storageindex"; import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper"; import { zAnnuncioId, zServizioId } from "~/server/utils/zod_types"; import { api } from "~/utils/api"; @@ -45,33 +43,23 @@ const ContrattoViewer: NextPageWithLayout = ({ ); }; -const FileSection = ({ storageId }: { storageId: StorageindexId }) => { - const { data: fileInfos, isLoading } = api.storage.getStorageFromId.useQuery({ +const FileSection = ({ storageId }: { storageId: string }) => { + const { data: fileInfos, isLoading } = api.storage.getFileInfos.useQuery({ storageId, }); - const { mutateAsync: getToken } = api.storage.getStorageToken.useMutation(); const { t } = useTranslation(); if (isLoading) return ; if (!fileInfos) return

{t.file_section.error}

; return ( <> - + - + ); }; diff --git a/apps/infoalloggi/src/pages/test.tsx b/apps/infoalloggi/src/pages/test.tsx index 974fbe9..d4b7d97 100644 --- a/apps/infoalloggi/src/pages/test.tsx +++ b/apps/infoalloggi/src/pages/test.tsx @@ -147,135 +147,6 @@ export default Test; */ const Test: NextPage = () => { - return ; + return
Test page
; }; export default Test; - -import { useEffect, useState } from "react"; -import { LoadingPage } from "~/components/loading"; -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { - deleteFile, - type FileMetadata, - fetchFiles, - uploadFile, -} from "~/server/storage"; - -function FileManagementPage() { - const [files, setFiles] = useState([]); - const [loading, setLoading] = useState(true); - - const loadData = async () => { - setLoading(true); - const fileList = await fetchFiles(); - setFiles(fileList); - setLoading(false); - }; - - useEffect(() => { - loadData(); - }, []); - - const [inputFile, setInputFile] = useState(null); - const [inputExpiresAt, setInputExpiresAt] = useState( - undefined, - ); - - const handleFileUpload = async () => { - if (inputFile) { - const success = await uploadFile(inputFile, inputExpiresAt); - if (success) { - // Reload list after successful upload - await loadData(); - } - } - }; - - const handleDelete = async (fileID: string) => { - const confirmDelete = window.confirm("Are you sure?"); - if (confirmDelete) { - const success = await deleteFile(fileID); - if (success) { - await loadData(); - } - } - }; - - return ( -
-
-

File Storage

- -
- - { - setInputFile(e.currentTarget.files?.[0] || null); - }} - type="file" - /> - { - setInputExpiresAt(new Date(e.currentTarget.value).toISOString()); - }} - type="datetime-local" - /> - - {loading ? ( - - ) : ( -
    - {files.map((file) => ( -
  • - {file.mimeType.includes("image") && ( - {file.originalName} - )} -
    -

    {file.originalName}

    -

    - {file.mimeType} | Size: {(file.size / 1024 / 1024).toFixed(2)}{" "} - MB -

    -

    Uploaded At: {new Date(file.uploadedAt).toLocaleString()}

    - {file.expiresAt && ( -

    Expires At: {new Date(file.expiresAt).toLocaleString()}

    - )} -
    -
    - - Download - - -
    -
  • - ))} -
- )} -
- ); -} diff --git a/apps/infoalloggi/src/providers/StorageTableProvider.tsx b/apps/infoalloggi/src/providers/StorageTableProvider.tsx index db8378c..863ce86 100644 --- a/apps/infoalloggi/src/providers/StorageTableProvider.tsx +++ b/apps/infoalloggi/src/providers/StorageTableProvider.tsx @@ -6,27 +6,16 @@ import { useContext, useState, } from "react"; -import type { - Storageindex, - StorageindexId, -} from "~/schemas/public/Storageindex"; +import type { FileMetadataWithAdmin } from "~/server/services/storage.service"; -type TData = { - id: StorageindexId; - filename: string | null; - ext: string | null; - created_at: Date; - from_admin: boolean; - actions: string; - expires_at: Date | null; -}; +type TData = FileMetadataWithAdmin; export type StorageTable = Table; const StorageTableContext = createContext<{ table: StorageTable | undefined; setTable: (table: StorageTable | undefined) => void; - selectedRows: Storageindex[]; + selectedRows: TData[]; cb_onStateChange: () => void; }>({ cb_onStateChange: () => {}, @@ -39,7 +28,7 @@ const StorageTableContext = createContext<{ export const StorageTableProvider = ({ children }: { children: ReactNode }) => { const [table, setTable] = useState(undefined); - const [selectedRows, setSelectedRows] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); const cb_onStateChange = useCallback(() => { if (table) { @@ -55,16 +44,7 @@ export const StorageTableProvider = ({ children }: { children: ReactNode }) => { }, [], ); - setSelectedRows( - selected.map((r) => ({ - created_at: r.created_at, - expires_at: r.expires_at, - ext: r.ext, - filename: r.filename, - from_admin: r.from_admin, - id: r.id, - })), - ); + setSelectedRows(selected); } }, [table]); diff --git a/apps/infoalloggi/src/schemas/public/PublicSchema.ts b/apps/infoalloggi/src/schemas/public/PublicSchema.ts index 9661491..712b41c 100644 --- a/apps/infoalloggi/src/schemas/public/PublicSchema.ts +++ b/apps/infoalloggi/src/schemas/public/PublicSchema.ts @@ -19,7 +19,6 @@ import type { default as PrezziarioTable } from './Prezziario'; import type { default as RatelimiterTable } from './Ratelimiter'; import type { default as ChatsTable } from './Chats'; import type { default as EtichetteTable } from './Etichette'; -import type { default as StorageindexTable } from './Storageindex'; import type { default as MessagesTable } from './Messages'; import type { default as UsersAnagraficaTable } from './UsersAnagrafica'; import type { default as BanlistTable } from './Banlist'; @@ -63,8 +62,6 @@ export default interface PublicSchema { etichette: EtichetteTable; - storageindex: StorageindexTable; - messages: MessagesTable; users_anagrafica: UsersAnagraficaTable; diff --git a/apps/infoalloggi/src/schemas/public/ServizioAnnunci.ts b/apps/infoalloggi/src/schemas/public/ServizioAnnunci.ts index ff75dbc..6ad33ae 100644 --- a/apps/infoalloggi/src/schemas/public/ServizioAnnunci.ts +++ b/apps/infoalloggi/src/schemas/public/ServizioAnnunci.ts @@ -3,7 +3,6 @@ import type { ServizioServizioId } from './Servizio'; import type { AnnunciId } from './Annunci'; -import type { StorageindexId } from './Storageindex'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; /** Represents the table public.servizio_annunci */ @@ -16,7 +15,7 @@ export default interface ServizioAnnunciTable { open_contatti_at: ColumnType; - doc_conferma_ref: ColumnType; + doc_conferma_ref: ColumnType; user_confirmed_at: ColumnType; @@ -32,7 +31,7 @@ export default interface ServizioAnnunciTable { hasConfermaAdmin: ColumnType; - doc_contratto_ref: ColumnType; + doc_contratto_ref: ColumnType; gestionale_id: ColumnType; @@ -42,7 +41,7 @@ export default interface ServizioAnnunciTable { contratto_tipo: ColumnType; - doc_registrazione_ref: ColumnType; + doc_registrazione_ref: ColumnType; doc_conferma_added: ColumnType; diff --git a/apps/infoalloggi/src/schemas/public/Storageindex.ts b/apps/infoalloggi/src/schemas/public/Storageindex.ts deleted file mode 100644 index 37536c8..0000000 --- a/apps/infoalloggi/src/schemas/public/Storageindex.ts +++ /dev/null @@ -1,28 +0,0 @@ -// @generated -// This file is automatically generated by Kanel. Do not modify manually. - -import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; - -/** Identifier type for public.storageindex */ -export type StorageindexId = string & { __brand: 'public.storageindex' }; - -/** Represents the table public.storageindex */ -export default interface StorageindexTable { - id: ColumnType; - - created_at: ColumnType; - - filename: ColumnType; - - ext: ColumnType; - - from_admin: ColumnType; - - expires_at: ColumnType; -} - -export type Storageindex = Selectable; - -export type NewStorageindex = Insertable; - -export type StorageindexUpdate = Updateable; diff --git a/apps/infoalloggi/src/schemas/public/UsersStorage.ts b/apps/infoalloggi/src/schemas/public/UsersStorage.ts index f9403da..1d7e29f 100644 --- a/apps/infoalloggi/src/schemas/public/UsersStorage.ts +++ b/apps/infoalloggi/src/schemas/public/UsersStorage.ts @@ -2,14 +2,15 @@ // This file is automatically generated by Kanel. Do not modify manually. import type { UsersId } from './Users'; -import type { StorageindexId } from './Storageindex'; import type { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; /** Represents the table public.users_storage */ export default interface UsersStorageTable { - userid: ColumnType; + userId: ColumnType; - storageid: ColumnType; + storageId: ColumnType; + + from_admin: ColumnType; } export type UsersStorage = Selectable; diff --git a/apps/infoalloggi/src/server/api/routers/storage.ts b/apps/infoalloggi/src/server/api/routers/storage.ts index 2cab9a7..5298a2b 100644 --- a/apps/infoalloggi/src/server/api/routers/storage.ts +++ b/apps/infoalloggi/src/server/api/routers/storage.ts @@ -1,151 +1,90 @@ import { TRPCError } from "@trpc/server"; -import { z } from "zod/v4"; +import z from "zod"; +import type { NewUsersStorage } from "~/schemas/public/UsersStorage"; import { adminProcedure, createTRPCRouter, protectedProcedure, } from "~/server/api/trpc"; import { - addUserStorageHandler, - deleteUserStorageHandler, - getAllHandler, - getFromIdHandler, + addUserStorage, + deleteUserStorageEntry, + getAllWithFromAdmin, getMultipleWithRelations, - getUserStorageHandler, + purgeFileFromUserStorages, + retrieveUserFiles, } from "~/server/controllers/storage.controller"; import { db } from "~/server/db"; -import { RateLimiterHandler } from "~/server/services/ratelimiter"; import { - addUserStorageJunc, - deleteHandler, - deleteUserStorageJunc, - getNewTokenHandler, - updateHandler, + deleteFile, + editFile, + getFileInfo, } from "~/server/services/storage.service"; -import { zStorageIndexId, zUserId } from "~/server/utils/zod_types"; - -const ratelimit = new RateLimiterHandler({ - analytics: false, - maxRequests: 50, - windowSize: 60, -}); +import { zUserId } from "~/server/utils/zod_types"; export const storageRouter = createTRPCRouter({ - addStorageVisibility: adminProcedure - .input(z.object({ storageId: zStorageIndexId, userId: zUserId })) + getFileInfos: protectedProcedure + .input(z.object({ storageId: z.string() })) + .query(async ({ input }) => { + return (await getFileInfo(input.storageId)) || undefined; + }), + deleteFile: protectedProcedure + .input(z.object({ storageId: z.string() })) .mutation(async ({ input }) => { - await addUserStorageJunc({ - db, - storageId: input.storageId, - userId: input.userId, + const deletion = await deleteFile(input.storageId); + if (!deletion.success) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: deletion.error, + }); + } + await purgeFileFromUserStorages({ + ...input, }); }), + deleteUserStorageEntry: protectedProcedure + .input(z.object({ storageId: z.string(), userId: zUserId })) + .mutation(async ({ input }) => { + return await deleteUserStorageEntry(input); + }), + deleteFilesFromUserStorage: adminProcedure + .input(z.object({ storageIds: z.string().array() })) + .mutation(async ({ input }) => { + for (const storageId of input.storageIds) { + await purgeFileFromUserStorages({ storageId }); + } + return "ok"; + }), + retrieveUserFileData: protectedProcedure + .input(z.object({ userId: zUserId })) + .query(async ({ input }) => { + return await retrieveUserFiles(input); + }), + + //OLD addUserStorage: protectedProcedure - .input( - z.object({ - fileId: zStorageIndexId, - userId: zUserId, - }), - ) + .input(z.custom()) .mutation(async ({ input }) => { - return await addUserStorageHandler({ - db, - storageId: input.fileId, - userId: input.userId, - }); - }), - deleteStorage: protectedProcedure - .input(z.object({ storageId: zStorageIndexId })) - .mutation(async ({ input }) => { - return await deleteHandler({ - cb: async (fileId) => { - await db - .deleteFrom("storageindex") - .where("id", "=", fileId) - .execute(); - }, - db, - fileId: input.storageId, - }); - }), - deleteUserStorage: protectedProcedure - .input(z.object({ fileId: zStorageIndexId, userId: zUserId })) - .mutation(async ({ input }) => { - return await deleteUserStorageHandler({ - db, - storageId: input.fileId, - userId: input.userId, - }); + return await addUserStorage(input); }), + getAll: protectedProcedure.query(async () => { - return await getAllHandler({ db }); + return await getAllWithFromAdmin(); }), - getStorageFromId: protectedProcedure - .input(z.object({ storageId: zStorageIndexId })) - .query(async ({ input }) => { - return await getFromIdHandler({ - db, - storage_id: input.storageId, - }); - }), - getStorageToken: protectedProcedure.mutation(async () => { - const { success } = await ratelimit.limit("getStorageToken"); - if (!success) throw new TRPCError({ code: "TOO_MANY_REQUESTS" }); - const token = (await getNewTokenHandler({ db }))[0]; - if (!token) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Error while creating token", - }); - } - return token; - }), - getStorageTokenM: protectedProcedure - .input(z.object({ num: z.number() })) - .mutation(async ({ input }) => { - const { success } = await ratelimit.limit("getStorageToken"); - if (!success) throw new TRPCError({ code: "TOO_MANY_REQUESTS" }); - return await getNewTokenHandler({ db, num: input.num }); - }), + getStorageVisibility: adminProcedure - .input(z.object({ storageIds: zStorageIndexId.array() })) + .input(z.object({ storageIds: z.string().array() })) .query(async ({ input }) => { return await getMultipleWithRelations({ db, ids: input.storageIds, }); }), - getUserStorage: protectedProcedure - .input(z.object({ userId: zUserId })) - .query(async ({ input }) => { - return await getUserStorageHandler({ db, userId: input.userId }); - }), - removeStorageVisibility: adminProcedure - .input(z.object({ storageId: zStorageIndexId, userId: zUserId })) - .mutation(async ({ input }) => { - await deleteUserStorageJunc({ - db, - storageId: input.storageId, - userId: input.userId, - }); - }), + renameFile: adminProcedure - .input(z.object({ fileId: zStorageIndexId, name: z.string() })) + .input(z.object({ fileId: z.string(), name: z.string() })) .mutation(async ({ input }) => { - return await updateHandler({ - data: { filename: input.name }, - db, - fileId: input.fileId, - }); - }), - resetStorageVisibility: adminProcedure - .input(z.object({ storageId: zStorageIndexId.array() })) - .mutation(async ({ input }) => { - await db - .deleteFrom("users_storage") - .where("storageid", "in", input.storageId) - .execute(); - return "ok"; + return await editFile(input.fileId, input.name); }), }); diff --git a/apps/infoalloggi/src/server/controllers/storage.controller.ts b/apps/infoalloggi/src/server/controllers/storage.controller.ts index f64fa95..d2eb386 100644 --- a/apps/infoalloggi/src/server/controllers/storage.controller.ts +++ b/apps/infoalloggi/src/server/controllers/storage.controller.ts @@ -1,75 +1,121 @@ import { TRPCError } from "@trpc/server"; -import { jsonArrayFrom } from "kysely/helpers/postgres"; -import type { - Storageindex, - StorageindexId, -} from "~/schemas/public/Storageindex"; import type { UsersId } from "~/schemas/public/Users"; -import type { Querier } from "~/server/db"; -import { - addUserStorageJunc, - deleteHandler, - deleteUserStorageJunc, -} from "~/server/services/storage.service"; +import type { NewUsersStorage } from "~/schemas/public/UsersStorage"; +import { db, type Querier } from "~/server/db"; +import { fetchFiles, retriveFilesSafely } from "../services/storage.service"; -export const getAllHandler = async ({ db }: { db: Querier }) => { - return await db.selectFrom("storageindex").selectAll().execute(); -}; - -export const getFromIdHandler = async ({ +export const getMultipleWithRelations = async ({ db, - storage_id, + ids, }: { db: Querier; - storage_id: StorageindexId; + ids: string[]; }) => { - return await db - .selectFrom("storageindex") - .selectAll() - .where("id", "=", storage_id) - .executeTakeFirst(); + const files = await fetchFiles(); + const userStorage = await db + .selectFrom("users_storage") + .selectAll("users_storage") + .innerJoin("users", "users.id", "users_storage.userId") + .select("users.username") + .where( + "storageId", + "in", + files.map((f) => f.id), + ) + + .execute(); + + return ids + .map((id) => { + const file = files.find((f) => f.id === id); + if (!file) return null; + const users = userStorage + .filter((us) => us.storageId === id) + .map((us) => ({ + id: us.userId, + username: us.username, + })); + return { + ...file, + users, + }; + }) + .filter((f) => f !== null); }; -export const getUserStorageHandler = async ({ - db, - userId, -}: { - db: Querier; - userId: UsersId; -}): Promise => { +export const retrieveUserFiles = async ({ userId }: { userId: UsersId }) => { try { - return await db - .selectFrom("storageindex") - .selectAll("storageindex") - .rightJoin("users_storage", "users_storage.storageid", "storageindex.id") - .where("users_storage.userid", "=", userId) - .orderBy("created_at", "desc") + //all files linked to the user + const files = await db + .selectFrom("users_storage") + .select(["storageId", "from_admin"]) + .where("userId", "=", userId) .execute(); - } catch { + const { available, unavailable } = await retriveFilesSafely( + files.map((f) => f.storageId), + ); + + //remove entries in users_storage for files that no longer exist in storage + await db + .deleteFrom("users_storage") + .where("userId", "=", userId) + .where("storageId", "in", unavailable) + .execute(); + + //merge file metadata with users_storage info + const userFiles = files + .map((uf) => { + const file = available.find((f) => f.id === uf.storageId); + if (!file) return null; + return { + ...file, + from_admin: uf.from_admin, + }; + }) + .filter((f) => f !== null); + + return userFiles; + } catch (e) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Error while getting user storage", + message: `Error while getting user storage: ${(e as Error).message}`, }); } }; -export const addUserStorageHandler = async ({ - db, - userId, - storageId, -}: { - db: Querier; - userId: UsersId; - storageId: StorageindexId; -}) => { +export const getAllWithFromAdmin = async () => { try { - await addUserStorageJunc({ - db, - storageId, - userId, + const files = await db + .selectFrom("users_storage") + .select(["storageId", "from_admin"]) + .execute(); + + const allFiles = await fetchFiles(); + const allFilesWithFromAdmin = allFiles.map((file) => { + const userStorageEntry = files.find((f) => f.storageId === file.id); + return { + ...file, + from_admin: userStorageEntry ? userStorageEntry.from_admin : true, + }; }); - return "ok"; + return allFilesWithFromAdmin; + } catch (e) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error while getting user storage: ${(e as Error).message}`, + }); + } +}; + +export const addUserStorage = async (data: NewUsersStorage) => { + try { + return await db + .insertInto("users_storage") + .values(data) + .onConflict((oc) => oc.columns(["userId", "storageId"]).doNothing()) + .returningAll() + .execute(); } catch (e) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", @@ -78,114 +124,58 @@ export const addUserStorageHandler = async ({ } }; -export const deleteUserStorageHandler = async ({ - db, - userId, - storageId, -}: { - db: Querier; - userId: UsersId; - storageId: StorageindexId; -}) => { +export const getUserStorage = async ({ userId }: { userId: UsersId }) => { try { - const file = await db + return await db .selectFrom("users_storage") + .where("userId", "=", userId) .selectAll() - .where("userid", "=", userId) - .where("storageid", "=", storageId) - .executeTakeFirst(); - if (!file) { - return "no file"; - } - - await deleteHandler({ - cb: async (fileId) => { - await db.transaction().execute(async (trx) => { - await deleteUserStorageJunc({ - db: trx, - storageId: fileId, - userId, - }); - - await trx - .deleteFrom("storageindex") - .where("id", "=", fileId) - .execute(); - }); - }, - db, - fileId: file.storageid, - }); - return "ok"; - } catch { + .execute(); + } catch (e) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "Error while deleting user storage", + message: `Error while getting user storage: ${(e as Error).message}`, }); } }; -// export const deleteAllUserStorageHandler = async ({ -// db, -// userId, -// }: { -// db: Querier; -// userId: UsersId; -// }) => { -// try { -// const files = await db -// .selectFrom("users_storage") -// .select("storageid") -// .where("userid", "=", userId) -// .execute(); - -// if (!files || files.length === 0) { -// return "no files"; -// } -// await deleteFiles({ -// db, -// refs: files, -// cb: async (fileIds) => { -// const ids = fileIds.map((file) => file.storageid); -// await db.transaction().execute(async (trx) => { -// await trx -// .deleteFrom("users_storage") -// .where("userid", "=", userId) -// .where("storageid", "in", ids) -// .execute(); -// await trx.deleteFrom("storageindex").where("id", "in", ids).execute(); -// }); -// }, -// }); -// return "ok"; -// } catch { -// throw new TRPCError({ -// code: "INTERNAL_SERVER_ERROR", -// message: "Error while deleting user storage", -// }); -// } -// }; - -export const getMultipleWithRelations = async ({ - db, - ids, +export const deleteUserStorageEntry = async ({ + userId, + storageId, }: { - db: Querier; - ids: StorageindexId[]; + userId: UsersId; + storageId: string; }) => { - return await db - .selectFrom("storageindex") - .selectAll("storageindex") - .select((eb) => [ - jsonArrayFrom( - eb - .selectFrom("users_storage") - .leftJoin("users", "users.id", "users_storage.userid") - .select(["users.username"]) - .whereRef("users_storage.storageid", "=", "storageindex.id") - .selectAll("users_storage"), - ).as("users_storages"), - ]) - .where("id", "in", ids) - .execute(); + try { + await db + .deleteFrom("users_storage") + .where("userId", "=", userId) + .where("storageId", "=", storageId) + .execute(); + return "ok"; + } catch (e) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error while deleting user storage: ${(e as Error).message}`, + }); + } +}; + +export const purgeFileFromUserStorages = async ({ + storageId, +}: { + storageId: string; +}) => { + try { + await db + .deleteFrom("users_storage") + .where("storageId", "=", storageId) + .execute(); + return "ok"; + } catch (e) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Error while deleting user storage: ${(e as Error).message}`, + }); + } }; diff --git a/apps/infoalloggi/src/server/services/storage.service.ts b/apps/infoalloggi/src/server/services/storage.service.ts index 33f9c6d..8d743ce 100644 --- a/apps/infoalloggi/src/server/services/storage.service.ts +++ b/apps/infoalloggi/src/server/services/storage.service.ts @@ -1,146 +1,210 @@ -import { TRPCError } from "@trpc/server"; +import z from "zod"; import { env } from "~/env"; -import type { - StorageindexId, - StorageindexUpdate, -} from "~/schemas/public/Storageindex"; -import type { TempTokensToken } from "~/schemas/public/TempTokens"; -import type { UsersId } from "~/schemas/public/Users"; -import type { Querier } from "~/server/db"; -import { deleteFile } from "../storage"; -export const getNewTokenHandler = async ({ - db, - num, -}: { - db: Querier; - num?: number; -}): Promise => { - const tokens: TempTokensToken[] = []; - if (num && num > 1) { - for (let i = 0; i < num; i++) { - const tok = await newToken({ db }); +const FileMetadataSchema = z.object({ + id: z.string(), + originalName: z.string(), + size: z.number(), + mimeType: z.string(), + blockCount: z.number(), + uploadedAt: z.string(), + expiresAt: z.string().nullable(), +}); - tokens.push(tok.token); - } - return tokens; - } - const tok = await newToken({ db }); - return [tok.token]; +export type FileMetadata = z.infer; + +export type FileMetadataWithAdmin = FileMetadata & { + from_admin: boolean; }; -const newToken = async ({ db }: { db: Querier }) => { +// Helper to fetch the list of all files +export async function fetchFiles(): Promise { try { - return await db - .insertInto("temp_tokens") - .defaultValues() - .returning("token") - .executeTakeFirstOrThrow(); - } catch { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Error while creating token", - }); - } -}; - -export const deleteHandler = async ({ - db, - fileId, - cb, -}: { - db: Querier; - fileId: StorageindexId; - cb?: (fileId: StorageindexId) => Promise; -}) => { - try { - const storage = await db - .selectFrom("storageindex") - .select(["id", "ext"]) - .where("id", "=", fileId) - .executeTakeFirstOrThrow(); - await deleteFile(storage.id); - - if (cb) { - await cb(fileId); + const response = await fetch( + `${env.STORAGE_URL}/files?token=${env.STORAGE_TOKEN}`, + ); + if (!response.ok) { + console.error("Failed to fetch file list:", response.statusText); + return []; } + const parse = FileMetadataSchema.array().safeParse(await response.json()); + + if (parse.success) { + return parse.data; + } + console.error("Failed to parse file metadata:", parse.error); + return []; } catch (error) { - console.error("Error:", error); - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "Error while deleting file", - }); + console.error("Error fetching file list:", error); + return []; } -}; +} -export const updateHandler = async ({ - db, - fileId, - data, -}: { - db: Querier; - fileId: StorageindexId; - data: StorageindexUpdate; -}) => { +export async function getFileInfo(id: string): Promise { try { - await db - .updateTable("storageindex") - .set(data) - .where("id", "=", fileId) - .execute(); - return "ok"; - } catch (e) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Error while updating file: ${(e as Error).message}`, - }); - } -}; + const response = await fetch( + `${env.STORAGE_URL}/info/${id}?token=${env.STORAGE_TOKEN}`, + ); + if (!response.ok) { + console.error("Failed to fetch file list:", response.statusText); + return null; + } + const parse = FileMetadataSchema.safeParse(await response.json()); -export const addUserStorageJunc = async ({ - db, - userId, - storageId, -}: { - db: Querier; - userId: UsersId; - storageId: StorageindexId; -}) => { - try { - return await db - .insertInto("users_storage") - .values({ storageid: storageId, userid: userId }) - .onConflict((oc) => oc.columns(["userid", "storageid"]).doNothing()) - .returningAll() - .execute(); - } catch (e) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Error while adding user storage: ${(e as Error).message}`, - }); + if (parse.success) { + return parse.data; + } + console.error("Failed to parse file metadata:", parse.error); + return null; + } catch (error) { + console.error("Error fetching file list:", error); + return null; } -}; +} -export const deleteUserStorageJunc = async ({ - db, - userId, - storageId, -}: { - db: Querier; - userId: UsersId; - storageId: StorageindexId; -}) => { - try { - await db - .deleteFrom("users_storage") - .where("userid", "=", userId) - .where("storageid", "=", storageId) - .execute(); - return "ok"; - } catch (e) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Error while deleting user storage: ${(e as Error).message}`, - }); +type EditResponse = + | { + success: true; + id: string; + } + | { + success: false; + error: string; + }; +export async function editFile( + id: string, + filename?: string, + expiresAt?: string, +): Promise { + const formData = new FormData(); + formData.append("id", id); + if (expiresAt) { + formData.append("expires_at", expiresAt); } -}; + if (filename) { + formData.append("filename", filename); + } + + try { + const response = await fetch( + `${env.STORAGE_URL}/edit?token=${env.STORAGE_TOKEN}`, + { + method: "POST", + body: formData, + }, + ); + + if (response.ok) { + const res = await response.json(); + const parsed = z + .object({ + id: z.string(), + }) + .parse(res); + const fileID = parsed.id; + console.log("File edited successfully."); + return { success: true, id: fileID }; + } + + return { + success: false, + error: `Modifica fallita con status: ${response.status}`, + }; + } catch (e) { + return { + success: false, + error: `Errore Modifica file: ${(e as Error).message}`, + }; + } +} + +type DeleteResponse = + | { + success: true; + } + | { + success: false; + error: string; + }; +// Helper to handle file deletion +export async function deleteFile(fileID: string): Promise { + try { + // The Go server accepts POST with form data for delete + const response = await fetch( + `${env.STORAGE_URL}/delete/${fileID}?token=${env.STORAGE_TOKEN}`, + { + method: "DELETE", + }, + ); + + // Treat redirect as success + if (response.ok || (response.status >= 300 && response.status < 400)) { + console.log(`File ${fileID} deleted successfully.`); + return { success: true }; + } + + return { + success: false, + error: `Eliminazione fallita con status: ${response.status}`, + }; + } catch (error) { + console.error("Error during file deletion:", error); + return { success: false, error: (error as Error).message }; + } +} + +// export const handleDownload = async (file: FileMetadata) => { +// try { +// const response = await fetch( +// `${env.STORAGE_URL}/get/${file.id}?token=${env.STORAGE_TOKEN}&mode=download`, +// ); + +// if (!response.ok) { +// throw new Error("File fetch failed"); +// } + +// const blob = await response.blob(); +// const url = window.URL.createObjectURL(blob); +// const a = document.createElement("a"); +// a.href = url; +// a.download = file.originalName; +// document.body.appendChild(a); +// a.click(); +// a.remove(); +// window.URL.revokeObjectURL(url); +// } catch { +// throw new Error("File download failed"); +// } +// }; + +const safeRetrive = z.object({ + available: z.array(FileMetadataSchema), + unavailable: z.array(z.string()), +}); + +export async function retriveFilesSafely(ids: string[]) { + try { + const response = await fetch( + `${env.STORAGE_URL}/files-check?token=${env.STORAGE_TOKEN}`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(ids), + }, + ); + + if (!response.ok) { + throw new Error("File fetch failed"); + } + const parse = safeRetrive.safeParse(await response.json()); + + if (parse.success) { + return parse.data; + } + throw new Error("File fetch parse failed"); + } catch (e) { + throw new Error(`File fetch failed: ${(e as Error).message}`); + } +} diff --git a/apps/infoalloggi/src/server/storage.ts b/apps/infoalloggi/src/server/storage.ts deleted file mode 100644 index 42b10ab..0000000 --- a/apps/infoalloggi/src/server/storage.ts +++ /dev/null @@ -1,87 +0,0 @@ -import z from "zod"; - -const FileMetadataSchema = z.object({ - id: z.string(), - originalName: z.string(), - size: z.number(), - mimeType: z.string(), - blockCount: z.number(), - uploadedAt: z.string(), - expiresAt: z.string().nullable(), -}); - -export type FileMetadata = z.infer; - -// Helper to fetch the list of all files -export async function fetchFiles(): Promise { - try { - const response = await fetch(`/storage-api/api/files`); - if (!response.ok) { - console.error("Failed to fetch file list:", response.statusText); - return []; - } - const parse = FileMetadataSchema.array().safeParse(await response.json()); - - if (parse.success) { - return parse.data; - } - console.error("Failed to parse file metadata:", parse.error); - return []; - } catch (error) { - console.error("Error fetching file list:", error); - return []; - } -} - -// Helper to handle file uploads -export async function uploadFile( - file: File, - expiresAt: string | undefined, -): Promise { - const formData = new FormData(); - formData.append("file", file); - if (expiresAt) { - formData.append("expires_at", expiresAt); - } - - try { - const response = await fetch(`/storage-api/upload`, { - method: "POST", - body: formData, - }); - - // The Go server redirects on success (StatusSeeOther), which may cause CORS issues - // or be difficult to read in a browser environment. - // We'll treat any successful status (2xx or 3xx) as a success for now. - if (response.ok || (response.status >= 300 && response.status < 400)) { - console.log("File uploaded successfully."); - return true; - } - - throw new Error(`Upload failed with status: ${response.status}`); - } catch (error) { - console.error("Error during file upload:", error); - return false; - } -} - -// Helper to delete a file -export async function deleteFile(fileID: string): Promise { - try { - // The Go server accepts POST with form data for delete - const response = await fetch(`/storage-api/delete/${fileID}`, { - method: "DELETE", - }); - - // Treat redirect as success - if (response.ok || (response.status >= 300 && response.status < 400)) { - console.log(`File ${fileID} deleted successfully.`); - return true; - } - - throw new Error(`Delete failed with status: ${response.status}`); - } catch (error) { - console.error("Error during file deletion:", error); - return false; - } -} diff --git a/apps/infoalloggi/src/server/utils/zod_types.ts b/apps/infoalloggi/src/server/utils/zod_types.ts index fa5e460..92acf52 100644 --- a/apps/infoalloggi/src/server/utils/zod_types.ts +++ b/apps/infoalloggi/src/server/utils/zod_types.ts @@ -10,7 +10,6 @@ import type { OrdiniOrdineId } from "~/schemas/public/Ordini"; import type { PaymentsId } from "~/schemas/public/Payments"; import type { PrezziarioIdprezziario } from "~/schemas/public/Prezziario"; import type { ServizioServizioId } from "~/schemas/public/Servizio"; -import type { StorageindexId } from "~/schemas/public/Storageindex"; import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe"; import type { UsersId } from "~/schemas/public/Users"; import type { EventDataTypes } from "~/server/controllers/event_queue.controller"; @@ -45,11 +44,6 @@ export const zFlagsId = z.custom( "falied to validate FlagsId", ); -export const zStorageIndexId = z.custom( - (val) => typeof val === "string", - "falied to validate StorageIndexId", -); - export const zEtichettaId = z.custom( (val) => typeof val === "number", "falied to validate EtichettaId",