diff --git a/README.md b/README.md index ab55cbe..5c79fe5 100644 --- a/README.md +++ b/README.md @@ -49,3 +49,7 @@ Frontend package: The campaign module can integrate with files when both modules are installed, for example for managed attachment selection and campaign file sharing. Platform RBAC and governance rules are documented in `govoplan-core/docs/`. + +## Release packaging + +The repository root includes a `package.json` for git-based WebUI installs. It exports the package `@govoplan/files-webui` from `webui/src` so release builds can depend on tagged git refs instead of local `file:` paths. diff --git a/package.json b/package.json new file mode 100644 index 0000000..923d1a4 --- /dev/null +++ b/package.json @@ -0,0 +1,31 @@ +{ + "name": "@govoplan/files-webui", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "webui/src/index.ts", + "module": "webui/src/index.ts", + "types": "webui/src/index.ts", + "exports": { + ".": { + "types": "./webui/src/index.ts", + "import": "./webui/src/index.ts" + }, + "./styles/file-manager.css": "./webui/src/styles/file-manager.css" + }, + "files": [ + "webui/src", + "README.md", + "LICENSE" + ], + "peerDependencies": { + "@govoplan/core-webui": "^0.1.0", + "lucide-react": "^0.555.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.1.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.7.2", + "vite": "^6.0.6" + } +} diff --git a/src/govoplan_files/backend/storage/archives.py b/src/govoplan_files/backend/storage/archives.py index f838515..a6116df 100644 --- a/src/govoplan_files/backend/storage/archives.py +++ b/src/govoplan_files/backend/storage/archives.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Session from govoplan_files.backend.db.models import FileAsset from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile -from govoplan_files.backend.storage.backends import get_storage_backend +from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend from govoplan_files.backend.storage.files import create_file_asset, current_version_and_blob from govoplan_files.backend.storage.paths import filename_from_path, normalize_folder, normalize_logical_path @@ -51,9 +51,12 @@ def create_zip_file(session: Session, assets: Iterable[FileAsset], output_path: info = zipfile.ZipInfo(asset.display_path) info.compress_type = zipfile.ZIP_DEFLATED with archive.open(info, "w") as member: - for chunk in backend.iter_bytes(blob.storage_key): - if chunk: - member.write(chunk) + try: + for chunk in backend.iter_bytes(blob.storage_key): + if chunk: + member.write(chunk) + except StorageBackendError as exc: + raise FileStorageError(str(exc)) from exc def extract_zip_upload( diff --git a/src/govoplan_files/backend/storage/backends.py b/src/govoplan_files/backend/storage/backends.py index 1f8c638..ea6746d 100644 --- a/src/govoplan_files/backend/storage/backends.py +++ b/src/govoplan_files/backend/storage/backends.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Iterable, Protocol @@ -26,33 +26,43 @@ class StorageBackend(Protocol): @dataclass(slots=True) class LocalFilesystemStorageBackend: root: Path + fallback_roots: tuple[Path, ...] = field(default_factory=tuple) name: str = "local" def __post_init__(self) -> None: self.root = self.root.expanduser().resolve() + self.fallback_roots = tuple(root.expanduser().resolve() for root in self.fallback_roots if root) self.root.mkdir(parents=True, exist_ok=True) - def _path(self, key: str) -> Path: - path = (self.root / key).resolve() - if not path.is_relative_to(self.root): + def _path_for_root(self, root: Path, key: str) -> Path: + path = (root / key).resolve() + if not path.is_relative_to(root): raise StorageBackendError("Storage key escapes local storage root") return path + def _path(self, key: str) -> Path: + return self._path_for_root(self.root, key) + + def _readable_path(self, key: str) -> Path: + primary = self._path(key) + if primary.exists() and primary.is_file(): + return primary + for root in self.fallback_roots: + candidate = self._path_for_root(root, key) + if candidate.exists() and candidate.is_file(): + return candidate + raise StorageBackendError("Stored object does not exist") + def put_bytes(self, key: str, data: bytes, *, content_type: str | None = None) -> None: path = self._path(key) path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) def get_bytes(self, key: str) -> bytes: - path = self._path(key) - if not path.exists() or not path.is_file(): - raise StorageBackendError("Stored object does not exist") - return path.read_bytes() + return self._readable_path(key).read_bytes() def iter_bytes(self, key: str, *, chunk_size: int = 1024 * 1024) -> Iterable[bytes]: - path = self._path(key) - if not path.exists() or not path.is_file(): - raise StorageBackendError("Stored object does not exist") + path = self._readable_path(key) with path.open("rb") as handle: while True: chunk = handle.read(chunk_size) @@ -66,8 +76,11 @@ class LocalFilesystemStorageBackend: path.unlink() def exists(self, key: str) -> bool: - path = self._path(key) - return path.exists() and path.is_file() + try: + self._readable_path(key) + except StorageBackendError: + return False + return True @dataclass(slots=True) @@ -125,10 +138,15 @@ class S3StorageBackend: return False +def _fallback_roots() -> tuple[Path, ...]: + raw = getattr(settings, "file_storage_local_fallback_roots", "") or "" + return tuple(Path(item.strip()) for item in str(raw).split(",") if item.strip()) + + def get_storage_backend() -> StorageBackend: backend = settings.file_storage_backend.lower().strip() if backend in {"local", "filesystem", "fs"}: - return LocalFilesystemStorageBackend(Path(settings.file_storage_local_root)) + return LocalFilesystemStorageBackend(Path(settings.file_storage_local_root), fallback_roots=_fallback_roots()) if backend in {"s3", "garage"}: return S3StorageBackend( bucket=settings.file_storage_s3_bucket or settings.s3_bucket, diff --git a/src/govoplan_files/backend/storage/files.py b/src/govoplan_files/backend/storage/files.py index 8ef32a0..e99d18a 100644 --- a/src/govoplan_files/backend/storage/files.py +++ b/src/govoplan_files/backend/storage/files.py @@ -14,7 +14,7 @@ from govoplan_campaign.backend.db.models import Campaign from govoplan_files.backend.db.models import CampaignAttachmentUse, FileAsset, FileBlob, FileShare, FileVersion from govoplan_files.backend.runtime import settings from govoplan_files.backend.storage.access import ensure_owner_access, user_group_ids -from govoplan_files.backend.storage.backends import get_storage_backend +from govoplan_files.backend.storage.backends import StorageBackendError, get_storage_backend from govoplan_files.backend.storage.common import FileConflictResolution, FileStorageError, UploadedStoredFile, utcnow from govoplan_files.backend.storage.paths import filename_from_path, join_folder_filename, normalize_folder, normalize_logical_path, safe_storage_component @@ -56,13 +56,22 @@ def _get_or_create_blob( .one_or_none() ) if blob: + backend = get_storage_backend() + if not backend.exists(blob.storage_key): + try: + backend.put_bytes(blob.storage_key, data, content_type=content_type) + except StorageBackendError as exc: + raise FileStorageError(str(exc)) from exc blob.ref_count += 1 session.add(blob) return blob storage_key = _storage_key(tenant_id=tenant_id, checksum=checksum, filename=filename) backend = get_storage_backend() - backend.put_bytes(storage_key, data, content_type=content_type) + try: + backend.put_bytes(storage_key, data, content_type=content_type) + except StorageBackendError as exc: + raise FileStorageError(str(exc)) from exc blob = FileBlob( tenant_id=tenant_id, storage_backend=_storage_backend_name(), @@ -250,7 +259,10 @@ def current_version_and_blob(session: Session, asset: FileAsset) -> tuple[FileVe def read_asset_bytes(session: Session, asset: FileAsset) -> tuple[bytes, FileVersion, FileBlob]: version, blob = current_version_and_blob(session, asset) backend = get_storage_backend() - return backend.get_bytes(blob.storage_key), version, blob + try: + return backend.get_bytes(blob.storage_key), version, blob + except StorageBackendError as exc: + raise FileStorageError(str(exc)) from exc def share_file( diff --git a/webui/src/features/files/components/FileManagerComponents.tsx b/webui/src/features/files/components/FileManagerComponents.tsx index 70e7c7c..bc642da 100644 --- a/webui/src/features/files/components/FileManagerComponents.tsx +++ b/webui/src/features/files/components/FileManagerComponents.tsx @@ -1,6 +1,6 @@ import type { CSSProperties, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, ReactNode } from "react"; -import { Copy, Download, Folder, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react"; -import { Button, Dialog } from "@govoplan/core-webui"; +import { Copy, Download, FolderOpen, Home, MoveRight, Plus, Trash2, UploadCloud } from "lucide-react"; +import { Button, Dialog, ExplorerTree, type ExplorerTreeNodeContext } from "@govoplan/core-webui"; import type { ConflictAction, FileSpace, RenameResponse } from "../../../api/files"; import type { ConflictDialogState, FileActionTarget, FileConflictItem, FolderNode, ContextMenuState } from "../types"; import { isPathUnderOrSame, normalizeFolder, treeNodeKey } from "../utils/fileManagerUtils"; @@ -111,80 +111,35 @@ export function FolderTree({ disabled?: boolean; depth?: number; }) { - if (nodes.length === 0) return null; return ( -
- {nodes.map((node) => { - const isActive = activeSpaceId === spaceId && currentFolder === node.path; + treeNodeKey(spaceId, node.path)} + getNodeLabel={(node) => node.name} + getNodeChildren={(node) => node.children} + activeId={activeSpaceId === spaceId ? treeNodeKey(spaceId, currentFolder) : ""} + expandedIds={expandedKeys} + onOpen={(node) => onOpen(spaceId, node.path)} + onToggle={(node) => onToggle(spaceId, node.path)} + disabled={disabled} + depth={depth} + getNodeWrapClassName={(node) => { const target = { spaceId, folderPath: node.path }; - const isDropTarget = dragDropEnabled && dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}`; - const hasChildren = node.children.length > 0; - const isExpanded = expandedKeys.has(treeNodeKey(spaceId, node.path)); - return ( -
-
onDragStartFolder(spaceId, node.path, event) : undefined} - onDragEnd={dragDropEnabled ? onDragEndFolder : undefined} - onContextMenu={contextMenuEnabled && onContextMenu ? (event) => onContextMenu(event, spaceId, node.path) : undefined} - onDragOver={dragDropEnabled && onDragOverTarget ? (event) => { - onDragOverTarget(event, target); - if (hasChildren && !isExpanded) onRequestDragExpand?.(spaceId, node.path); - } : undefined} - onDragLeave={dragDropEnabled ? onClearDropState : undefined} - onDrop={dragDropEnabled && onDropOnTarget ? (event) => void onDropOnTarget(event, target) : undefined} - > - - -
- {hasChildren && isExpanded && ( - - )} -
- ); - })} -
+ return dragDropEnabled && dropTargetKey === `${target.spaceId}:${normalizeFolder(target.folderPath)}` ? "is-drop-target" : undefined; + }} + getNodeWrapStyle={(_node, context: ExplorerTreeNodeContext) => ({ paddingLeft: `${Math.min(context.depth * 14, 56)}px` })} + getNodeDraggable={() => dragDropEnabled && !disabled} + onContextMenu={contextMenuEnabled && onContextMenu ? (event, node) => onContextMenu(event, spaceId, node.path) : undefined} + onDragStart={dragDropEnabled && onDragStartFolder ? (event, node) => onDragStartFolder(spaceId, node.path, event) : undefined} + onDragEnd={dragDropEnabled && onDragEndFolder ? () => onDragEndFolder() : undefined} + onDragOver={dragDropEnabled && onDragOverTarget ? (event, node, context) => { + const target = { spaceId, folderPath: node.path }; + onDragOverTarget(event, target); + if (context.hasChildren && !context.expanded) onRequestDragExpand?.(spaceId, node.path); + } : undefined} + onDragLeave={dragDropEnabled && onClearDropState ? () => onClearDropState() : undefined} + onDrop={dragDropEnabled && onDropOnTarget ? (event, node) => void onDropOnTarget(event, { spaceId, folderPath: node.path }) : undefined} + /> ); } diff --git a/webui/src/features/files/utils/fileManagerUtils.ts b/webui/src/features/files/utils/fileManagerUtils.ts index 13be27b..5cc00dd 100644 --- a/webui/src/features/files/utils/fileManagerUtils.ts +++ b/webui/src/features/files/utils/fileManagerUtils.ts @@ -1,3 +1,4 @@ +import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui"; import type { FileFolder, ManagedFile } from "../../../api/files"; import type { DragSelectionState, EntrySelectionKey, ExplorerEntry, FileEntry, FolderEntry, FolderNode, SortColumn, SortDirection } from "../types"; @@ -297,7 +298,5 @@ export function formatBytes(value: number): string { } export function formatDate(value: string): string { - const date = new Date(value); - if (Number.isNaN(date.getTime())) return value; - return date.toLocaleString(); + return formatPlatformDateTime(value); } diff --git a/webui/src/module.ts b/webui/src/module.ts index 38a94d9..eaaf904 100644 --- a/webui/src/module.ts +++ b/webui/src/module.ts @@ -1,4 +1,3 @@ -import { Folder } from "lucide-react"; import { createElement, lazy } from "react"; import type { PlatformWebModule } from "@govoplan/core-webui"; const FilesPage = lazy(() => import("./features/files/FilesPage")); @@ -10,7 +9,7 @@ export const filesModule: PlatformWebModule = { label: "Files", version: "1.0.0", dependencies: ["access"], - navItems: [{ to: "/files", label: "Files", icon: Folder, anyOf: fileRead, order: 40 }], + navItems: [{ to: "/files", label: "Files", iconName: "folder", anyOf: fileRead, order: 40 }], routes: [ { path: "/files", anyOf: fileRead, order: 40, render: ({ settings, auth }) => createElement(FilesPage, { settings, auth }) } ] diff --git a/webui/src/styles/file-manager.css b/webui/src/styles/file-manager.css index 8afc472..917b29a 100644 --- a/webui/src/styles/file-manager.css +++ b/webui/src/styles/file-manager.css @@ -59,7 +59,7 @@ flex: 1 1 auto; min-height: 0; overflow: auto; - padding: 10px 8px 16px; + padding: 0px 0px 16px; } .file-tree-space + .file-tree-space { @@ -73,7 +73,7 @@ grid-template-columns: 26px minmax(0, 1fr); align-items: center; gap: 2px; - border-radius: 9px; + border-radius: 0px; } .file-tree-node, @@ -90,7 +90,7 @@ place-items: center; width: 26px; height: 32px; - border-radius: 9px; + border-radius: 0px; color: var(--muted); } @@ -106,7 +106,7 @@ min-width: 0; width: 100%; padding: 8px 9px; - border-radius: 9px; + border-radius: 0px; font: inherit; text-align: left; user-select: none; @@ -137,7 +137,7 @@ .file-tree-root:hover:not(:disabled), .file-tree-root:focus-visible, .file-tree-root.is-active { - background: rgba(13, 110, 253, .08); + background: var(--line); outline: none; } @@ -149,8 +149,8 @@ .file-tree-node-wrap.is-drop-target, .file-tree-root.is-drop-target { - background: rgba(13, 110, 253, .14); - box-shadow: inset 0 0 0 2px rgba(13, 110, 253, .28); + background: var(--line); + box-shadow: inset 0 0 0 2px var(--line-dark); } .file-tree-node-wrap.is-selected .file-tree-select { @@ -240,8 +240,8 @@ .file-list-drop-target.is-active, .file-list-drop-target.is-drop-target { - background: rgba(13, 110, 253, .05); - box-shadow: inset 0 0 0 2px rgba(13, 110, 253, .22); + background: var(--line); + box-shadow: inset 0 0 0 2px var(--line-dark); } .file-list-table { @@ -295,18 +295,18 @@ } .file-list-row:focus-visible { - outline: 2px solid rgba(13, 110, 253, .35); + outline: 2px solid var(--line-dark); outline-offset: -2px; } .file-list-row:hover, .file-list-row.is-selected { - background: rgba(13, 110, 253, .07); + background: var(--line); } .file-list-row.is-drop-target { - background: rgba(13, 110, 253, .13); - box-shadow: inset 0 0 0 2px rgba(13, 110, 253, .28); + background: var(--line); + box-shadow: inset 0 0 0 2px var(--line-dark); } .file-parent-row.is-disabled {