Browse Source

refactor: migrate web UI to Vue and OpenCloud design system

codex/vue-opencloud-design-system
Rain Mark 2 months ago
parent
commit
70c35acbe2
  1. 2
      AGENTS.md
  2. 4
      README.md
  3. BIN
      app/web/dist/assets/OpenCloud500-Regular.woff2
  4. BIN
      app/web/dist/assets/OpenCloud750-Bold.woff2
  5. 2
      app/web/dist/assets/app.css
  6. 106
      app/web/dist/assets/app.js
  7. BIN
      app/web/dist/assets/inter.woff2
  8. 716
      app/web/dist/assets/module.js
  9. 1
      app/web/dist/assets/native.js
  10. 1
      app/web/dist/icons/add-fill.svg
  11. 1
      app/web/dist/icons/checkbox-blank-fill.svg
  12. 1
      app/web/dist/icons/database-fill.svg
  13. 1
      app/web/dist/icons/delete-bin-fill.svg
  14. 1
      app/web/dist/icons/folder-fill.svg
  15. 1
      app/web/dist/icons/menu-fill.svg
  16. 1
      app/web/dist/icons/upload-fill.svg
  17. 8
      docs/architecture.md
  18. 2
      frontend/index.html
  19. 1
      frontend/public/icons/add-fill.svg
  20. 1
      frontend/public/icons/checkbox-blank-fill.svg
  21. 1
      frontend/public/icons/database-fill.svg
  22. 1
      frontend/public/icons/delete-bin-fill.svg
  23. 1
      frontend/public/icons/folder-fill.svg
  24. 1
      frontend/public/icons/menu-fill.svg
  25. 1
      frontend/public/icons/upload-fill.svg
  26. 599
      frontend/src/App.vue
  27. 16
      frontend/src/app/App.tsx
  28. 71
      frontend/src/app/router.tsx
  29. BIN
      frontend/src/assets/fonts/OpenCloud500-Regular.woff2
  30. BIN
      frontend/src/assets/fonts/OpenCloud750-Bold.woff2
  31. BIN
      frontend/src/assets/fonts/inter.woff2
  32. 32
      frontend/src/components/Dialog.tsx
  33. 55
      frontend/src/components/Menu.tsx
  34. 117
      frontend/src/components/PaginationControls.tsx
  35. 170
      frontend/src/components/layout/AppLayout.tsx
  36. 29
      frontend/src/features/auth/RequireAuth.tsx
  37. 50
      frontend/src/features/toast/ToastProvider.tsx
  38. 43
      frontend/src/main.ts
  39. 10
      frontend/src/main.tsx
  40. 1050
      frontend/src/routes/FilesPage.tsx
  41. 215
      frontend/src/routes/JobsPage.tsx
  42. 79
      frontend/src/routes/LoginPage.tsx
  43. 279
      frontend/src/routes/PoliciesPage.tsx
  44. 97
      frontend/src/routes/RecycleBinPage.tsx
  45. 518
      frontend/src/routes/StoragePage.tsx
  46. 3502
      frontend/src/styles/app.css
  47. 767
      frontend/src/styles/opencloud-app.css
  48. 9
      frontend/src/vite-env.d.ts
  49. 6
      frontend/vite.config.ts
  50. 1720
      package-lock.json
  51. 13
      package.json
  52. 4
      tests/test_web_routes.py

2
AGENTS.md

@ -7,7 +7,7 @@ Guidance for coding agents and automated contributors working in this repository
Iron is a self-hosted personal cloud drive gateway with: Iron is a self-hosted personal cloud drive gateway with:
- FastAPI backend under `app/` - FastAPI backend under `app/`
- Vite + React + TypeScript frontend under `frontend/` - Vite + Vue + TypeScript frontend under `frontend/`
- built frontend assets served from `app/web/dist/` - built frontend assets served from `app/web/dist/`
- Alembic migrations under `alembic/` - Alembic migrations under `alembic/`
- tests under `tests/` - tests under `tests/`

4
README.md

@ -17,7 +17,7 @@ coverage, and more real-world usage.
- Directory and file operations: browse, create folder, rename, move, delete, - Directory and file operations: browse, create folder, rename, move, delete,
restore, download, and preview restore, download, and preview
- `tus` upload flow with local object persistence - `tus` upload flow with local object persistence
- Desktop-first React Web app at `/app` - Desktop-first Vue Web app at `/app`, using the OpenCloud design system
- Storage backend management for local directories, S3 storage, and - Storage backend management for local directories, S3 storage, and
OpenList/WebDAV runtime paths OpenList/WebDAV runtime paths
- Placement policies, reconcile jobs, health checks, and retryable background jobs - Placement policies, reconcile jobs, health checks, and retryable background jobs
@ -126,7 +126,7 @@ Playwright screenshots and runtime artifacts are written to `output/playwright/`
```text ```text
app/ FastAPI application, services, repositories, schemas app/ FastAPI application, services, repositories, schemas
alembic/ Database migrations alembic/ Database migrations
frontend/ Vite + React + TypeScript source frontend/ Vite + Vue + TypeScript source
app/web/dist/ Built frontend assets served by FastAPI app/web/dist/ Built frontend assets served by FastAPI
scripts/ Local automation and E2E scripts scripts/ Local automation and E2E scripts
deploy/ OpenList compose, Iron env templates, and systemd examples deploy/ OpenList compose, Iron env templates, and systemd examples

BIN
app/web/dist/assets/OpenCloud500-Regular.woff2

Binary file not shown.

BIN
app/web/dist/assets/OpenCloud750-Bold.woff2

Binary file not shown.

2
app/web/dist/assets/app.css

File diff suppressed because one or more lines are too long

106
app/web/dist/assets/app.js

File diff suppressed because one or more lines are too long

BIN
app/web/dist/assets/inter.woff2

Binary file not shown.

716
app/web/dist/assets/module.js

File diff suppressed because one or more lines are too long

1
app/web/dist/assets/native.js

File diff suppressed because one or more lines are too long

1
app/web/dist/icons/add-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"/></svg>

After

Width:  |  Height:  |  Size: 139 B

1
app/web/dist/icons/checkbox-blank-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M4 3H20C20.5523 3 21 3.44772 21 4V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3Z"/></svg>

After

Width:  |  Height:  |  Size: 217 B

1
app/web/dist/icons/database-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M11 7V4C11 3.44772 11.4477 3 12 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V8C2 7.44772 2.44772 7 3 7H11ZM5 16V18H10V16H5ZM14 16V18H19V16H14ZM14 13V15H19V13H14ZM14 10V12H19V10H14ZM5 13V15H10V13H5Z"/></svg>

After

Width:  |  Height:  |  Size: 340 B

1
app/web/dist/icons/delete-bin-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM9 11V17H11V11H9ZM13 11V17H15V11H13ZM9 4V6H15V4H9Z"/></svg>

After

Width:  |  Height:  |  Size: 286 B

1
app/web/dist/icons/folder-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5Z"/></svg>

After

Width:  |  Height:  |  Size: 241 B

1
app/web/dist/icons/menu-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M3 4H21V6H3V4ZM3 11H21V13H3V11ZM3 18H21V20H3V18Z"/></svg>

After

Width:  |  Height:  |  Size: 146 B

1
app/web/dist/icons/upload-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M3 19H21V21H3V19ZM13 10V18H11V10H4L12 2L20 10H13Z"/></svg>

After

Width:  |  Height:  |  Size: 147 B

8
docs/architecture.md

@ -1,6 +1,6 @@
# Architecture # Architecture
Iron is an async Python modular monolith with a React single-page Web app. Iron is an async Python modular monolith with a Vue single-page Web app.
## System Overview ## System Overview
@ -42,10 +42,10 @@ Important packages:
Core stack: Core stack:
- Vite - Vite
- React - Vue 3
- TypeScript - TypeScript
- React Router - Pinia
- TanStack Query - OpenCloud design system
Source lives in `frontend/`. Built assets are emitted into `app/web/dist/` and Source lives in `frontend/`. Built assets are emitted into `app/web/dist/` and
served by FastAPI at `/app`. served by FastAPI at `/app`.

2
frontend/index.html

@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Iron</title> <title>Iron</title>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.ts"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

1
frontend/public/icons/add-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"/></svg>

After

Width:  |  Height:  |  Size: 139 B

1
frontend/public/icons/checkbox-blank-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M4 3H20C20.5523 3 21 3.44772 21 4V20C21 20.5523 20.5523 21 20 21H4C3.44772 21 3 20.5523 3 20V4C3 3.44772 3.44772 3 4 3Z"/></svg>

After

Width:  |  Height:  |  Size: 217 B

1
frontend/public/icons/database-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M11 7V4C11 3.44772 11.4477 3 12 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V8C2 7.44772 2.44772 7 3 7H11ZM5 16V18H10V16H5ZM14 16V18H19V16H14ZM14 13V15H19V13H14ZM14 10V12H19V10H14ZM5 13V15H10V13H5Z"/></svg>

After

Width:  |  Height:  |  Size: 340 B

1
frontend/public/icons/delete-bin-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M17 6H22V8H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V8H2V6H7V3C7 2.44772 7.44772 2 8 2H16C16.5523 2 17 2.44772 17 3V6ZM9 11V17H11V11H9ZM13 11V17H15V11H13ZM9 4V6H15V4H9Z"/></svg>

After

Width:  |  Height:  |  Size: 286 B

1
frontend/public/icons/folder-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M12.4142 5H21C21.5523 5 22 5.44772 22 6V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4C2 3.44772 2.44772 3 3 3H10.4142L12.4142 5Z"/></svg>

After

Width:  |  Height:  |  Size: 241 B

1
frontend/public/icons/menu-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M3 4H21V6H3V4ZM3 11H21V13H3V11ZM3 18H21V20H3V18Z"/></svg>

After

Width:  |  Height:  |  Size: 146 B

1
frontend/public/icons/upload-fill.svg

@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M3 19H21V21H3V19ZM13 10V18H11V10H4L12 2L20 10H13Z"/></svg>

After

Width:  |  Height:  |  Size: 147 B

599
frontend/src/App.vue

@ -0,0 +1,599 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { api, type BackendSummary, type DirectoryItem, type FileDetailResponse, type JobSummary, type PlacementPolicySummary } from "./lib/api";
import { clearAccessToken, getAccessToken, setAccessToken } from "./lib/auth";
import { formatBackendType, formatBytes, formatDate, titleCase } from "./lib/format";
import { uploadFileToDirectory } from "./lib/uploads";
type RouteName = "login" | "files" | "recycle" | "storage" | "policies" | "jobs";
type DialogMode = "create" | "rename" | "move" | "delete" | null;
type ViewMode = "list" | "grid";
const route = ref(parseRoute());
const userInitial = ref("A");
const loginUsername = ref("admin");
const loginPassword = ref("changeme-iron");
const loginError = ref("");
const isLoginPending = ref(false);
const search = ref(new URLSearchParams(window.location.search).get("q") ?? "");
const toast = ref("");
const directoryId = computed(() => route.value.directoryId ?? "dir_root");
const directoryName = ref("Personal");
const parentStack = ref<Array<{ id: string; name: string }>>([{ id: "dir_root", name: "Personal" }]);
const directoryItems = ref<DirectoryItem[]>([]);
const selectedItem = ref<DirectoryItem | null>(null);
const sortBy = ref<"name" | "updated" | "size">("name");
const viewMode = ref<ViewMode>("list");
const fileInput = ref<HTMLInputElement | null>(null);
const page = ref(1);
const pageSize = ref(8);
const openMenuId = ref<string | null>(null);
const dialogMode = ref<DialogMode>(null);
const fieldValue = ref("");
const fileDetail = ref<FileDetailResponse | null>(null);
const previewUrl = ref("");
const previewText = ref("");
const isDetailDrawerOpen = ref(false);
const recycleItems = ref<Array<DirectoryItem & { deleted_at?: string }>>([]);
const backends = ref<BackendSummary[]>([]);
const editingBackend = ref<BackendSummary | null>(null);
const policies = ref<PlacementPolicySummary[]>([]);
const selectedPolicyClass = ref<PlacementPolicySummary["file_class"]>("document");
const jobs = ref<JobSummary[]>([]);
const selectedJob = ref<JobSummary | null>(null);
const currentPolicy = computed(() => policies.value.find((policy) => policy.file_class === selectedPolicyClass.value) ?? null);
const folders = computed(() => filteredItems.value.filter((item) => item.kind === "directory"));
const files = computed(() => filteredItems.value.filter((item) => item.kind === "file"));
const filteredItems = computed(() => {
const query = search.value.trim().toLowerCase();
return [...directoryItems.value]
.filter((item) => (query ? item.name.toLowerCase().includes(query) : true))
.sort((first, second) => {
if (sortBy.value === "updated") {
return second.updated_at.localeCompare(first.updated_at);
}
if (sortBy.value === "size") {
return (second.size_bytes ?? 0) - (first.size_bytes ?? 0);
}
if (first.kind !== second.kind) {
return first.kind === "directory" ? -1 : 1;
}
return first.name.localeCompare(second.name);
});
});
const totalPages = computed(() => Math.max(1, Math.ceil(filteredItems.value.length / pageSize.value)));
const pagedItems = computed(() => filteredItems.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value));
const fileTotalBytes = computed(() => files.value.reduce((sum, item) => sum + (item.size_bytes ?? 0), 0));
const pageItems = computed(() => getPaginationItems(page.value, totalPages.value));
watch(route, async () => {
if (route.value.name === "login") {
return;
}
await loadRouteData();
});
watch(selectedItem, async (item) => {
isDetailDrawerOpen.value = false;
fileDetail.value = null;
previewText.value = "";
if (previewUrl.value) {
URL.revokeObjectURL(previewUrl.value);
previewUrl.value = "";
}
if (item?.kind === "file") {
await loadFileDetailAndPreview(item);
}
});
onMounted(async () => {
window.addEventListener("popstate", handlePopState);
if (!getAccessToken() && route.value.name !== "login") {
navigate("/app/login");
return;
}
await loadRouteData();
});
onUnmounted(() => window.removeEventListener("popstate", handlePopState));
function handlePopState() {
route.value = parseRoute();
}
function parseRoute(): { name: RouteName; directoryId?: string } {
const path = window.location.pathname;
if (path.startsWith("/app/login")) return { name: "login" };
if (path.startsWith("/app/recycle-bin")) return { name: "recycle" };
if (path.startsWith("/app/storage")) return { name: "storage" };
if (path.startsWith("/app/policies")) return { name: "policies" };
if (path.startsWith("/app/jobs")) return { name: "jobs" };
const match = path.match(/^\/app\/files\/([^/]+)/);
return { name: "files", directoryId: match?.[1] ?? "dir_root" };
}
function navigate(path: string) {
window.history.pushState({}, "", path);
route.value = parseRoute();
}
async function loadRouteData() {
try {
if (route.value.name === "files") await loadDirectory();
if (route.value.name === "recycle") await loadRecycleBin();
if (route.value.name === "storage") await loadStorage();
if (route.value.name === "policies") await loadPolicies();
if (route.value.name === "jobs") await loadJobs();
} catch (error) {
showError(error);
}
}
async function login() {
isLoginPending.value = true;
loginError.value = "";
try {
const result = await api.login(loginUsername.value, loginPassword.value);
setAccessToken(result.access_token);
userInitial.value = result.user.username.slice(0, 1).toUpperCase();
navigate("/app/files");
} catch (error) {
loginError.value = error instanceof Error ? error.message : "Sign in failed.";
} finally {
isLoginPending.value = false;
}
}
function logout() {
clearAccessToken();
navigate("/app/login");
}
async function loadDirectory() {
const result = await api.listDirectoryChildren(directoryId.value);
directoryItems.value = result.items;
directoryName.value = directoryId.value === "dir_root" ? "Personal" : result.directory.name;
updateStack(result.directory.id, result.directory.name);
selectedItem.value = null;
page.value = 1;
}
function updateStack(id: string, name: string) {
const index = parentStack.value.findIndex((item) => item.id === id);
if (index >= 0) {
parentStack.value = parentStack.value.slice(0, index + 1);
} else {
parentStack.value = [...parentStack.value, { id, name }];
}
}
function openDirectory(item: DirectoryItem) {
navigate(item.id === "dir_root" ? "/app/files" : `/app/files/${item.id}`);
}
function goBackDirectory() {
const nextStack = parentStack.value.slice(0, -1);
const parent = nextStack[nextStack.length - 1] ?? { id: "dir_root", name: "Personal" };
parentStack.value = nextStack.length ? nextStack : [{ id: "dir_root", name: "Personal" }];
navigate(parent.id === "dir_root" ? "/app/files" : `/app/files/${parent.id}`);
}
async function uploadSelectedFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
await uploadFileToDirectory(file, directoryId.value);
toast.value = `Uploaded ${file.name}.`;
await loadDirectory();
} catch (error) {
showError(error);
} finally {
input.value = "";
}
}
function openDialog(mode: DialogMode, item?: DirectoryItem) {
if (item) selectedItem.value = item;
dialogMode.value = mode;
fieldValue.value = mode === "rename" ? selectedItem.value?.name ?? "" : "";
}
async function confirmDialog() {
if (!dialogMode.value) return;
try {
if (dialogMode.value === "create") {
await api.createDirectory(directoryId.value, fieldValue.value || "New Folder");
toast.value = "Folder created.";
}
if (dialogMode.value === "rename" && selectedItem.value) {
if (selectedItem.value.kind === "directory") {
await api.renameDirectory(selectedItem.value.id, fieldValue.value);
} else {
await api.renameFile(selectedItem.value.id, fieldValue.value);
}
toast.value = "Renamed.";
}
if (dialogMode.value === "delete" && selectedItem.value?.kind === "file") {
await api.deleteFile(selectedItem.value.id);
toast.value = "Moved to recycle bin.";
}
dialogMode.value = null;
await loadDirectory();
} catch (error) {
showError(error);
}
}
async function loadFileDetailAndPreview(item: DirectoryItem) {
fileDetail.value = await api.fileDetail(item.id);
if (isImage(item.mime_type)) {
const blob = await api.fileBlob(item.id, "preview");
previewUrl.value = URL.createObjectURL(blob);
} else if (isText(item.mime_type, item.name)) {
const blob = await api.fileBlob(item.id, "preview");
previewText.value = (await blob.text()).slice(0, 8000);
}
}
async function downloadFile(item: DirectoryItem) {
if (item.kind !== "file") return;
const blob = await api.fileBlob(item.id, "download");
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = item.name;
link.click();
URL.revokeObjectURL(url);
}
async function reconcileFile(item: DirectoryItem) {
if (item.kind !== "file") return;
await api.reconcileFile(item.id);
toast.value = "Reconcile queued.";
await loadJobs();
}
async function loadRecycleBin() {
const result = await api.listRecycleBin();
recycleItems.value = result.items.map((item) => ({ ...item, kind: "file" as const, replica_ready_count: null, replica_desired_count: null, replica_missing_count: null }));
}
async function restoreFile(item: DirectoryItem) {
await api.restoreFile(item.id);
await loadRecycleBin();
}
async function loadStorage() {
const result = await api.listBackends();
backends.value = result.items;
}
async function checkBackend(backend: BackendSummary) {
await api.checkBackend(backend.id);
toast.value = "Backend check queued.";
await loadStorage();
}
async function runAllBackendChecks() {
toast.value = "Backend checks queued.";
await Promise.all(backends.value.map(checkBackend));
}
async function loadPolicies() {
const result = await api.listPlacementPolicies();
policies.value = result.items;
}
async function savePolicy() {
if (!currentPolicy.value) return;
const policy = currentPolicy.value;
await api.updatePlacementPolicy(policy.file_class, {
require_local: policy.require_local,
stable_replica_count: policy.stable_replica_count,
opportunistic_replica_count: policy.opportunistic_replica_count,
preferred_backend_ids: policy.preferred_backend_ids,
excluded_backend_ids: policy.excluded_backend_ids,
max_non_local_size_bytes: policy.max_non_local_size_bytes,
});
toast.value = "Policy saved.";
await loadPolicies();
}
async function loadJobs() {
const result = await api.listJobs();
jobs.value = result.items;
selectedJob.value = jobs.value[0] ?? null;
}
async function retryJob(job: JobSummary) {
await api.retryJob(job.id);
await loadJobs();
}
async function runPendingJobs() {
await api.runPendingJobs();
await loadJobs();
}
async function enqueueHealthChecks() {
await api.enqueueHealthChecks();
await loadJobs();
}
async function enqueueFullReconcile() {
await api.enqueueFullReconcile();
await loadJobs();
}
function showError(error: unknown) {
toast.value = error instanceof Error ? error.message : "Something went wrong.";
}
function setPageSize(value: Event) {
pageSize.value = Number((value.target as HTMLSelectElement).value);
page.value = 1;
}
function formatReplicaRatio(item: DirectoryItem) {
if (item.kind !== "file") return "--";
return `${item.replica_ready_count ?? 0}/${item.replica_desired_count ?? 0}`;
}
function replicaClass(item: DirectoryItem) {
return item.replica_missing_count ? "replica-ratio" : "replica-ratio replica-ratio-ready";
}
function iconClass(item: DirectoryItem) {
if (item.kind === "directory") return "resource-icon resource-icon-folder";
if (item.mime_type?.includes("image")) return "resource-icon resource-icon-image";
if (item.mime_type?.includes("pdf")) return "resource-icon resource-icon-pdf";
if (item.name.endsWith(".md") || item.name.endsWith(".txt")) return "resource-icon resource-icon-document";
if (item.name.endsWith(".zip")) return "resource-icon resource-icon-archive";
return "resource-icon";
}
function iconLabel(item: DirectoryItem) {
if (item.kind === "directory") return "";
const ext = item.name.split(".").pop();
return (ext ?? "FILE").slice(0, 3).toUpperCase();
}
function isImage(mime: string | null) {
return Boolean(mime?.startsWith("image/"));
}
function isText(mime: string | null, name: string) {
return Boolean(mime?.startsWith("text/") || name.endsWith(".md") || name.endsWith(".json") || name.endsWith(".csv"));
}
function getPaginationItems(current: number, total: number): Array<number | "ellipsis"> {
if (total <= 7) return Array.from({ length: total }, (_, index) => index + 1);
const values = new Set<number>([1, total, current - 1, current, current + 1]);
if (current <= 4) [2, 3, 4, 5].forEach((item) => values.add(item));
if (current >= total - 3) [total - 4, total - 3, total - 2, total - 1].forEach((item) => values.add(item));
const sorted = Array.from(values).filter((item) => item >= 1 && item <= total).sort((a, b) => a - b);
return sorted.flatMap((item, index) => {
const previous = sorted[index - 1];
if (!previous) return [item];
if (item - previous === 2) return [previous + 1, item];
if (item - previous > 2) return ["ellipsis", item];
return [item];
});
}
</script>
<template>
<main v-if="route.name === 'login'" class="login-page">
<form class="login-form" @submit.prevent="login">
<div class="section-heading">
<span class="eyebrow">Iron</span>
<h2>Sign in</h2>
</div>
<label>
<span>Username</span>
<input v-model="loginUsername" required />
</label>
<label>
<span>Password</span>
<input v-model="loginPassword" type="password" required />
</label>
<OcButton appearance="filled" color-role="primary" submit="submit" :disabled="isLoginPending">
{{ isLoginPending ? "Signing in..." : "Sign in" }}
</OcButton>
<p v-if="loginError" class="error-text">{{ loginError }}</p>
</form>
</main>
<div v-else class="iron-app">
<header class="iron-topbar">
<div class="brand-area">
<button class="app-switcher-button" type="button" aria-label="Application menu">
<span v-for="item in 9" :key="item" />
</button>
<div class="brand-lockup"><span class="brand-mark">I</span><span>Iron</span></div>
</div>
<form class="topbar-search" role="search" @submit.prevent>
<input v-model="search" aria-label="Search" placeholder="Enter search term" />
</form>
<button class="user-chip" type="button" @click="logout">{{ userInitial }}</button>
</header>
<div class="iron-workspace">
<aside class="sidebar">
<span class="sidebar-kicker">Personal</span>
<h1 class="sidebar-title">Drive</h1>
<nav class="main-nav">
<a :class="route.name === 'files' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/files')"><OcIcon name="folder" size="small" />Files</a>
<a :class="route.name === 'recycle' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/recycle-bin')"><OcIcon name="delete-bin" size="small" />Deleted files</a>
<a :class="route.name === 'storage' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/storage')"><OcIcon name="database" size="small" />Storage</a>
<a :class="route.name === 'policies' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/policies')"><OcIcon name="checkbox-blank" size="small" />Policies</a>
<a :class="route.name === 'jobs' ? 'nav-link active' : 'nav-link'" @click.prevent="navigate('/app/jobs')"><OcIcon name="menu" size="small" />Jobs</a>
</nav>
<div class="sidebar-footer">Iron Web UI<br />Personal cloud drive</div>
</aside>
<section class="workspace-content">
<div v-if="route.name === 'files'" class="files-page-layout">
<section class="files-content-shell">
<div class="panel-toolbar">
<div class="files-header">
<div class="directory-title-row">
<button v-if="directoryId !== 'dir_root'" class="directory-back-button" type="button" aria-label="Back to parent folder" @click="goBackDirectory"></button>
<h2>{{ directoryName }}</h2>
</div>
<div class="inline-metrics">
<span>{{ folders.length }} folders</span>
<span>{{ files.length }} files</span>
<span>{{ formatBytes(fileTotalBytes) }}</span>
</div>
</div>
<div class="command-bar files-command-bar">
<OcButton appearance="filled" color-role="secondary" @click="openDialog('create')"><OcIcon name="add" size="small" />New</OcButton>
<OcButton appearance="outline" @click="fileInput?.click()"><OcIcon name="upload" size="small" />Upload</OcButton>
<OcButton appearance="outline" @click="loadDirectory">Refresh</OcButton>
<OcButton appearance="outline" @click="viewMode = viewMode === 'list' ? 'grid' : 'list'">{{ viewMode === "list" ? "Grid" : "List" }}</OcButton>
<select v-model="sortBy" class="compact-sort-select" aria-label="Sort current directory">
<option value="name">Sort by name</option>
<option value="updated">Sort by updated</option>
<option value="size">Sort by size</option>
</select>
</div>
</div>
<input ref="fileInput" hidden type="file" @change="uploadSelectedFile" />
<p v-if="toast" class="notice-text">{{ toast }}</p>
<div :class="viewMode === 'grid' ? 'list-surface grid-surface' : 'list-surface'">
<div class="list-header"><span>Name</span><span>Updated</span><span>Size</span><span>Replicas</span><span>Actions</span></div>
<div v-if="!pagedItems.length" class="empty-row">Add a folder or upload a file.</div>
<div :class="viewMode === 'grid' ? 'drive-grid' : 'drive-list'">
<div
v-for="item in pagedItems"
:key="item.id"
:class="selectedItem?.id === item.id ? 'drive-item active' : 'drive-item'"
role="button"
tabindex="0"
@click="selectedItem = item"
@dblclick="item.kind === 'directory' ? openDirectory(item) : undefined"
>
<div class="drive-item-main">
<span :class="iconClass(item)">{{ iconLabel(item) }}</span>
<span class="resource-name-stack"><strong>{{ item.name }}</strong></span>
</div>
<span>{{ formatDate(item.updated_at) }}</span>
<span>{{ formatBytes(item.size_bytes) }}</span>
<span :class="replicaClass(item)">{{ formatReplicaRatio(item) }}</span>
<span class="row-actions" @click.stop>
<OcButton class="row-action-button" appearance="outline" size="small" @click="openMenuId = item.id">More</OcButton>
<span v-if="openMenuId === item.id" class="menu-popover" role="menu">
<OcButton class="menu-item" appearance="raw" justify-content="left" @click="item.kind === 'directory' ? openDirectory(item) : downloadFile(item)">Download</OcButton>
<OcButton v-if="item.kind === 'file'" class="menu-item" appearance="raw" justify-content="left" @click="reconcileFile(item)">Reconcile</OcButton>
<OcButton class="menu-item" appearance="raw" justify-content="left" @click="openDialog('rename', item)">Rename</OcButton>
<OcButton v-if="item.kind === 'file'" class="menu-item menu-item-danger" appearance="raw" justify-content="left" @click="openDialog('delete', item)">Delete</OcButton>
</span>
</span>
</div>
</div>
</div>
<div class="pagination-bar">
<span>{{ filteredItems.length }} items</span>
<div class="pagination-controls">
<button class="pagination-page-button" type="button" :disabled="page === 1" @click="page = Math.max(1, page - 1)"></button>
<span class="pagination-page-list">
<template v-for="(item, index) in pageItems" :key="`${item}-${index}`">
<span v-if="item === 'ellipsis'" class="pagination-ellipsis">...</span>
<button v-else :class="item === page ? 'pagination-page-button active' : 'pagination-page-button'" type="button" @click="page = item">{{ item }}</button>
</template>
</span>
<button class="pagination-page-button" type="button" :disabled="page === totalPages" @click="page = Math.min(totalPages, page + 1)"></button>
<select class="page-size-select" :value="pageSize" @change="setPageSize">
<option :value="8">8 / page</option>
<option :value="16">16 / page</option>
<option :value="32">32 / page</option>
</select>
</div>
</div>
</section>
<aside class="detail-panel">
<div v-if="!selectedItem" class="detail-empty"><span class="eyebrow">Inspector</span><h3>Select a file or folder.</h3></div>
<template v-else>
<div class="section-heading"><span class="eyebrow">Inspector</span><h3>{{ selectedItem.name }}</h3></div>
<img v-if="previewUrl && isImage(selectedItem.mime_type)" class="preview-frame" :src="previewUrl" alt="" />
<pre v-else-if="previewText" class="preview-frame text-preview-frame">{{ previewText }}</pre>
<div v-else-if="selectedItem.kind === 'file'" class="preview-fallback">No inline preview</div>
<div v-if="selectedItem.kind === 'file'" class="inspector-storage-summary">
<div><span>Replicas</span><strong>{{ formatReplicaRatio(selectedItem) }}</strong></div>
<span :class="replicaClass(selectedItem)">{{ selectedItem.replica_missing_count ? "Needs sync" : "Ready" }}</span>
</div>
<OcButton v-if="selectedItem.kind === 'file'" class="inspector-info-button" appearance="outline" @click="isDetailDrawerOpen = true">More Info</OcButton>
</template>
</aside>
</div>
<div v-if="route.name === 'recycle'" class="page-frame files-content-shell">
<div class="section-heading"><span>Deleted files</span><h2>Recycle Bin</h2><div class="inline-metrics"><span>{{ recycleItems.length }} items</span><span>{{ formatBytes(recycleItems.reduce((sum, item) => sum + (item.size_bytes ?? 0), 0)) }}</span></div></div>
<div class="list-surface"><div class="list-header recycle-list-header"><span>Name</span><span>Deleted</span><span>Size</span><span>Actions</span></div><div v-if="!recycleItems.length" class="empty-row">Recycle bin is empty.</div><div v-for="item in recycleItems" :key="item.id" class="drive-item recycle-row" role="button" tabindex="0"><div class="drive-item-main"><span :class="iconClass(item)">{{ iconLabel(item) }}</span><strong>{{ item.name }}</strong></div><span>{{ formatDate(item.deleted_at ?? item.updated_at) }}</span><span>{{ formatBytes(item.size_bytes) }}</span><OcButton appearance="outline" size="small" @click="restoreFile(item)">Restore</OcButton></div></div>
</div>
<div v-if="route.name === 'storage'" class="page-frame files-content-shell">
<div class="page-toolbar"><div class="section-heading"><span>Storage</span><h2>Backends</h2><div class="inline-metrics"><span>{{ backends.length }} backends</span><span>{{ backends.filter((b) => b.last_health_status === 'healthy').length }} healthy</span></div></div><div class="command-bar"><OcButton appearance="filled" color-role="secondary">Add Backend</OcButton><OcButton appearance="outline" @click="runAllBackendChecks">Run All Checks</OcButton></div></div>
<div class="list-surface"><div class="list-header storage-list-header"><span>Name</span><span>Class</span><span>Status</span><span>Updated</span><span>Actions</span></div><div v-for="backend in backends" :key="backend.id" class="drive-item storage-row"><div><strong>{{ backend.name }}</strong><br /><span class="eyebrow">{{ formatBackendType(backend.type) }}</span></div><span>{{ titleCase(backend.stability_class) }}</span><span :class="backend.last_health_status === 'healthy' ? 'status-chip status-chip-success' : 'status-chip'">{{ backend.last_health_status ?? 'Unknown' }}</span><span>{{ formatDate(backend.updated_at) }}</span><span class="row-actions"><OcButton appearance="outline" size="small" @click="checkBackend(backend)">Check</OcButton><OcButton appearance="outline" size="small" @click="editingBackend = backend">Edit</OcButton></span></div></div>
</div>
<div v-if="route.name === 'policies'" class="page-frame files-content-shell">
<div class="section-heading"><span>Placement</span><h2>Replica policies</h2><div class="inline-metrics"><span>{{ policies.length }} policies</span><span>2 default replicas</span></div></div>
<div class="command-bar" style="justify-content:flex-start;margin:.8rem 0"><OcButton v-for="policy in policies" :key="policy.file_class" :appearance="policy.file_class === selectedPolicyClass ? 'filled' : 'outline'" @click="selectedPolicyClass = policy.file_class">{{ titleCase(policy.file_class) }}</OcButton></div>
<form v-if="currentPolicy" class="dialog-form policy-form" @submit.prevent="savePolicy"><label><span>Stable replicas</span><input v-model.number="currentPolicy.stable_replica_count" class="dialog-input" type="number" min="0" /></label><label><span>Opportunistic replicas</span><input v-model.number="currentPolicy.opportunistic_replica_count" class="dialog-input" type="number" min="0" /></label><OcButton appearance="filled" color-role="secondary" submit="submit">Save Policy</OcButton></form>
</div>
<div v-if="route.name === 'jobs'" class="files-page-layout">
<section class="files-content-shell"><div class="page-toolbar"><div class="section-heading"><span>Jobs</span><h2>Background activity</h2><div class="inline-metrics"><span>{{ jobs.filter((job) => job.status === 'queued').length }} pending</span><span>{{ jobs.filter((job) => job.status === 'completed').length }} completed</span><span>{{ jobs.filter((job) => job.status === 'failed').length }} failed</span></div></div><div class="command-bar"><OcButton appearance="outline" @click="runPendingJobs">Run Pending</OcButton><OcButton appearance="outline" @click="enqueueHealthChecks">Enqueue Checks</OcButton><OcButton appearance="filled" color-role="secondary" @click="enqueueFullReconcile">Full Reconcile</OcButton></div></div><div class="list-surface"><div class="list-header jobs-list-header"><span>Job</span><span>Status</span><span>Attempts</span><span>Actions</span></div><div v-for="job in jobs" :key="job.id" :class="selectedJob?.id === job.id ? 'drive-item jobs-row jobs-table-row active' : 'drive-item jobs-row jobs-table-row'" role="button" tabindex="0" @click="selectedJob = job"><div><strong>{{ titleCase(job.kind) }}</strong><br /><span class="eyebrow">Run after {{ formatDate(job.run_after) }}</span></div><span class="status-chip">{{ titleCase(job.status) }}</span><span>{{ job.attempt_count }}/{{ job.max_attempts }}</span><OcButton appearance="outline" size="small" @click.stop="retryJob(job)">Retry</OcButton></div></div></section><aside class="detail-panel jobs-detail-panel"><div v-if="selectedJob" class="section-heading"><span>Job Detail</span><h3>{{ titleCase(selectedJob.kind) }}</h3></div><pre v-if="selectedJob" class="payload-preview">{{ selectedJob.payload_json || '{}' }}</pre></aside>
</div>
</section>
</div>
</div>
<div v-if="toast" class="toast-item">{{ toast }}</div>
<div v-if="dialogMode" class="dialog-backdrop">
<form class="dialog-card" role="dialog" aria-modal="true" @submit.prevent="confirmDialog">
<div class="dialog-header">
<div><span class="eyebrow">Action</span><h3>{{ dialogMode === "delete" ? "Delete file" : dialogMode === "create" ? "New folder" : "Rename" }}</h3></div>
<OcButton appearance="outline" @click="dialogMode = null">Close</OcButton>
</div>
<p v-if="dialogMode === 'delete'">Move <strong>{{ selectedItem?.name }}</strong> to the recycle bin?</p>
<label v-else><span>Name</span><input v-model="fieldValue" class="dialog-input" required /></label>
<div class="dialog-actions">
<OcButton appearance="outline" @click="dialogMode = null">Cancel</OcButton>
<button v-if="dialogMode === 'delete'" class="danger-confirm-button" type="submit">Confirm</button>
<OcButton v-else appearance="filled" color-role="secondary" submit="submit">Confirm</OcButton>
</div>
</form>
</div>
<div v-if="editingBackend" class="dialog-backdrop">
<form class="dialog-card" role="dialog" aria-modal="true" @submit.prevent="editingBackend = null">
<div class="dialog-header">
<div><span class="eyebrow">Storage</span><h3>Edit storage backend</h3></div>
<OcButton appearance="outline" @click="editingBackend = null">Close</OcButton>
</div>
<label><span>Name</span><input class="dialog-input" :value="editingBackend.name" readonly /></label>
<label><span>Type</span><input class="dialog-input" :value="formatBackendType(editingBackend.type)" readonly /></label>
<div class="inspector-card"><strong>Stored secrets</strong><p>{{ editingBackend.secret_fields.length ? editingBackend.secret_fields.join(", ") : "None" }}</p></div>
<div class="dialog-actions">
<OcButton appearance="outline" @click="editingBackend = null">Cancel</OcButton>
<OcButton appearance="filled" color-role="secondary" submit="submit">Save</OcButton>
</div>
</form>
</div>
<div v-if="isDetailDrawerOpen && selectedItem?.kind === 'file'" class="detail-drawer-backdrop" @click="isDetailDrawerOpen = false">
<aside class="detail-drawer" role="dialog" aria-modal="true" @click.stop>
<div class="detail-drawer-header"><div><span class="eyebrow">Replica Info</span><h3>{{ selectedItem.name }}</h3></div><OcButton appearance="outline" @click="isDetailDrawerOpen = false">Close</OcButton></div>
<div class="detail-drawer-content"><div class="inspector-card"><strong>Desired Backends</strong><p>{{ fileDetail?.desired_backends.map((backend) => backend.name).join(', ') || 'None' }}</p></div><div class="inspector-card"><strong>Replicas</strong><p v-for="replica in fileDetail?.replicas ?? []" :key="replica.id">{{ replica.backend_name }} · {{ titleCase(replica.status) }} · {{ formatBytes(replica.size_bytes) }}</p></div></div>
</aside>
</div>
</template>

16
frontend/src/app/App.tsx

@ -1,16 +0,0 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider } from "react-router-dom";
import { router } from "./router";
import { ToastProvider } from "../features/toast/ToastProvider";
const queryClient = new QueryClient();
export function App() {
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>
<RouterProvider router={router} />
</ToastProvider>
</QueryClientProvider>
);
}

71
frontend/src/app/router.tsx

@ -1,71 +0,0 @@
import {
Navigate,
Outlet,
createBrowserRouter,
useLocation,
} from "react-router-dom";
import { AppLayout } from "../components/layout/AppLayout";
import { RequireAuth } from "../features/auth/RequireAuth";
import { LoginPage } from "../routes/LoginPage";
import { FilesPage } from "../routes/FilesPage";
import { RecycleBinPage } from "../routes/RecycleBinPage";
import { StoragePage } from "../routes/StoragePage";
import { PoliciesPage } from "../routes/PoliciesPage";
import { JobsPage } from "../routes/JobsPage";
function AppShell() {
const location = useLocation();
return (
<RequireAuth>
<AppLayout currentPath={location.pathname}>
<Outlet />
</AppLayout>
</RequireAuth>
);
}
export const router = createBrowserRouter([
{
path: "/app/login",
element: <LoginPage />,
},
{
path: "/app",
element: <AppShell />,
children: [
{
index: true,
element: <Navigate to="/app/files" replace />,
},
{
path: "files",
element: <FilesPage />,
},
{
path: "files/:directoryId",
element: <FilesPage />,
},
{
path: "recycle-bin",
element: <RecycleBinPage />,
},
{
path: "storage",
element: <StoragePage />,
},
{
path: "policies",
element: <PoliciesPage />,
},
{
path: "jobs",
element: <JobsPage />,
},
],
},
{
path: "*",
element: <Navigate to="/app" replace />,
},
]);

BIN
frontend/src/assets/fonts/OpenCloud500-Regular.woff2

Binary file not shown.

BIN
frontend/src/assets/fonts/OpenCloud750-Bold.woff2

Binary file not shown.

BIN
frontend/src/assets/fonts/inter.woff2

Binary file not shown.

32
frontend/src/components/Dialog.tsx

@ -1,32 +0,0 @@
type DialogProps = {
title: string;
description?: string;
children: React.ReactNode;
onClose: () => void;
};
export function Dialog({ title, description, children, onClose }: DialogProps) {
return (
<div className="dialog-backdrop" role="presentation" onClick={onClose}>
<div
className="dialog-card"
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(event) => event.stopPropagation()}
>
<div className="dialog-header">
<div>
<span className="eyebrow">Action</span>
<h3>{title}</h3>
{description ? <p className="item-muted">{description}</p> : null}
</div>
<button className="ghost-button" type="button" onClick={onClose}>
Close
</button>
</div>
{children}
</div>
</div>
);
}

55
frontend/src/components/Menu.tsx

@ -1,55 +0,0 @@
import { useEffect, useRef } from "react";
type MenuItem = {
label: string;
tone?: "default" | "danger";
onSelect: () => void;
};
type MenuProps = {
items: MenuItem[];
onClose: () => void;
};
export function Menu({ items, onClose }: MenuProps) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
function handlePointer(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
onClose();
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
window.addEventListener("mousedown", handlePointer);
window.addEventListener("keydown", handleEscape);
return () => {
window.removeEventListener("mousedown", handlePointer);
window.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
return (
<div ref={ref} className="menu-popover" role="menu">
{items.map((item) => (
<button
key={item.label}
type="button"
className={item.tone === "danger" ? "menu-item danger" : "menu-item"}
onClick={() => {
item.onSelect();
onClose();
}}
>
{item.label}
</button>
))}
</div>
);
}

117
frontend/src/components/PaginationControls.tsx

@ -1,117 +0,0 @@
type PaginationControlsProps = {
page: number;
pageSize: number;
totalItems: number;
totalPages: number;
onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void;
pageSizeOptions?: number[];
label: string;
};
export function PaginationControls({
page,
pageSize,
totalItems,
totalPages,
onPageChange,
onPageSizeChange,
pageSizeOptions = [8, 16, 32, 64],
label,
}: PaginationControlsProps) {
const safeTotalPages = Math.max(1, totalPages);
const pageItems = getPaginationItems(page, safeTotalPages);
return (
<div className="pagination-bar pagination-bar-full" aria-label={label}>
<span className="pagination-copy">
{totalItems} items
</span>
<div className="pagination-controls" role="group" aria-label={`${label} controls`}>
<button
className="pagination-page-button pagination-arrow-button"
type="button"
aria-label="Previous page"
disabled={page === 1}
onClick={() => onPageChange(Math.max(1, page - 1))}
>
</button>
<div className="pagination-page-list" aria-label={`Page ${page} of ${safeTotalPages}`}>
{pageItems.map((item, index) =>
item === "ellipsis" ? (
<span key={`ellipsis-${index}`} className="pagination-ellipsis" aria-hidden="true">
...
</span>
) : (
<button
key={item}
className={item === page ? "pagination-page-button active" : "pagination-page-button"}
type="button"
aria-current={item === page ? "page" : undefined}
onClick={() => onPageChange(item)}
>
{item}
</button>
),
)}
</div>
<button
className="pagination-page-button pagination-arrow-button"
type="button"
aria-label="Next page"
disabled={page === safeTotalPages}
onClick={() => onPageChange(Math.min(safeTotalPages, page + 1))}
>
</button>
<label className="pagination-size-control">
<select
className="input-select pagination-select"
aria-label="Rows per page"
value={pageSize}
onChange={(event) => onPageSizeChange(Number(event.target.value))}
>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>
{option} / page
</option>
))}
</select>
</label>
</div>
</div>
);
}
function getPaginationItems(page: number, totalPages: number): Array<number | "ellipsis"> {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, index) => index + 1);
}
const pages = new Set<number>([1, totalPages, page - 1, page, page + 1]);
if (page <= 4) {
[2, 3, 4, 5].forEach((item) => pages.add(item));
}
if (page >= totalPages - 3) {
[totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1].forEach((item) => pages.add(item));
}
const sortedPages = Array.from(pages)
.filter((item) => item >= 1 && item <= totalPages)
.sort((first, second) => first - second);
return sortedPages.flatMap((item, index) => {
const previous = sortedPages[index - 1];
if (!previous) {
return [item];
}
if (item - previous === 2) {
return [previous + 1, item];
}
if (item - previous > 2) {
return ["ellipsis", item];
}
return [item];
});
}

170
frontend/src/components/layout/AppLayout.tsx

@ -1,170 +0,0 @@
import type { ChangeEvent } from "react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { api } from "../../lib/api";
import { clearAccessToken } from "../../lib/auth";
import { useToast } from "../../features/toast/ToastProvider";
type AppLayoutProps = {
currentPath: string;
children: React.ReactNode;
};
const navItems = [
{ to: "/app/files", label: "Files", icon: "files" },
{ to: "/app/recycle-bin", label: "Deleted files", icon: "trash" },
{ to: "/app/storage", label: "Storage", icon: "storage" },
{ to: "/app/policies", label: "Policies", icon: "policies" },
{ to: "/app/jobs", label: "Jobs", icon: "jobs" },
];
export function AppLayout({ currentPath, children }: AppLayoutProps) {
const navigate = useNavigate();
const location = useLocation();
const { pushToast } = useToast();
const isFilesSearch = location.pathname.startsWith("/app/files");
const topbarSearchValue = isFilesSearch ? new URLSearchParams(location.search).get("q") ?? "" : "";
const meQuery = useQuery({
queryKey: ["auth", "me"],
queryFn: api.me,
retry: false,
});
async function handleLogout() {
try {
await api.logout();
} catch {
// best effort
}
clearAccessToken();
pushToast("Signed out.", "info");
navigate("/app/login", { replace: true });
}
function handleTopbarSearchChange(event: ChangeEvent<HTMLInputElement>) {
if (!isFilesSearch) {
return;
}
const params = new URLSearchParams(location.search);
const value = event.target.value.trimStart();
if (value) {
params.set("q", value);
} else {
params.delete("q");
}
navigate(
{
pathname: location.pathname,
search: params.toString() ? `?${params.toString()}` : "",
},
{ replace: true, state: location.state },
);
}
return (
<div className="app-frame">
<header className="oc-topbar" aria-label="Top bar">
<div className="topbar-brand">
<button className="app-switcher-button" type="button" aria-label="Application menu">
<span />
<span />
<span />
<span />
<span />
<span />
<span />
<span />
<span />
</button>
<div className="brand-lockup" aria-label="Iron Drive">
<span className="brand-symbol">I</span>
<strong>Iron</strong>
</div>
</div>
<div className="topbar-center">
<div className="topbar-search" role="search">
<input
aria-label="Search"
placeholder="Enter search term"
readOnly={!isFilesSearch}
value={topbarSearchValue}
onChange={handleTopbarSearchChange}
/>
<span className="topbar-search-scope">{getPageSearchScope(currentPath)}</span>
<span className="topbar-search-icon" aria-hidden="true" />
</div>
</div>
<div className="topbar-actions">
<button className="user-chip" type="button" onClick={handleLogout}>
<span>{getInitial(meQuery.data?.user.username)}</span>
</button>
</div>
</header>
<div className="app-shell">
<aside className="sidebar">
<div className="brand-block brand-block-compact">
<span className="eyebrow">Personal</span>
<h1>Drive</h1>
</div>
<nav className="main-nav" aria-label="Primary">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) => (isActive ? "nav-link active" : "nav-link")}
>
<span className={`nav-icon nav-icon-${item.icon}`} aria-hidden="true" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
<div className="sidebar-footer">
<span>Iron Web UI</span>
<span>Personal cloud drive</span>
</div>
</aside>
<div className="workspace">
<main className="workspace-content">{children}</main>
</div>
</div>
</div>
);
}
function getInitial(username: string | undefined) {
return (username?.trim().charAt(0) || "I").toUpperCase();
}
function getPageSearchScope(pathname: string) {
if (pathname.startsWith("/app/files")) {
return "All files";
}
return getPageTitle(pathname);
}
function getPageTitle(pathname: string) {
if (pathname.startsWith("/app/files")) {
return "Files";
}
if (pathname.startsWith("/app/recycle-bin")) {
return "Recycle Bin";
}
if (pathname.startsWith("/app/storage")) {
return "Storage";
}
if (pathname.startsWith("/app/policies")) {
return "Policies";
}
if (pathname.startsWith("/app/jobs")) {
return "Jobs";
}
return "Iron";
}

29
frontend/src/features/auth/RequireAuth.tsx

@ -1,29 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { Navigate, useLocation } from "react-router-dom";
import { api, ApiError } from "../../lib/api";
import { getAccessToken } from "../../lib/auth";
export function RequireAuth({ children }: { children: React.ReactNode }) {
const location = useLocation();
const token = getAccessToken();
const query = useQuery({
queryKey: ["auth", "me"],
queryFn: api.me,
enabled: Boolean(token),
retry: false,
});
if (!token) {
return <Navigate to="/app/login" replace state={{ from: `${location.pathname}${location.search}` }} />;
}
if (query.isLoading) {
return <div className="page-loading">Loading your drive</div>;
}
if (query.error instanceof ApiError && query.error.status === 401) {
return <Navigate to="/app/login" replace state={{ from: `${location.pathname}${location.search}` }} />;
}
return <>{children}</>;
}

50
frontend/src/features/toast/ToastProvider.tsx

@ -1,50 +0,0 @@
import { createContext, useCallback, useContext, useMemo, useState } from "react";
type ToastTone = "info" | "success" | "error";
type ToastItem = {
id: string;
title: string;
tone: ToastTone;
};
type ToastContextValue = {
pushToast: (title: string, tone?: ToastTone) => void;
};
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const pushToast = useCallback((title: string, tone: ToastTone = "info") => {
const id = crypto.randomUUID();
setToasts((current) => [...current.slice(-1), { id, title, tone }]);
window.setTimeout(() => {
setToasts((current) => current.filter((item) => item.id !== id));
}, 2200);
}, []);
const value = useMemo(() => ({ pushToast }), [pushToast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-stack" aria-live="polite" aria-atomic="true">
{toasts.map((toast) => (
<div key={toast.id} className={`toast-item toast-${toast.tone}`}>
{toast.title}
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within ToastProvider.");
}
return context;
}

43
frontend/src/main.ts

@ -0,0 +1,43 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import DesignSystem from "@opencloud-eu/design-system";
import "@opencloud-eu/design-system/tailwind";
import "@opencloud-eu/design-system/dist/design-system.css";
import "./styles/opencloud-app.css";
import App from "./App.vue";
const app = createApp(App);
app.use(createPinia());
app.use(DesignSystem, {
iconUrlPrefix: "/icons/",
language: {
initGettext: true,
defaultLanguage: "en",
},
tokens: {
roles: {
primary: "#00677f",
onPrimary: "#ffffff",
primaryContainer: "#b7eaff",
secondary: "#20434f",
onSecondary: "#ffffff",
secondaryContainer: "#cfe6f1",
background: "#ffffff",
onBackground: "#191c1d",
surface: "#ffffff",
onSurface: "#191c1d",
surfaceContainer: "#f6f8fa",
surfaceContainerHigh: "#f2f4f5",
surfaceContainerHighest: "#eceef0",
outline: "#70787c",
outlineVariant: "#bfc8cc",
chrome: "#20434f",
onChrome: "#ffffff",
error: "#ba1a1a",
onError: "#ffffff",
},
},
});
app.mount("#root");

10
frontend/src/main.tsx

@ -1,10 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { App } from "./app/App";
import "./styles/app.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

1050
frontend/src/routes/FilesPage.tsx

File diff suppressed because it is too large

215
frontend/src/routes/JobsPage.tsx

@ -1,215 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { PaginationControls } from "../components/PaginationControls";
import { api, type JobSummary } from "../lib/api";
import { formatDate, titleCase } from "../lib/format";
export function JobsPage() {
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(8);
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const jobsQuery = useQuery({
queryKey: ["jobs"],
queryFn: api.listJobs,
});
const refreshJobs = async () => {
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
};
const retryMutation = useMutation({
mutationFn: (jobId: string) => api.retryJob(jobId),
onSuccess: refreshJobs,
});
const runMutation = useMutation({
mutationFn: api.runPendingJobs,
onSuccess: refreshJobs,
});
const healthMutation = useMutation({
mutationFn: api.enqueueHealthChecks,
onSuccess: refreshJobs,
});
const reconcileMutation = useMutation({
mutationFn: api.enqueueFullReconcile,
onSuccess: refreshJobs,
});
const items = jobsQuery.data?.items ?? [];
const pendingCount = items.filter((job) => job.status === "pending").length;
const completedCount = items.filter((job) => job.status === "completed").length;
const failedCount = items.filter((job) => job.status === "failed").length;
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const pagedItems = useMemo(
() => items.slice((page - 1) * pageSize, page * pageSize),
[items, page, pageSize],
);
const selectedJob = items.find((job) => job.id === selectedJobId) ?? null;
useEffect(() => {
if (page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
useEffect(() => {
if (selectedJobId && !items.some((job) => job.id === selectedJobId)) {
setSelectedJobId(null);
}
}, [items, selectedJobId]);
return (
<div className="compact-page-shell compact-page-shell-wide">
<div className="jobs-page-layout">
<section className="panel main-panel dashboard-main-panel">
<div className="panel-toolbar files-toolbar-top">
<div className="files-header">
<span className="eyebrow">Jobs</span>
<div className="directory-title-row">
<h2>Background activity</h2>
</div>
<div className="inline-metrics">
<span>{pendingCount} pending</span>
<span>{completedCount} completed</span>
<span>{failedCount} failed</span>
</div>
</div>
<div className="toolbar-group files-command-bar">
<button className="ghost-button" type="button" onClick={() => runMutation.mutate()}>
Run Pending
</button>
<button className="ghost-button" type="button" onClick={() => healthMutation.mutate()}>
Enqueue Checks
</button>
<button className="primary-button" type="button" onClick={() => reconcileMutation.mutate()}>
Full Reconcile
</button>
</div>
</div>
<div className="jobs-list-shell list-surface">
<div className="list-header jobs-list-header">
<span>Job</span>
<span>Status</span>
<span>Attempts</span>
<span className="list-header-actions">Actions</span>
</div>
<div className="list-table">
{items.length ? (
pagedItems.map((job) => (
<div
key={job.id}
className={selectedJobId === job.id ? "table-row jobs-table-row active" : "table-row jobs-table-row"}
role="button"
tabIndex={0}
onClick={() => setSelectedJobId(job.id)}
onKeyDown={(event) => {
if (event.key === "Enter") {
setSelectedJobId(job.id);
}
}}
>
<div>
<strong>{titleCase(job.kind)}</strong>
<span className="item-muted">Run after {formatDate(job.run_after)}</span>
{job.last_error ? <span className="error-text">{job.last_error}</span> : null}
</div>
<div className="table-meta jobs-row-meta">
<span className="status-chip">{titleCase(job.status)}</span>
<span>
{job.attempt_count}/{job.max_attempts}
</span>
<button
className="ghost-button compact-button"
type="button"
onClick={(event) => {
event.stopPropagation();
retryMutation.mutate(job.id);
}}
>
Retry
</button>
</div>
</div>
))
) : (
<div className="empty-state empty-state-rich">
<strong>No background jobs recorded yet.</strong>
</div>
)}
</div>
</div>
<PaginationControls
label="Jobs pagination"
page={page}
pageSize={pageSize}
totalItems={items.length}
totalPages={totalPages}
onPageChange={setPage}
onPageSizeChange={(nextPageSize) => {
setPageSize(nextPageSize);
setPage(1);
}}
/>
</section>
<aside className="panel detail-panel jobs-detail-panel">
{!selectedJob ? (
<div className="detail-empty">
<span className="eyebrow">Inspector</span>
<h3>Select a job</h3>
</div>
) : (
<>
<div className="section-heading">
<span className="eyebrow">Job Detail</span>
<h3>{titleCase(selectedJob.kind)}</h3>
</div>
<div className="inspector-storage-summary">
<div>
<span>Status</span>
<strong>{titleCase(selectedJob.status)}</strong>
</div>
<span className="status-chip">
{selectedJob.attempt_count}/{selectedJob.max_attempts}
</span>
</div>
<div className="meta-list inspector-card jobs-detail-meta">
<div>
<span>Run after</span>
<strong>{formatDate(selectedJob.run_after)}</strong>
</div>
<div>
<span>Created</span>
<strong>{formatDate(selectedJob.created_at)}</strong>
</div>
<div>
<span>Updated</span>
<strong>{formatDate(selectedJob.updated_at)}</strong>
</div>
<div>
<span>ID</span>
<strong className="mono-text">{selectedJob.id}</strong>
</div>
</div>
{selectedJob.last_error ? (
<div className="inspector-card replica-warning-card">
<strong>Last error</strong>
<p className="item-muted">{selectedJob.last_error}</p>
</div>
) : null}
<section className="inspector-section">
<span className="eyebrow">Payload</span>
<pre className="payload-preview">{formatJobPayload(selectedJob)}</pre>
</section>
</>
)}
</aside>
</div>
</div>
);
}
function formatJobPayload(job: JobSummary) {
try {
return JSON.stringify(JSON.parse(job.payload_json), null, 2);
} catch {
return job.payload_json || "{}";
}
}

79
frontend/src/routes/LoginPage.tsx

@ -1,79 +0,0 @@
import { FormEvent, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { api } from "../lib/api";
import { setAccessToken } from "../lib/auth";
export function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("changeme-iron");
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsSubmitting(true);
setError("");
try {
const result = await api.login(username, password);
setAccessToken(result.access_token);
navigate((location.state as { from?: string } | null)?.from || "/app/files", { replace: true });
} catch (submitError) {
setError(submitError instanceof Error ? submitError.message : "Unable to sign in.");
} finally {
setIsSubmitting(false);
}
}
return (
<div className="login-page">
<section className="login-hero">
<span className="eyebrow">Iron</span>
<h1>One personal drive, under your control.</h1>
<p>
Browse media, upload from the browser, and inspect storage health
without turning your daily file flow into an ops chore.
</p>
<div className="hero-metrics">
<article>
<span className="metric-label">Product target</span>
<strong>Personal drive UI</strong>
</article>
<article>
<span className="metric-label">Runtime model</span>
<strong>Gateway + multi-backend storage</strong>
</article>
</div>
</section>
<section className="login-card">
<div className="section-heading">
<span className="eyebrow">Local Login</span>
<h2>Enter your drive</h2>
</div>
<form className="stack-form" onSubmit={handleSubmit}>
<label>
<span>Username</span>
<input value={username} onChange={(event) => setUsername(event.target.value)} required />
</label>
<label>
<span>Password</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
<button className="primary-button" type="submit" disabled={isSubmitting}>
{isSubmitting ? "Signing in…" : "Sign in"}
</button>
{error ? <p className="error-text">{error}</p> : null}
</form>
</section>
</div>
);
}

279
frontend/src/routes/PoliciesPage.tsx

@ -1,279 +0,0 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type PlacementPolicyPayload, type PlacementPolicySummary } from "../lib/api";
import { useToast } from "../features/toast/ToastProvider";
import { formatBackendType, formatBytes, titleCase } from "../lib/format";
const FILE_CLASSES: PlacementPolicySummary["file_class"][] = ["document", "image", "video", "other"];
const MIME_PRESETS: Record<PlacementPolicySummary["file_class"], string> = {
document: "application/pdf",
image: "image/png",
video: "video/mp4",
other: "application/octet-stream",
};
export function PoliciesPage() {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [selectedClass, setSelectedClass] = useState<PlacementPolicySummary["file_class"]>("document");
const [requireLocal, setRequireLocal] = useState(true);
const [stableCount, setStableCount] = useState(1);
const [opportunisticCount, setOpportunisticCount] = useState(0);
const [preferredBackendIds, setPreferredBackendIds] = useState<string[]>([]);
const [excludedBackendIds, setExcludedBackendIds] = useState<string[]>([]);
const [maxNonLocalSize, setMaxNonLocalSize] = useState("");
const [mimeType, setMimeType] = useState(MIME_PRESETS.document);
const [previewSize, setPreviewSize] = useState("1048576");
const [formError, setFormError] = useState("");
const policiesQuery = useQuery({
queryKey: ["placement-policies"],
queryFn: api.listPlacementPolicies,
});
const backendQuery = useQuery({
queryKey: ["backends"],
queryFn: api.listBackends,
});
const policies = policiesQuery.data?.items ?? [];
const backends = backendQuery.data?.items ?? [];
const selectedPolicy = policies.find((policy) => policy.file_class === selectedClass);
useEffect(() => {
if (!selectedPolicy) {
return;
}
setRequireLocal(selectedPolicy.require_local);
setStableCount(selectedPolicy.stable_replica_count);
setOpportunisticCount(selectedPolicy.opportunistic_replica_count);
setPreferredBackendIds(selectedPolicy.preferred_backend_ids);
setExcludedBackendIds(selectedPolicy.excluded_backend_ids);
setMaxNonLocalSize(
selectedPolicy.max_non_local_size_bytes === null ? "" : String(selectedPolicy.max_non_local_size_bytes),
);
}, [selectedPolicy?.id, selectedPolicy?.updated_at]);
useEffect(() => {
setMimeType(MIME_PRESETS[selectedClass]);
}, [selectedClass]);
const previewSizeBytes = parseOptionalInt(previewSize);
const previewQuery = useQuery({
queryKey: ["placement-preview", mimeType, previewSizeBytes],
queryFn: () => api.previewPlacementDecision(mimeType, previewSizeBytes),
});
const updateMutation = useMutation({
mutationFn: (payload: PlacementPolicyPayload) => api.updatePlacementPolicy(selectedClass, payload),
onSuccess: async (result) => {
setFormError("");
pushToast(
`Policy saved. Full reconcile queued ${result.reconcile_enqueued_jobs} job${result.reconcile_enqueued_jobs === 1 ? "" : "s"}.`,
"success",
);
await queryClient.invalidateQueries({ queryKey: ["placement-policies"] });
await queryClient.invalidateQueries({ queryKey: ["placement-preview"] });
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
},
onError: (error) => {
setFormError(error instanceof Error ? error.message : "Policy update failed.");
pushToast(error instanceof Error ? error.message : "Policy update failed.", "error");
},
});
const localBackendCount = backends.filter((backend) => backend.type === "local_directory" && backend.is_enabled).length;
const stableBackendCount = backends.filter((backend) => backend.stability_class === "stable" && backend.is_enabled).length;
const opportunisticBackendCount = backends.filter(
(backend) => backend.stability_class === "opportunistic" && backend.is_enabled,
).length;
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const maxSize = parseOptionalInt(maxNonLocalSize);
if (maxNonLocalSize.trim() && maxSize === null) {
setFormError("Max non-local size must be a whole number.");
return;
}
updateMutation.mutate({
require_local: requireLocal,
stable_replica_count: Math.max(0, stableCount),
opportunistic_replica_count: Math.max(0, opportunisticCount),
preferred_backend_ids: preferredBackendIds,
excluded_backend_ids: excludedBackendIds,
max_non_local_size_bytes: maxSize,
});
}
function toggleBackend(listName: "preferred" | "excluded", backendId: string) {
if (listName === "preferred") {
setPreferredBackendIds((current) => toggleId(current, backendId));
setExcludedBackendIds((current) => current.filter((id) => id !== backendId));
return;
}
setExcludedBackendIds((current) => toggleId(current, backendId));
setPreferredBackendIds((current) => current.filter((id) => id !== backendId));
}
return (
<div className="compact-page-shell compact-page-shell-wide">
<div className="dashboard-page-grid">
<section className="dashboard-main-column">
<div className="panel-toolbar storage-toolbar">
<div className="files-header">
<span className="eyebrow">Placement</span>
<div className="directory-title-row">
<h2>Replica policies</h2>
</div>
<div className="inline-metrics">
<span>{policies.length} policies</span>
<span>{localBackendCount} local</span>
<span>{stableBackendCount} stable</span>
<span>{opportunisticBackendCount} opportunistic</span>
</div>
</div>
</div>
<div className="policy-tabs" role="tablist" aria-label="File classes">
{FILE_CLASSES.map((fileClass) => (
<button
key={fileClass}
className={selectedClass === fileClass ? "policy-tab active" : "policy-tab"}
type="button"
onClick={() => setSelectedClass(fileClass)}
>
{titleCase(fileClass)}
</button>
))}
</div>
<form className="panel policy-editor" onSubmit={handleSubmit}>
<div className="policy-editor-grid">
<label className="checkbox-field">
<input
type="checkbox"
checked={requireLocal}
onChange={(event) => setRequireLocal(event.target.checked)}
/>
<span>Require local replica</span>
</label>
<label>
<span>Stable replicas</span>
<input
type="number"
min={0}
value={stableCount}
onChange={(event) => setStableCount(Number(event.target.value))}
/>
</label>
<label>
<span>Opportunistic replicas</span>
<input
type="number"
min={0}
value={opportunisticCount}
onChange={(event) => setOpportunisticCount(Number(event.target.value))}
/>
</label>
<label>
<span>Max non-local bytes</span>
<input
value={maxNonLocalSize}
onChange={(event) => setMaxNonLocalSize(event.target.value)}
placeholder="No limit"
/>
</label>
</div>
<div className="backend-policy-list">
{backends.map((backend) => (
<div key={backend.id} className="backend-policy-row">
<div>
<strong>{backend.name}</strong>
<span className="item-muted">
{formatBackendType(backend.type)} · {titleCase(backend.stability_class)}
</span>
</div>
<div className="backend-policy-actions">
<label className="checkbox-field compact-checkbox">
<input
type="checkbox"
checked={preferredBackendIds.includes(backend.id)}
onChange={() => toggleBackend("preferred", backend.id)}
/>
<span>Prefer</span>
</label>
<label className="checkbox-field compact-checkbox">
<input
type="checkbox"
checked={excludedBackendIds.includes(backend.id)}
onChange={() => toggleBackend("excluded", backend.id)}
/>
<span>Exclude</span>
</label>
</div>
</div>
))}
</div>
{formError ? <p className="error-text">{formError}</p> : null}
<div className="dialog-actions">
<button className="primary-button" type="submit" disabled={updateMutation.isPending}>
Save Policy
</button>
</div>
</form>
</section>
<aside className="dashboard-side-column">
<section className="panel dashboard-side-panel">
<div className="section-heading">
<span className="eyebrow">Preview</span>
<h3>Placement decision</h3>
</div>
<div className="dialog-form">
<label>
<span>Media type</span>
<input value={mimeType} onChange={(event) => setMimeType(event.target.value)} />
</label>
<label>
<span>Object size</span>
<input value={previewSize} onChange={(event) => setPreviewSize(event.target.value)} />
</label>
</div>
<div className="decision-list">
<span className="status-chip">
{previewQuery.data?.non_local_allowed === false ? "Local only" : "Non-local allowed"}
</span>
<span className="item-muted">
{previewSizeBytes === null ? "No size input" : formatBytes(previewSizeBytes)}
</span>
{(previewQuery.data?.selected_backends ?? []).map((backend) => (
<div key={backend.id} className="decision-backend">
<strong>{backend.name}</strong>
<span>{formatBackendType(backend.type)}</span>
</div>
))}
</div>
</section>
</aside>
</div>
</div>
);
}
function toggleId(values: string[], id: string) {
return values.includes(id) ? values.filter((value) => value !== id) : [...values, id];
}
function parseOptionalInt(value: string) {
const cleaned = value.trim();
if (!cleaned) {
return null;
}
const parsed = Number(cleaned);
if (!Number.isInteger(parsed) || parsed < 0) {
return null;
}
return parsed;
}

97
frontend/src/routes/RecycleBinPage.tsx

@ -1,97 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { PaginationControls } from "../components/PaginationControls";
import { api } from "../lib/api";
import { formatBytes, formatDate } from "../lib/format";
export function RecycleBinPage() {
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(8);
const recycleQuery = useQuery({
queryKey: ["recycle-bin"],
queryFn: api.listRecycleBin,
});
const restoreMutation = useMutation({
mutationFn: (fileId: string) => api.restoreFile(fileId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["recycle-bin"] });
},
});
const items = recycleQuery.data?.items ?? [];
const totalBytes = items.reduce((sum, item) => sum + item.size_bytes, 0);
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const pagedItems = useMemo(
() => items.slice((page - 1) * pageSize, page * pageSize),
[items, page, pageSize],
);
useEffect(() => {
if (page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
return (
<div className="compact-page-shell compact-page-shell-wide">
<section className="panel dashboard-main-panel">
<div className="panel-toolbar files-toolbar-top">
<div className="files-header">
<span className="eyebrow">Deleted files</span>
<div className="directory-title-row">
<h2>Recycle Bin</h2>
</div>
<div className="inline-metrics">
<span>{items.length} items</span>
<span>{formatBytes(totalBytes)}</span>
</div>
</div>
</div>
<div className="list-surface">
<div className="list-header recycle-list-header">
<span>Name</span>
<span>Deleted</span>
<span>Size</span>
<span className="list-header-actions">Actions</span>
</div>
<div className="list-table">
{items.length ? (
pagedItems.map((item) => (
<div key={item.id} className="table-row recycle-row">
<div>
<strong>{item.name}</strong>
<span className="item-muted">Recoverable file</span>
</div>
<div className="table-meta recycle-row-meta">
<span>{formatDate(item.deleted_at)}</span>
<span>{formatBytes(item.size_bytes)}</span>
<button className="ghost-button compact-button" type="button" onClick={() => restoreMutation.mutate(item.id)}>
Restore
</button>
</div>
</div>
))
) : (
<div className="empty-state empty-state-rich">
<strong>Recycle bin is empty.</strong>
</div>
)}
</div>
</div>
<PaginationControls
label="Recycle bin pagination"
page={page}
pageSize={pageSize}
totalItems={items.length}
totalPages={totalPages}
onPageChange={setPage}
onPageSizeChange={(nextPageSize) => {
setPageSize(nextPageSize);
setPage(1);
}}
/>
</section>
</div>
);
}

518
frontend/src/routes/StoragePage.tsx

@ -1,518 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FormEvent, useState } from "react";
import { Dialog } from "../components/Dialog";
import { useToast } from "../features/toast/ToastProvider";
import { api } from "../lib/api";
import type { BackendSummary } from "../lib/api";
import { formatBackendType, formatDate, titleCase } from "../lib/format";
type BackendType = "local_directory" | "s3" | "webdav";
type StabilityClass = "local" | "stable" | "opportunistic";
const CONFIG_TEMPLATES: Record<BackendType, string> = {
local_directory: JSON.stringify({ base_path: "./.iron-storage/archive" }, null, 2),
s3: JSON.stringify(
{
bucket: "iron",
region: "us-east-1",
endpoint_url: "http://127.0.0.1:9000",
access_key_id: "",
secret_access_key: "",
prefix: "iron",
force_path_style: true,
},
null,
2,
),
webdav: JSON.stringify(
{
endpoint_url: "http://127.0.0.1:5244/dav",
username: "iron",
password: "",
root_path: "iron",
verify_ssl: true,
},
null,
2,
),
};
export function StoragePage() {
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [backendType, setBackendType] = useState<BackendType>("s3");
const [stabilityClass, setStabilityClass] = useState<StabilityClass>("stable");
const [name, setName] = useState("");
const [readPriority, setReadPriority] = useState(80);
const [writePriority, setWritePriority] = useState(80);
const [configText, setConfigText] = useState(CONFIG_TEMPLATES.s3);
const [formError, setFormError] = useState("");
const [editingBackend, setEditingBackend] = useState<BackendSummary | null>(null);
const [editName, setEditName] = useState("");
const [editStabilityClass, setEditStabilityClass] = useState<StabilityClass>("stable");
const [editReadPriority, setEditReadPriority] = useState(80);
const [editWritePriority, setEditWritePriority] = useState(80);
const [editConfigText, setEditConfigText] = useState("{}");
const [editSecretText, setEditSecretText] = useState("{}");
const [editError, setEditError] = useState("");
const [deleteTarget, setDeleteTarget] = useState<BackendSummary | null>(null);
const backendQuery = useQuery({
queryKey: ["backends"],
queryFn: api.listBackends,
});
function handleTypeChange(nextType: BackendType) {
setBackendType(nextType);
setConfigText(CONFIG_TEMPLATES[nextType]);
setStabilityClass(nextType === "local_directory" ? "local" : "stable");
if (!name.trim()) {
setName(
nextType === "local_directory"
? "local-archive"
: nextType === "s3"
? "s3-main"
: "openlist-main",
);
}
}
const createMutation = useMutation({
mutationFn: api.createBackend,
onSuccess: async () => {
setIsCreateOpen(false);
setFormError("");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
setFormError(error instanceof Error ? error.message : "Backend creation failed.");
},
});
const checkMutation = useMutation({
mutationFn: (backendId: string) => api.checkBackend(backendId),
onSuccess: async () => {
pushToast("Backend check finished.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Backend check failed.", "error");
},
});
const disableMutation = useMutation({
mutationFn: (backendId: string) => api.disableBackend(backendId),
onSuccess: async () => {
pushToast("Backend disabled.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Disable failed.", "error");
},
});
const enableMutation = useMutation({
mutationFn: (backendId: string) => api.enableBackend(backendId),
onSuccess: async () => {
pushToast("Backend enabled.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Enable failed.", "error");
},
});
const runAllChecksMutation = useMutation({
mutationFn: api.enqueueHealthChecks,
onSuccess: async (result) => {
pushToast(`Queued ${result.enqueued_jobs} backend health check job${result.enqueued_jobs === 1 ? "" : "s"}.`, "success");
await queryClient.invalidateQueries({ queryKey: ["jobs"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Could not queue health checks.", "error");
},
});
const updateMutation = useMutation({
mutationFn: (payload: { backendId: string; form: ReturnType<typeof buildEditPayload> }) =>
api.updateBackend(payload.backendId, payload.form),
onSuccess: async () => {
setEditingBackend(null);
setEditError("");
pushToast("Backend updated.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
},
onError: (error) => {
setEditError(error instanceof Error ? error.message : "Backend update failed.");
},
});
const deleteMutation = useMutation({
mutationFn: (backendId: string) => api.deleteBackend(backendId),
onSuccess: async () => {
const deletedName = deleteTarget?.name;
setDeleteTarget(null);
pushToast(deletedName ? `Deleted ${deletedName}.` : "Backend deleted.", "success");
await queryClient.invalidateQueries({ queryKey: ["backends"] });
await queryClient.invalidateQueries({ queryKey: ["placement-policies"] });
},
onError: (error) => {
pushToast(error instanceof Error ? error.message : "Delete failed.", "error");
},
});
const items = backendQuery.data?.items ?? [];
const healthyCount = items.filter((backend) => (backend.last_health_status ?? "unknown") === "healthy").length;
const enabledCount = items.filter((backend) => backend.is_enabled).length;
const disabledCount = items.filter((backend) => !backend.is_enabled).length;
function handleCreateBackend(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setFormError("");
let config: Record<string, unknown>;
try {
config = JSON.parse(configText) as Record<string, unknown>;
} catch {
setFormError("Config must be valid JSON.");
return;
}
createMutation.mutate({
name,
type: backendType,
stability_class: stabilityClass,
read_priority: readPriority,
write_priority: writePriority,
config,
});
}
function openEditBackend(backend: BackendSummary) {
setEditingBackend(backend);
setEditName(backend.name);
setEditStabilityClass(backend.stability_class as StabilityClass);
setEditReadPriority(backend.read_priority);
setEditWritePriority(backend.write_priority);
setEditConfigText(JSON.stringify(backend.config ?? {}, null, 2));
setEditSecretText("{}");
setEditError("");
}
function handleUpdateBackend(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!editingBackend) {
return;
}
setEditError("");
let form;
try {
form = buildEditPayload({
name: editName,
stabilityClass: editStabilityClass,
readPriority: editReadPriority,
writePriority: editWritePriority,
configText: editConfigText,
secretText: editSecretText,
});
} catch (error) {
setEditError(error instanceof Error ? error.message : "Config must be valid JSON.");
return;
}
updateMutation.mutate({ backendId: editingBackend.id, form });
}
return (
<div className="compact-page-shell compact-page-shell-wide">
<section className="panel dashboard-main-panel">
<div className="panel-toolbar files-toolbar-top">
<div className="files-header">
<span className="eyebrow">Storage</span>
<div className="directory-title-row">
<h2>Backends</h2>
</div>
<div className="inline-metrics">
<span>{items.length} backends</span>
<span>{healthyCount} healthy</span>
<span>{enabledCount} enabled</span>
<span>{disabledCount} disabled</span>
</div>
</div>
<div className="toolbar-group files-command-bar">
<button className="primary-button" type="button" onClick={() => setIsCreateOpen(true)}>
Add Backend
</button>
<button className="ghost-button" type="button" onClick={() => runAllChecksMutation.mutate()}>
Run All Checks
</button>
</div>
</div>
<div className="list-surface storage-list-surface">
<div className="list-header storage-list-header">
<span>Backend</span>
<span>Status</span>
<span>Class</span>
<span>Checked</span>
<span className="list-header-actions">Actions</span>
</div>
<div className="list-table">
{items.map((backend) => (
<div key={backend.id} className="table-row storage-table-row">
<div>
<strong>{backend.name}</strong>
<span className="item-muted">
{formatBackendType(backend.type)} · {formatBackendConfig(backend.config)}
{backend.secret_fields.length ? ` · ${backend.secret_fields.length} secrets` : ""}
</span>
</div>
<div className="table-meta storage-row-meta">
<span className={backend.is_enabled ? "status-chip status-chip-success" : "status-chip"}>
{backend.is_enabled ? "Enabled" : "Disabled"}
</span>
<span className="status-chip">{titleCase(backend.last_health_status ?? "unknown")}</span>
<span>{titleCase(backend.stability_class)}</span>
<span>{formatDate(backend.last_health_checked_at)}</span>
<div className="toolbar-group storage-row-actions">
<button className="ghost-button compact-button" type="button" onClick={() => checkMutation.mutate(backend.id)}>
Check
</button>
<button className="ghost-button compact-button" type="button" onClick={() => openEditBackend(backend)}>
Edit
</button>
{backend.is_enabled ? (
<button className="danger-button compact-button" type="button" onClick={() => disableMutation.mutate(backend.id)}>
Disable
</button>
) : (
<>
<button className="ghost-button compact-button" type="button" onClick={() => enableMutation.mutate(backend.id)}>
Enable
</button>
<button className="danger-button compact-button" type="button" onClick={() => setDeleteTarget(backend)}>
Delete
</button>
</>
)}
</div>
</div>
</div>
))}
{!items.length ? (
<div className="empty-state empty-state-rich">
<strong>No storage backends configured.</strong>
</div>
) : null}
</div>
</div>
</section>
{isCreateOpen ? (
<Dialog title="Add storage backend" onClose={() => setIsCreateOpen(false)}>
<form className="dialog-form" onSubmit={handleCreateBackend}>
<label>
<span>Type</span>
<select value={backendType} onChange={(event) => handleTypeChange(event.target.value as BackendType)}>
<option value="local_directory">Local directory</option>
<option value="s3">S3</option>
<option value="webdav">OpenList / WebDAV</option>
</select>
</label>
<label>
<span>Name</span>
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="storage-main" />
</label>
<label>
<span>Policy class</span>
<select
value={stabilityClass}
onChange={(event) => setStabilityClass(event.target.value as StabilityClass)}
>
<option value="local">Local</option>
<option value="stable">Stable</option>
<option value="opportunistic">Opportunistic</option>
</select>
</label>
<div className="split-fields">
<label>
<span>Read priority</span>
<input
type="number"
value={readPriority}
onChange={(event) => setReadPriority(Number(event.target.value))}
/>
</label>
<label>
<span>Write priority</span>
<input
type="number"
value={writePriority}
onChange={(event) => setWritePriority(Number(event.target.value))}
/>
</label>
</div>
<label>
<span>Config JSON</span>
<textarea
value={configText}
onChange={(event) => setConfigText(event.target.value)}
rows={backendType === "webdav" ? 8 : 9}
spellCheck={false}
/>
</label>
{formError ? <p className="error-text">{formError}</p> : null}
<div className="dialog-actions">
<button className="ghost-button" type="button" onClick={() => setIsCreateOpen(false)}>
Cancel
</button>
<button className="primary-button" type="submit" disabled={createMutation.isPending}>
Create
</button>
</div>
</form>
</Dialog>
) : null}
{editingBackend ? (
<Dialog title="Edit storage backend" onClose={() => setEditingBackend(null)}>
<form className="dialog-form" onSubmit={handleUpdateBackend}>
<label>
<span>Type</span>
<input value={editingBackend.type} disabled />
</label>
<label>
<span>Name</span>
<input value={editName} onChange={(event) => setEditName(event.target.value)} />
</label>
<label>
<span>Policy class</span>
<select
value={editStabilityClass}
onChange={(event) => setEditStabilityClass(event.target.value as StabilityClass)}
>
<option value="local">Local</option>
<option value="stable">Stable</option>
<option value="opportunistic">Opportunistic</option>
</select>
</label>
<div className="split-fields">
<label>
<span>Read priority</span>
<input
type="number"
value={editReadPriority}
onChange={(event) => setEditReadPriority(Number(event.target.value))}
/>
</label>
<label>
<span>Write priority</span>
<input
type="number"
value={editWritePriority}
onChange={(event) => setEditWritePriority(Number(event.target.value))}
/>
</label>
</div>
<label>
<span>Public config JSON</span>
<textarea
value={editConfigText}
onChange={(event) => setEditConfigText(event.target.value)}
rows={8}
spellCheck={false}
/>
</label>
<label>
<span>Secret override JSON</span>
<textarea
value={editSecretText}
onChange={(event) => setEditSecretText(event.target.value)}
rows={4}
spellCheck={false}
/>
</label>
<div className="meta-list compact-meta-list">
<div>
<span>Stored secrets</span>
<strong>{editingBackend.secret_fields.length ? editingBackend.secret_fields.join(", ") : "None"}</strong>
</div>
</div>
{editError ? <p className="error-text">{editError}</p> : null}
<div className="dialog-actions">
<button className="ghost-button" type="button" onClick={() => setEditingBackend(null)}>
Cancel
</button>
<button className="primary-button" type="submit" disabled={updateMutation.isPending}>
Save
</button>
</div>
</form>
</Dialog>
) : null}
{deleteTarget ? (
<Dialog title="Delete storage backend" onClose={() => setDeleteTarget(null)}>
<div className="dialog-form">
<div className="empty-state">
<strong>{deleteTarget.name}</strong>
<p className="item-muted">
Delete only works for disabled backends with no stored replicas and no placement policy references.
</p>
</div>
<div className="dialog-actions">
<button className="ghost-button" type="button" onClick={() => setDeleteTarget(null)}>
Cancel
</button>
<button
className="danger-button"
type="button"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(deleteTarget.id)}
>
Delete
</button>
</div>
</div>
</Dialog>
) : null}
</div>
);
}
function buildEditPayload(input: {
name: string;
stabilityClass: StabilityClass;
readPriority: number;
writePriority: number;
configText: string;
secretText: string;
}) {
const publicConfig = JSON.parse(input.configText) as Record<string, unknown>;
const secretConfig = JSON.parse(input.secretText) as Record<string, unknown>;
if (!isObject(publicConfig) || !isObject(secretConfig)) {
throw new Error("Config must be a JSON object.");
}
return {
name: input.name,
stability_class: input.stabilityClass,
read_priority: input.readPriority,
write_priority: input.writePriority,
config: { ...publicConfig, ...secretConfig },
};
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function formatBackendConfig(config: Record<string, unknown> | null) {
if (!config) {
return "Configured";
}
if (typeof config.base_path === "string") {
return config.base_path;
}
if (typeof config.bucket === "string") {
const prefix = typeof config.prefix === "string" && config.prefix ? `/${config.prefix}` : "";
return `${config.bucket}${prefix}`;
}
if (typeof config.endpoint_url === "string") {
const rootPath = typeof config.root_path === "string" ? config.root_path : "iron";
return `${config.endpoint_url}/${rootPath}`;
}
return "Configured";
}

3502
frontend/src/styles/app.css

File diff suppressed because it is too large

767
frontend/src/styles/opencloud-app.css

@ -0,0 +1,767 @@
html,
body,
#root {
min-height: 100%;
}
body {
margin: 0;
background: var(--oc-role-background);
color: var(--oc-role-on-surface);
font-family: var(--oc-font-family), Inter, "Segoe UI", sans-serif;
overflow: auto;
}
button,
input,
select,
textarea {
font: inherit;
}
.iron-app {
min-height: 100vh;
display: grid;
grid-template-rows: 52px minmax(0, 1fr);
background: var(--oc-role-background);
}
.iron-topbar {
display: grid;
grid-template-columns: 230px minmax(260px, 590px) 44px;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0 1rem;
color: var(--oc-role-on-chrome);
background: var(--oc-role-chrome);
}
.brand-area,
.brand-lockup,
.app-switcher-button,
.user-chip,
.nav-link,
.command-bar,
.directory-title-row,
.row-actions,
.pagination-controls,
.preview-actions {
display: flex;
align-items: center;
}
.brand-area {
gap: 0.85rem;
}
.brand-lockup {
gap: 0.5rem;
font-weight: 500;
}
.brand-mark {
width: 22px;
height: 22px;
display: inline-grid;
place-items: center;
border: 1px solid color-mix(in srgb, var(--oc-role-on-chrome) 72%, transparent);
border-radius: 5px;
color: #d9b8ff;
font-size: 0.78rem;
}
.app-switcher-button,
.user-chip {
justify-content: center;
padding: 0;
color: var(--oc-role-on-chrome);
background: transparent;
border: 0;
}
.app-switcher-button {
width: 26px;
height: 26px;
display: grid;
grid-template-columns: repeat(3, 4px);
grid-template-rows: repeat(3, 4px);
gap: 4px;
}
.app-switcher-button span {
width: 4px;
height: 4px;
background: currentColor;
border-radius: 1px;
}
.user-chip {
width: 34px;
height: 34px;
border: 1px solid color-mix(in srgb, var(--oc-role-on-chrome) 38%, transparent);
border-radius: 50%;
}
.topbar-search {
width: 100%;
}
.topbar-search input {
width: 100%;
min-height: 34px;
padding: 0.35rem 0.85rem;
border: 1px solid color-mix(in srgb, var(--oc-role-outline) 24%, transparent);
border-radius: 999px;
color: var(--oc-role-on-surface);
background: var(--oc-role-surface);
}
.iron-workspace {
min-height: 0;
display: grid;
grid-template-columns: 238px minmax(0, 1fr);
background: var(--oc-role-surface);
}
.sidebar {
display: grid;
align-content: start;
gap: 0.5rem;
padding: 1.05rem 1rem;
background: var(--oc-role-surface-container);
border-right: 1px solid var(--oc-role-outline-variant);
}
.sidebar-kicker,
.eyebrow,
.metric-label {
color: var(--oc-role-on-surface-variant);
font-size: 0.78rem;
}
.sidebar-title {
margin: 0 0 0.8rem;
font-size: 1.08rem;
font-weight: 400;
}
.main-nav {
display: grid;
gap: 0.2rem;
}
.nav-link {
gap: 0.65rem;
min-height: 38px;
padding: 0.48rem 0.75rem;
color: color-mix(in srgb, var(--oc-role-on-surface) 78%, transparent);
text-decoration: none;
border-radius: 5px;
cursor: pointer;
}
.nav-link.active {
background: var(--oc-role-surface);
}
.nav-icon {
width: 18px;
height: 18px;
display: inline-block;
color: var(--oc-role-chrome);
}
.sidebar-footer {
margin-top: 1.6rem;
color: var(--oc-role-on-surface-variant);
font-size: 0.78rem;
}
.workspace-content {
min-width: 0;
padding: 0.5rem;
}
.page-frame,
.files-page-layout,
.login-page {
min-height: calc(100vh - 68px);
border: 1px solid var(--oc-role-outline-variant);
border-radius: 10px;
background: var(--oc-role-surface);
}
.files-page-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
}
.files-content-shell {
min-width: 0;
padding: 1.25rem 1rem;
}
.detail-panel {
min-width: 0;
padding: 1.25rem 1rem;
border-left: 1px solid var(--oc-role-outline-variant);
}
.panel-toolbar,
.page-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.7rem;
}
.files-header,
.section-heading {
display: grid;
gap: 0.22rem;
}
.directory-title-row {
min-height: 34px;
gap: 0.55rem;
}
.directory-title-row h2,
.section-heading h2,
.section-heading h3 {
margin: 0;
font-size: 1.1rem;
font-weight: 500;
}
.directory-back-button {
width: 32px;
height: 32px;
border: 1px solid color-mix(in srgb, var(--oc-role-outline) 24%, transparent);
border-radius: 5px;
background: var(--oc-role-surface);
color: var(--oc-role-on-surface);
font-size: 1.5rem;
}
.inline-metrics {
display: flex;
flex-wrap: wrap;
gap: 0.52rem;
color: var(--oc-role-on-surface-variant);
font-size: 0.84rem;
}
.inline-metrics span + span::before {
content: "/";
margin-right: 0.52rem;
color: color-mix(in srgb, var(--oc-role-outline) 24%, transparent);
}
.command-bar {
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.42rem;
}
.compact-sort-select,
.page-size-select,
.dialog-input,
.dialog-select,
.dialog-textarea,
.login-form input {
min-height: 34px;
padding: 0.38rem 0.55rem;
border: 1px solid color-mix(in srgb, var(--oc-role-outline) 24%, transparent);
border-radius: 5px;
color: var(--oc-role-on-surface);
background: var(--oc-role-surface);
}
.compact-sort-select {
width: 142px;
}
.list-surface {
overflow: visible;
border: 1px solid color-mix(in srgb, var(--oc-role-outline) 24%, transparent);
border-radius: 5px;
background: var(--oc-role-surface);
}
.list-header,
.drive-item {
display: grid;
grid-template-columns: minmax(0, 1fr) 124px 76px 64px 58px;
gap: 0.7rem;
align-items: center;
}
.jobs-list-header,
.jobs-row {
grid-template-columns: minmax(0, 1fr) 92px 72px 70px;
}
.recycle-list-header,
.recycle-row {
grid-template-columns: minmax(0, 1fr) 132px 82px 86px;
}
.storage-list-header,
.storage-row {
grid-template-columns: minmax(0, 1fr) 92px 92px 132px 190px;
}
.list-header {
min-height: 38px;
padding: 0.5rem 0.85rem;
color: color-mix(in srgb, var(--oc-role-on-surface-variant) 78%, transparent);
font-size: 0.82rem;
border-bottom: 1px solid color-mix(in srgb, var(--oc-role-outline-variant) 46%, transparent);
}
.drive-list,
.drive-grid,
.table-list {
display: grid;
}
.drive-item {
position: relative;
min-height: 50px;
width: 100%;
padding: 0.48rem 0.85rem;
border: 0;
border-bottom: 1px solid color-mix(in srgb, var(--oc-role-outline-variant) 46%, transparent);
text-align: left;
color: var(--oc-role-on-surface);
background: var(--oc-role-surface);
cursor: pointer;
}
.drive-item:hover {
background: var(--oc-role-surface-container);
}
.drive-item.active {
background: color-mix(in srgb, var(--oc-role-primary-container) 46%, var(--oc-role-surface));
}
.drive-grid {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.grid-surface .list-header {
display: none;
}
.drive-grid .drive-item {
min-height: 142px;
grid-template-columns: minmax(0, 1fr);
align-content: space-between;
}
.drive-grid .drive-item > span:nth-of-type(1) {
display: none;
}
.drive-grid .row-actions {
position: absolute;
top: 0.58rem;
right: 0.58rem;
}
.drive-item-main {
min-width: 0;
display: flex;
align-items: center;
gap: 0.82rem;
}
.resource-name-stack {
min-width: 0;
}
.resource-name-stack strong {
display: block;
overflow: hidden;
color: color-mix(in srgb, var(--oc-role-on-surface) 78%, transparent);
font-size: 0.98rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}
.resource-icon {
width: 30px;
height: 30px;
flex: 0 0 auto;
display: inline-grid;
place-items: center;
border-radius: 3px;
background: #171c1f;
color: #fff;
font-size: 0.5rem;
font-weight: 600;
}
.resource-icon-folder {
width: 32px;
height: 24px;
margin-top: 3px;
background: #4d7eaf;
}
.resource-icon-image,
.resource-icon-presentation {
background: #ee6b3b;
}
.resource-icon-document {
background: #3b44a6;
}
.resource-icon-pdf {
background: #ec0d47;
}
.resource-icon-archive {
background: #fbbe54;
color: #191c1d;
}
.row-actions {
justify-content: flex-end;
min-width: 58px;
}
.row-action-button {
min-height: 30px;
padding: 0.24rem 0.48rem;
}
.menu-popover {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 80;
min-width: 150px;
padding: 0.25rem;
display: grid;
gap: 0.2rem;
border: 1px solid var(--oc-role-outline-variant);
border-radius: 5px;
background: var(--oc-role-surface);
box-shadow: 0 8px 18px rgba(25, 28, 29, 0.08);
}
.menu-item {
justify-content: flex-start;
width: 100%;
}
.menu-item-danger {
color: var(--oc-role-error);
}
.replica-ratio {
width: fit-content;
justify-self: end;
min-width: 44px;
min-height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.12rem 0.42rem;
border: 1px solid color-mix(in srgb, var(--oc-role-outline-variant) 46%, transparent);
border-radius: 5px;
background: var(--oc-role-surface-container);
color: var(--oc-role-on-surface-variant);
font-size: 0.78rem;
}
.replica-ratio-ready {
color: color-mix(in srgb, var(--oc-role-primary) 76%, var(--oc-role-on-surface));
background: color-mix(in srgb, var(--oc-role-primary-container) 56%, var(--oc-role-surface));
}
.pagination-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding-top: 0.8rem;
}
.pagination-controls {
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
}
.pagination-page-list {
display: flex;
gap: 0.42rem;
}
.pagination-page-button,
.pagination-ellipsis {
width: 34px;
height: 34px;
display: inline-grid;
place-items: center;
border: 1px solid color-mix(in srgb, var(--oc-role-outline) 24%, transparent);
border-radius: 5px;
background: var(--oc-role-surface);
color: var(--oc-role-on-surface);
}
.pagination-page-button.active {
color: var(--oc-role-primary);
border-color: color-mix(in srgb, var(--oc-role-primary) 74%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--oc-role-primary) 50%, transparent);
}
.page-size-select {
min-width: 118px;
}
.detail-empty,
.preview-fallback,
.inspector-card,
.dropzone {
display: grid;
gap: 0.7rem;
padding: 0.78rem;
border: 1px dashed color-mix(in srgb, var(--oc-role-outline-variant) 70%, transparent);
border-radius: 5px;
background: color-mix(in srgb, var(--oc-role-surface-container) 50%, var(--oc-role-surface));
}
.detail-empty h3,
.preview-fallback h3 {
margin: 0;
font-size: 1.05rem;
font-weight: 500;
}
.preview-frame {
width: 100%;
min-height: 210px;
max-height: 360px;
border: 1px solid var(--oc-role-outline-variant);
border-radius: 5px;
object-fit: contain;
background: var(--oc-role-surface-container);
}
.text-preview-frame {
overflow: auto;
min-height: 180px;
padding: 0.75rem;
white-space: pre-wrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.78rem;
}
.inspector-storage-summary {
margin-top: 0.8rem;
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.62rem 0.68rem;
border: 1px solid color-mix(in srgb, var(--oc-role-outline-variant) 46%, transparent);
border-radius: 5px;
background: color-mix(in srgb, var(--oc-role-surface-container) 48%, var(--oc-role-surface));
}
.inspector-info-button {
width: 100%;
margin-top: 0.85rem;
}
.detail-drawer-backdrop,
.dialog-backdrop {
position: fixed;
inset: 0;
z-index: 70;
display: flex;
justify-content: flex-end;
background: rgba(25, 28, 29, 0.38);
}
.dialog-backdrop {
align-items: center;
justify-content: center;
}
.detail-drawer,
.dialog-card {
background: var(--oc-role-surface);
box-shadow: 0 16px 32px rgba(25, 28, 29, 0.14);
}
.detail-drawer {
width: min(560px, 100%);
height: 100%;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
border-left: 1px solid var(--oc-role-outline-variant);
}
.dialog-card {
width: min(520px, calc(100% - 2rem));
display: grid;
gap: 1rem;
padding: 1.2rem;
border: 1px solid var(--oc-role-outline-variant);
border-radius: 5px;
}
.detail-drawer-header,
.dialog-header,
.dialog-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.detail-drawer-header {
padding: 1.05rem 1.15rem;
border-bottom: 1px solid var(--oc-role-outline-variant);
}
.detail-drawer-content {
overflow: auto;
display: grid;
align-content: start;
gap: 1rem;
padding: 1rem 1.15rem 1.25rem;
}
.dialog-form,
.stack-form,
.page-stack,
.login-form {
display: grid;
gap: 0.85rem;
}
.policy-form {
max-width: 440px;
}
.dialog-actions {
justify-content: flex-end;
}
.danger-confirm-button {
min-height: 34px;
padding: 0.38rem 0.8rem;
border: 1px solid #b42318;
border-radius: 5px;
color: #fff;
background: #d92d20;
cursor: pointer;
}
.danger-confirm-button:hover {
background: #b42318;
}
.dialog-form label,
.login-form label {
display: grid;
gap: 0.35rem;
color: var(--oc-role-on-surface-variant);
font-size: 0.84rem;
}
.dialog-textarea {
min-height: 130px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
.notice-text {
color: #008b63;
}
.error-text {
color: var(--oc-role-error);
}
.status-chip {
width: fit-content;
padding: 0.22rem 0.5rem;
border: 1px solid color-mix(in srgb, var(--oc-role-outline) 20%, transparent);
border-radius: 5px;
color: var(--oc-role-on-surface-variant);
background: var(--oc-role-surface-container);
font-size: 0.78rem;
}
.status-chip-success {
color: #008b63;
background: #ecfdf5;
}
.toast-item {
position: fixed;
left: 50%;
bottom: 1rem;
z-index: 100;
min-width: 220px;
max-width: 380px;
transform: translateX(-50%);
padding: 0.74rem 0.88rem;
border: 1px solid var(--oc-role-outline-variant);
border-radius: 5px;
background: var(--oc-role-surface);
box-shadow: 0 8px 20px rgba(25, 28, 29, 0.12);
}
.payload-preview {
overflow: auto;
max-height: 260px;
padding: 0.72rem;
border: 1px solid var(--oc-role-outline-variant);
border-radius: 5px;
background: var(--oc-role-surface-container);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.78rem;
white-space: pre-wrap;
}
.login-page {
width: min(420px, calc(100% - 2rem));
min-height: auto;
margin: 6rem auto;
padding: 1.4rem;
}
.empty-row {
padding: 1.2rem 0.85rem;
}
@media (max-width: 900px) {
.iron-topbar {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.iron-workspace,
.files-page-layout {
grid-template-columns: 1fr;
}
.sidebar {
display: none;
}
.detail-panel {
border-left: 0;
border-top: 1px solid var(--oc-role-outline-variant);
}
}

9
frontend/src/vite-env.d.ts

@ -0,0 +1,9 @@
/// <reference types="vite/client" />
/// <reference types="@opencloud-eu/design-system/types" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
export default component;
}

6
frontend/vite.config.ts

@ -1,11 +1,11 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react"; import vue from "@vitejs/plugin-vue";
import path from "node:path"; import path from "node:path";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [vue()],
root: path.resolve(__dirname), root: path.resolve(__dirname),
publicDir: false, publicDir: path.resolve(__dirname, "public"),
build: { build: {
outDir: path.resolve(__dirname, "../app/web/dist"), outDir: path.resolve(__dirname, "../app/web/dist"),
emptyOutDir: true, emptyOutDir: true,

1720
package-lock.json

File diff suppressed because it is too large

13
package.json

@ -9,15 +9,14 @@
"preview": "vite preview --config frontend/vite.config.ts" "preview": "vite preview --config frontend/vite.config.ts"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.76.1", "@opencloud-eu/design-system": "^6.2.0",
"react": "^19.1.0", "@vitejs/plugin-vue": "^6.0.6",
"react-dom": "^19.1.0", "pinia": "^3.0.4",
"react-router-dom": "^7.6.0" "tailwindcss": "^4.3.0",
"vue": "^3.5.34",
"vue3-gettext": "^4.0.0-beta.1"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.4.1",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^6.3.5" "vite": "^6.3.5"
} }

4
tests/test_web_routes.py

@ -17,8 +17,8 @@ async def test_web_assets_are_served(raw_api_client) -> None:
assert script_response.status_code == 200 assert script_response.status_code == 200
assert "javascript" in script_response.headers["content-type"] assert "javascript" in script_response.headers["content-type"]
assert "createRoot" in script_response.text assert '.mount("#root")' in script_response.text
assert style_response.status_code == 200 assert style_response.status_code == 200
assert "text/css" in style_response.headers["content-type"] assert "text/css" in style_response.headers["content-type"]
assert ".app-shell" in style_response.text assert ".iron-app" in style_response.text

Loading…
Cancel
Save