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 2ecebfa..0000000 Binary files a/multi-seal-mail-webui-datagrid-individual-resize-fix-20260611.zip and /dev/null differ diff --git a/src/App.tsx b/src/App.tsx index 0091b10..deb9d26 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -104,7 +104,7 @@ export default function App() { } /> } /> } /> - } /> + } /> } /> } /> } /> 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 }); }} >