From 7b7ed05229b9c6b9104304350da1299e41e2aa51 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 12 Jun 2026 03:39:40 +0200 Subject: [PATCH] DataGrid fix; File explorer - inital commit --- .env | 12 + .env.example | 12 + ...atagrid-individual-resize-fix-20260611.zip | Bin 10945 -> 0 bytes src/App.tsx | 2 +- src/api/files.ts | 210 ++ src/components/table/DataGrid.tsx | 87 +- src/components/table/DataGrid.tsx.old | 730 +++++ .../campaigns/AttachmentsDataPage.tsx | 6 +- src/features/files/FilesPage.tsx | 2391 +++++++++++++++-- src/styles/campaign-workspace.css | 712 +++++ src/styles/components.css | 7 +- src/styles/layout.css | 2 +- 12 files changed, 3922 insertions(+), 249 deletions(-) delete mode 100644 multi-seal-mail-webui-datagrid-individual-resize-fix-20260611.zip create mode 100644 src/api/files.ts create mode 100644 src/components/table/DataGrid.tsx.old diff --git a/.env b/.env index 6c9bbfd..49f4964 100644 --- a/.env +++ b/.env @@ -1 +1,13 @@ VITE_API_BASE_URL=http://127.0.0.1:8000 + +# Web UI +WEBUI_PUBLISHED_PORT=5173 +VITE_API_BASE_URL=/api/v1 +# For local Vite development outside Docker: +VITE_DEV_API_PROXY_TARGET=http://127.0.0.1:8000 +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080 +MULTIMAILER_HOST=msm.localhost +TRAEFIK_API_ROUTER_NAME=msm-api +TRAEFIK_API_SERVICE_NAME=msm-api +TRAEFIK_WEBUI_ROUTER_NAME=msm-webui +TRAEFIK_WEBUI_SERVICE_NAME=msm-webui diff --git a/.env.example b/.env.example index 6c9bbfd..d4554eb 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,13 @@ VITE_API_BASE_URL=http://127.0.0.1:8000 + +# Web UI +WEBUI_PUBLISHED_PORT=5173 +VITE_API_BASE_URL=/api/v1 +# For local Vite development outside Docker: +# VITE_DEV_API_PROXY_TARGET=http://127.0.0.1:8000 +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080 +MULTIMAILER_HOST=multimailer.localhost +TRAEFIK_API_ROUTER_NAME=multimailer-api +TRAEFIK_API_SERVICE_NAME=multimailer-api +TRAEFIK_WEBUI_ROUTER_NAME=multimailer-webui +TRAEFIK_WEBUI_SERVICE_NAME=multimailer-webui diff --git a/multi-seal-mail-webui-datagrid-individual-resize-fix-20260611.zip b/multi-seal-mail-webui-datagrid-individual-resize-fix-20260611.zip deleted file mode 100644 index 2ecebfaf8a0315af20b85be3060a1221b37674e8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10945 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 /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/src/api/files.ts b/src/api/files.ts new file mode 100644 index 0000000..630e3cb --- /dev/null +++ b/src/api/files.ts @@ -0,0 +1,210 @@ +import { apiFetch } from "./client"; +import type { ApiSettings } from "../types"; + +export type FileSpace = { + id: string; + label: string; + owner_type: "user" | "group"; + owner_id: string; + description?: string | null; +}; + +export type FileShare = { + id: string; + target_type: string; + target_id: string; + permission: string; + created_at: string; + revoked_at?: string | null; +}; + +export type ManagedFile = { + id: string; + tenant_id: string; + owner_type: "user" | "group"; + owner_id: string; + display_path: string; + filename: string; + description?: string | null; + size_bytes: number; + content_type?: string | null; + checksum_sha256: string; + version_id: string; + created_at: string; + updated_at: string; + deleted_at?: string | null; + audit_relevant: boolean; + metadata?: Record | null; + shares?: FileShare[]; +}; + +export type FileListResponse = { files: ManagedFile[] }; +export type FileSpacesResponse = { spaces: FileSpace[] }; +export type FileUploadResponse = { files: ManagedFile[] }; +export type FileFolder = { + id: string; + tenant_id: string; + owner_type: "user" | "group"; + owner_id: string; + path: string; + created_at: string; + updated_at: string; + deleted_at?: string | null; +}; +export type FileFoldersResponse = { folders: FileFolder[] }; +export type FolderDeleteResponse = { deleted_folders: number; deleted_files: number }; +export type BulkDeleteResponse = { deleted_count: number }; +export type RenameResponse = { dry_run: boolean; items: { kind: "file" | "folder"; id: string; file_id?: string | null; folder_path?: string | null; old_path: string; new_path: string }[] }; +export type TransferResponse = { operation: "move" | "copy"; files: number; folders: number }; +export type ConflictAction = "overwrite" | "rename" | "skip"; +export type ConflictStrategy = "reject" | "overwrite" | "rename"; +export type ConflictResolution = { target_path: string; action: ConflictAction; new_path?: string }; +export type PatternResolveResponse = { + patterns: { pattern: string; matches: ManagedFile[] }[]; + unmatched: ManagedFile[]; +}; + +function authHeaders(settings: ApiSettings): Headers { + const headers = new Headers(); + if (settings.accessToken) headers.set("Authorization", `Bearer ${settings.accessToken}`); + else if (settings.apiKey) headers.set("X-API-Key", settings.apiKey); + return headers; +} + +function apiUrl(settings: ApiSettings, path: string): string { + const baseUrl = settings.apiBaseUrl.trim().replace(/\/$/, ""); + return baseUrl ? `${baseUrl}${path}` : path; +} + +export function listFileSpaces(settings: ApiSettings): Promise { + return apiFetch(settings, "/api/v1/files/spaces"); +} + + +export function listFolders(settings: ApiSettings, params: { owner_type: "user" | "group"; owner_id: string }): Promise { + const search = new URLSearchParams(); + search.set("owner_type", params.owner_type); + search.set("owner_id", params.owner_id); + return apiFetch(settings, `/api/v1/files/folders?${search.toString()}`); +} + +export function createFolder( + settings: ApiSettings, + payload: { owner_type: "user" | "group"; owner_id: string; path: string } +): Promise { + return apiFetch(settings, "/api/v1/files/folders", { method: "POST", body: JSON.stringify(payload) }); +} + +export function deleteFolder( + settings: ApiSettings, + payload: { owner_type: "user" | "group"; owner_id: string; path: string; recursive?: boolean } +): Promise { + return apiFetch(settings, "/api/v1/files/folders/delete", { method: "POST", body: JSON.stringify({ recursive: true, ...payload }) }); +} + +export function listFiles(settings: ApiSettings, params: { owner_type?: string; owner_id?: string; campaign_id?: string; path_prefix?: string } = {}): Promise { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value) search.set(key, value); + } + const suffix = search.toString() ? `?${search.toString()}` : ""; + return apiFetch(settings, `/api/v1/files${suffix}`); +} + +export async function uploadFiles( + settings: ApiSettings, + files: File[], + options: { owner_type: "user" | "group"; owner_id: string; path?: string; campaign_id?: string; unpack_zip?: boolean; conflict_strategy?: ConflictStrategy; conflict_resolutions?: ConflictResolution[] } +): Promise { + const form = new FormData(); + files.forEach((file) => form.append("files", file)); + form.append("owner_type", options.owner_type); + form.append("owner_id", options.owner_id); + form.append("path", options.path ?? ""); + if (options.campaign_id) form.append("campaign_id", options.campaign_id); + if (options.unpack_zip) form.append("unpack_zip", "true"); + if (options.conflict_strategy) form.append("conflict_strategy", options.conflict_strategy); + if (options.conflict_resolutions?.length) form.append("conflict_resolutions_json", JSON.stringify(options.conflict_resolutions)); + return apiFetch(settings, "/api/v1/files/upload", { method: "POST", body: form }); +} + +export function deleteFile(settings: ApiSettings, fileId: string): Promise { + return apiFetch(settings, `/api/v1/files/${fileId}`, { method: "DELETE" }); +} + +export function bulkDeleteFiles(settings: ApiSettings, fileIds: string[]): Promise { + return apiFetch(settings, "/api/v1/files/bulk-delete", { method: "POST", body: JSON.stringify({ file_ids: fileIds }) }); +} + +export function shareFileWithCampaign(settings: ApiSettings, fileId: string, campaignId: string): Promise { + return apiFetch(settings, `/api/v1/files/${fileId}/shares`, { + method: "POST", + body: JSON.stringify({ target_type: "campaign", target_id: campaignId, permission: "read" }) + }); +} + +export function bulkRenameFiles( + settings: ApiSettings, + payload: { file_ids: string[]; folder_paths?: string[]; owner_type?: "user" | "group"; owner_id?: string; mode: "direct" | "prefix" | "suffix" | "replace"; new_name?: string; find?: string; replacement?: string; prefix?: string; suffix?: string; recursive?: boolean; dry_run?: boolean } +): Promise { + return apiFetch(settings, "/api/v1/files/bulk-rename", { + method: "POST", + body: JSON.stringify({ replacement: "", prefix: "", suffix: "", dry_run: true, ...payload }) + }); +} + +export function resolveFilePatterns( + settings: ApiSettings, + payload: { patterns: string[]; owner_type?: "user" | "group"; owner_id?: string; campaign_id?: string; path_prefix?: string; include_unmatched?: boolean; case_sensitive?: boolean } +): Promise { + return apiFetch(settings, "/api/v1/files/resolve-patterns", { method: "POST", body: JSON.stringify(payload) }); +} + +export function transferFiles( + settings: ApiSettings, + payload: { + operation: "move" | "copy"; + file_ids: string[]; + folder_paths: string[]; + source_owner_type: "user" | "group"; + source_owner_id: string; + target_owner_type: "user" | "group"; + target_owner_id: string; + target_folder: string; + conflict_strategy?: ConflictStrategy; + conflict_resolutions?: ConflictResolution[]; + } +): Promise { + return apiFetch(settings, "/api/v1/files/transfer", { method: "POST", body: JSON.stringify(payload) }); +} + +export async function downloadFile(settings: ApiSettings, file: ManagedFile): Promise { + const response = await fetch(apiUrl(settings, `/api/v1/files/${file.id}/download`), { headers: authHeaders(settings) }); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`); + const blob = await response.blob(); + triggerDownload(blob, file.filename); +} + +export async function downloadFilesAsZip(settings: ApiSettings, fileIds: string[], filename = "files.zip"): Promise { + const headers = authHeaders(settings); + headers.set("Content-Type", "application/json"); + const response = await fetch(apiUrl(settings, "/api/v1/files/archive.zip"), { + method: "POST", + headers, + body: JSON.stringify({ file_ids: fileIds, filename }) + }); + if (!response.ok) throw new Error(`${response.status} ${response.statusText}: ${await response.text()}`); + const blob = await response.blob(); + triggerDownload(blob, filename); +} + +function triggerDownload(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} diff --git a/src/components/table/DataGrid.tsx b/src/components/table/DataGrid.tsx index f05f8e9..a0d5e3e 100644 --- a/src/components/table/DataGrid.tsx +++ b/src/components/table/DataGrid.tsx @@ -31,6 +31,7 @@ type DataGridState = { sort?: { columnId: string; direction: DataGridSortDirection }; filters?: Record; widths?: Record; + fillColumnId?: string; }; type DataGridProps = { @@ -68,7 +69,13 @@ export default function DataGrid({ }: DataGridProps) { const localStorageKey = storageKey ?? `${STORAGE_PREFIX}${id}`; const [state, setState] = useState(() => loadState(localStorageKey)); - const [resizeState, setResizeState] = useState<{ columnId: string; startX: number; startWidth: number } | null>(null); + const [resizeState, setResizeState] = useState<{ + columnId: string; + startX: number; + startWidth: number; + baseWidths: Record; + fillColumnId?: string; + } | null>(null); const [openFilterColumnId, setOpenFilterColumnId] = useState(null); const [filterPosition, setFilterPosition] = useState(null); const gridRef = useRef(null); @@ -138,7 +145,11 @@ export default function DataGrid({ const minWidth = column?.minWidth ?? 80; const maxWidth = column?.maxWidth ?? 2000; const nextWidth = Math.min(maxWidth, Math.max(minWidth, activeResize.startWidth + event.clientX - activeResize.startX)); - setState((current) => ({ ...current, widths: { ...(current.widths ?? {}), [activeResize.columnId]: nextWidth } })); + setState((current) => ({ + ...current, + widths: { ...activeResize.baseWidths, [activeResize.columnId]: nextWidth }, + fillColumnId: activeResize.fillColumnId + })); } function onUp() { setResizeState(null); @@ -214,7 +225,7 @@ export default function DataGrid({ }); }, [rows, columns, state.filters, state.sort, filterTypes]); - const stretchedColumnIds = useMemo(() => chooseStretchedColumns(columns, state.widths), [columns, state.widths]); + const stretchedColumnIds = useMemo(() => chooseStretchedColumns(columns, state.widths, state.fillColumnId), [columns, state.widths, state.fillColumnId]); const templateColumns = columns.map((column) => widthForColumn(column, state.widths?.[column.id], stretchedColumnIds.has(column.id))).join(" "); const hasFlexibleColumns = columns.some((column) => stretchedColumnIds.has(column.id) || isFlexibleColumn(column, state.widths?.[column.id])); const stickyOffsets = useMemo(() => computeStickyOffsets(columns, state.widths, measuredWidths), [columns, state.widths, measuredWidths]); @@ -301,9 +312,11 @@ export default function DataGrid({ onMouseDown={(event) => { event.preventDefault(); event.stopPropagation(); - const headerElement = headerCellRefs.current[column.id]; - const currentWidth = headerElement ? Math.round(headerElement.getBoundingClientRect().width) : columnPixelWidth(column, state.widths?.[column.id], measuredWidths[column.id]); - setResizeState({ columnId: column.id, startX: event.clientX, startWidth: currentWidth }); + const baseWidths = measuredColumnWidths(columns, headerCellRefs.current, state.widths, measuredWidths); + const currentWidth = baseWidths[column.id] ?? columnPixelWidth(column, state.widths?.[column.id], measuredWidths[column.id]); + const fillColumnId = chooseResizeFillColumn(columns, column.id); + setState((current) => ({ ...current, widths: { ...baseWidths }, fillColumnId })); + setResizeState({ columnId: column.id, startX: event.clientX, startWidth: currentWidth, baseWidths, fillColumnId }); }} >