feat: initial commit

This commit is contained in:
2026-05-20 23:26:11 +01:00
commit 5b18c7c975
28 changed files with 5141 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link
rel="icon"
type="image/png"
href="https://assets.jdbnet.co.uk/projects/s3.png"
/>
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0f1115" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<title>S3 Client</title>
</head>
<body class="app-shell bg-surface text-slate-200 antialiased">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2473
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "s3-client-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"lucide-vue-next": "^1.0.0",
"pinia": "^2.2.6",
"vue": "^3.5.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "~5.6.3",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+584
View File
@@ -0,0 +1,584 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import {
Boxes,
ChevronRight,
Folder,
LogOut,
Pencil,
Plus,
Search,
Trash2,
Cloud,
} from "lucide-vue-next";
import {
api,
type AccountRow,
type FolderRow,
} from "@/api";
import LoginForm from "@/components/LoginForm.vue";
import AccountTab from "@/components/AccountTab.vue";
interface TabItem {
id: string;
accountId: number;
label: string;
}
const loggedIn = ref(false);
const checking = ref(true);
const appVersion = ref("unknown");
const loadErr = ref("");
const allAccounts = ref<AccountRow[]>([]);
const allFolders = ref<FolderRow[]>([]);
const browseFolders = ref<FolderRow[]>([]);
const browseAccounts = ref<AccountRow[]>([]);
const breadcrumb = ref<{ id: number; label: string }[]>([]);
const searchActive = ref(false);
const currentFolderId = ref<number | null>(null);
const searchQuery = ref("");
const sidebarOpen = ref(true);
const tabs = ref<TabItem[]>([]);
const activeTabId = ref<string | null>(null);
let searchDebounceTimer = 0;
const showAccountForm = ref(false);
const showEditAccount = ref(false);
const showFolderForm = ref(false);
const showEditFolder = ref(false);
const newFolderLabel = ref("");
const showingFolderSearch = ref(false);
const accountForm = ref({
label: "",
endpoint_url: "",
region: "",
force_path_style: false,
access_key: "",
secret_key: "",
session_token: "",
});
const editAccountForm = ref({
id: 0,
label: "",
endpoint_url: "",
region: "",
force_path_style: false,
access_key: "",
secret_key: "",
session_token: "",
folder_id: null as number | null,
});
const editFolderForm = ref({
id: 0,
label: "",
parent_id: null as number | null,
});
function folderOptionLabel(id: number | null): string {
if (id == null) return "(root)";
const byId = new Map(allFolders.value.map((f) => [f.id, f]));
const parts: string[] = [];
let cur: number | null | undefined = id;
const guard = new Set<number>();
while (cur != null && !guard.has(cur)) {
guard.add(cur);
const f = byId.get(cur);
if (!f) break;
parts.unshift(f.label);
cur = f.parent_id;
}
return parts.join(" / ") || `#${id}`;
}
async function refreshBrowse() {
try {
const d = await api.browse(currentFolderId.value, searchQuery.value);
browseFolders.value = d.folders;
browseAccounts.value = d.accounts;
breadcrumb.value = d.breadcrumb;
searchActive.value = d.search_active;
} catch (e) {
loadErr.value = e instanceof Error ? e.message : "Browse failed";
}
}
function onSearchInput() {
window.clearTimeout(searchDebounceTimer);
searchDebounceTimer = window.setTimeout(() => {
void refreshBrowse();
}, 300);
}
function goToFolder(id: number | null) {
currentFolderId.value = id;
searchQuery.value = "";
showingFolderSearch.value = false;
void refreshBrowse();
}
function openSearchMode() {
showingFolderSearch.value = true;
}
function clearSearch() {
searchQuery.value = "";
showingFolderSearch.value = false;
void refreshBrowse();
}
async function refreshData() {
loadErr.value = "";
try {
allAccounts.value = await api.listAccounts();
allFolders.value = await api.listFoldersFlat();
await refreshBrowse();
} catch (e) {
loadErr.value = e instanceof Error ? e.message : "Failed to load data";
}
}
onMounted(async () => {
try {
const m = await api.me();
loggedIn.value = m.logged_in;
if (m.app_version) {
appVersion.value = m.app_version;
}
if (loggedIn.value) await refreshData();
} catch {
loggedIn.value = false;
} finally {
checking.value = false;
}
});
async function onLoggedIn() {
loggedIn.value = true;
await refreshData();
}
async function logout() {
await api.logout();
tabs.value = [];
activeTabId.value = null;
loggedIn.value = false;
}
function openTab(a: AccountRow) {
const id = crypto.randomUUID();
tabs.value.push({ id, accountId: a.id, label: a.label });
activeTabId.value = id;
if (window.matchMedia("(max-width: 1023px)").matches) {
sidebarOpen.value = false;
}
}
function closeTab(id: string) {
tabs.value = tabs.value.filter((t) => t.id !== id);
if (activeTabId.value === id) {
activeTabId.value = tabs.value.length ? tabs.value[tabs.value.length - 1].id : null;
}
}
async function submitAccount() {
const f = accountForm.value;
await api.createAccount({
label: f.label.trim(),
endpoint_url: f.endpoint_url.trim() || null,
region: f.region.trim() || null,
force_path_style: f.force_path_style,
access_key: f.access_key.trim(),
secret_key: f.secret_key.trim(),
session_token: f.session_token.trim(),
folder_id: currentFolderId.value,
});
showAccountForm.value = false;
accountForm.value = {
label: "",
endpoint_url: "",
region: "",
force_path_style: false,
access_key: "",
secret_key: "",
session_token: "",
};
await refreshData();
}
function openEditAccount(a: AccountRow) {
editAccountForm.value = {
id: a.id,
label: a.label,
endpoint_url: a.endpoint_url || "",
region: a.region || "",
force_path_style: Boolean(a.force_path_style),
access_key: "",
secret_key: "",
session_token: "",
folder_id: a.folder_id,
};
showEditAccount.value = true;
}
async function submitEditAccount() {
const f = editAccountForm.value;
const body: Record<string, unknown> = {
label: f.label.trim(),
endpoint_url: f.endpoint_url.trim() || null,
region: f.region.trim() || null,
force_path_style: f.force_path_style,
folder_id: f.folder_id,
};
if (f.access_key.trim()) body.access_key = f.access_key.trim();
if (f.secret_key.trim()) body.secret_key = f.secret_key.trim();
if (f.session_token.trim()) body.session_token = f.session_token.trim();
await api.updateAccount(f.id, body);
showEditAccount.value = false;
await refreshData();
}
async function deleteAccount(a: AccountRow) {
if (!confirm(`Delete ${a.label}?`)) return;
await api.deleteAccount(a.id);
tabs.value = tabs.value.filter((t) => t.accountId !== a.id);
if (activeTabId.value && !tabs.value.find((t) => t.id === activeTabId.value)) {
activeTabId.value = tabs.value.length ? tabs.value[tabs.value.length - 1].id : null;
}
await refreshData();
}
async function submitFolder() {
const label = newFolderLabel.value.trim();
if (!label) return;
await api.createFolder({ label, parent_id: currentFolderId.value });
newFolderLabel.value = "";
showFolderForm.value = false;
await refreshBrowse();
}
function openEditFolder(f: FolderRow) {
editFolderForm.value = {
id: f.id,
label: f.label,
parent_id: f.parent_id,
};
showEditFolder.value = true;
}
async function submitEditFolder() {
const f = editFolderForm.value;
await api.updateFolder(f.id, {
label: f.label.trim(),
parent_id: f.parent_id,
});
showEditFolder.value = false;
await refreshBrowse();
}
async function deleteFolder(f: FolderRow) {
if (!confirm(`Delete ${f.label}?`)) return;
await api.deleteFolder(f.id);
await refreshBrowse();
}
</script>
<template>
<div v-if="checking" class="flex min-h-screen items-center justify-center text-slate-400">
Loading...
</div>
<LoginForm v-else-if="!loggedIn" @logged-in="onLoggedIn" />
<div v-else class="min-h-screen">
<header class="border-b border-slate-800/80 bg-surface/80 backdrop-blur">
<div class="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<div class="flex items-center gap-3">
<div class="rounded-2xl bg-accent/20 p-2 text-accent">
<Boxes class="h-5 w-5" />
</div>
<div>
<h1 class="font-sans text-lg font-semibold tracking-tight text-white">S3 Client</h1>
<p class="text-xs text-slate-500">{{ appVersion }}</p>
</div>
</div>
<button type="button" class="button-secondary" @click="logout">
<LogOut class="h-4 w-4" />
Sign out
</button>
</div>
</header>
<div class="mx-auto flex max-w-7xl flex-1 gap-6 px-6 py-6">
<aside
class="panel w-80 shrink-0 flex-col"
:class="sidebarOpen ? 'flex' : 'hidden lg:flex'"
>
<div class="border-b border-slate-800/80 p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Workspace</p>
<h2 class="text-base font-semibold text-white">Accounts</h2>
</div>
<button
type="button"
class="button-secondary"
@click="showAccountForm = true"
>
<Plus class="h-4 w-4" />
New
</button>
</div>
<div class="mt-4 space-y-2">
<div class="relative">
<Search class="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-slate-500" />
<input
v-model="searchQuery"
type="search"
class="field pl-9"
@input="onSearchInput"
@focus="openSearchMode"
/>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="button-secondary flex-1"
@click="showFolderForm = true"
>
<Folder class="h-4 w-4" />
New folder
</button>
<button
v-if="searchQuery"
type="button"
class="button-secondary"
@click="clearSearch"
>
Clear
</button>
</div>
</div>
</div>
<div class="flex-1 overflow-auto p-4">
<p v-if="loadErr" class="mb-3 text-sm text-red-400">{{ loadErr }}</p>
<div v-if="breadcrumb.length" class="mb-4 flex flex-wrap items-center gap-2 text-xs text-slate-400">
<button type="button" class="hover:text-slate-200" @click="goToFolder(null)">Root</button>
<ChevronRight class="h-3 w-3" />
<button
v-for="b in breadcrumb"
:key="b.id"
type="button"
class="hover:text-slate-200"
@click="goToFolder(b.id)"
>
{{ b.label }}
</button>
</div>
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Folders</p>
<ul class="mt-2 space-y-1">
<li
v-for="f in browseFolders"
:key="f.id"
class="group flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-surface-overlay"
>
<button
type="button"
class="flex-1 truncate text-left text-sm text-slate-200"
@click="goToFolder(f.id)"
>
<span class="inline-flex items-center gap-2">
<Folder class="h-4 w-4 text-accent" />
{{ f.label }}
</span>
</button>
<button
type="button"
class="text-xs text-slate-500 opacity-0 transition group-hover:opacity-100"
@click="openEditFolder(f)"
>
<Pencil class="h-4 w-4" />
</button>
<button
type="button"
class="text-xs text-red-400/80 opacity-0 transition group-hover:opacity-100"
@click="deleteFolder(f)"
>
<Trash2 class="h-4 w-4" />
</button>
</li>
<li v-if="!browseFolders.length" class="text-xs text-slate-500">No folders</li>
</ul>
</div>
<div class="mt-6">
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">
{{ searchQuery ? "Search results" : "Accounts" }}
</p>
<ul class="mt-2 space-y-2">
<li
v-for="a in browseAccounts"
:key="a.id"
class="rounded-lg border border-slate-800 bg-surface-overlay p-3"
>
<button type="button" class="w-full text-left" @click="openTab(a)">
<div class="flex items-center justify-between">
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-white">{{ a.label }}</p>
<p class="truncate text-xs text-slate-500">{{ a.endpoint_url || "AWS S3" }}</p>
</div>
<Cloud class="h-4 w-4 text-accent" />
</div>
</button>
<div class="mt-3 flex items-center gap-2 text-xs text-slate-400">
<button type="button" class="hover:text-slate-200" @click="openEditAccount(a)">Edit</button>
<button type="button" class="hover:text-red-400" @click="deleteAccount(a)">Delete</button>
</div>
</li>
<li v-if="!browseAccounts.length" class="text-xs text-slate-500">No accounts</li>
</ul>
</div>
</div>
</aside>
<main class="min-h-[640px] flex-1">
<div v-if="!tabs.length" class="panel flex h-full items-center justify-center p-10">
<div class="max-w-md text-center">
<p class="text-lg font-semibold text-white">Open an account to start</p>
<p class="mt-2 text-sm text-slate-400">
Browse buckets, manage folders, and upload objects once an account is selected.
</p>
</div>
</div>
<div v-else class="flex h-full flex-col">
<div class="flex flex-wrap gap-2">
<button
v-for="t in tabs"
:key="t.id"
type="button"
class="rounded-lg border border-slate-800 px-4 py-2 text-sm"
:class="activeTabId === t.id ? 'bg-accent/20 text-white' : 'bg-surface-overlay text-slate-400'"
@click="activeTabId = t.id"
>
{{ t.label }}
<span class="ml-2 text-slate-500" @click.stop="closeTab(t.id)">x</span>
</button>
</div>
<div class="mt-4 flex-1">
<AccountTab
v-for="t in tabs"
:key="t.id"
:account-id="t.accountId"
:account-label="t.label"
:visible="activeTabId === t.id"
v-show="activeTabId === t.id"
/>
</div>
</div>
</main>
</div>
<div v-if="showAccountForm" class="fixed inset-0 z-40 flex items-center justify-center bg-black/50 p-6">
<div class="panel w-full max-w-xl p-6">
<h3 class="text-lg font-semibold text-white">New account</h3>
<form class="mt-4 space-y-4" @submit.prevent="submitAccount">
<input v-model="accountForm.label" class="field" placeholder="Label" required />
<div class="grid gap-3 md:grid-cols-2">
<input v-model="accountForm.endpoint_url" class="field" placeholder="Endpoint URL" />
<input v-model="accountForm.region" class="field" placeholder="Region" />
</div>
<label class="flex items-center gap-2 text-sm text-slate-300">
<input v-model="accountForm.force_path_style" type="checkbox" />
Force path style
</label>
<div class="grid gap-3 md:grid-cols-2">
<input v-model="accountForm.access_key" class="field" placeholder="Access key" required />
<input v-model="accountForm.secret_key" class="field" placeholder="Secret key" required />
</div>
<input v-model="accountForm.session_token" class="field" placeholder="Session token (optional)" />
<div class="flex justify-end gap-2">
<button type="button" class="button-secondary" @click="showAccountForm = false">Cancel</button>
<button type="submit" class="button-primary">Create</button>
</div>
</form>
</div>
</div>
<div v-if="showEditAccount" class="fixed inset-0 z-40 flex items-center justify-center bg-black/50 p-6">
<div class="panel w-full max-w-xl p-6">
<h3 class="text-lg font-semibold text-white">Edit account</h3>
<form class="mt-4 space-y-4" @submit.prevent="submitEditAccount">
<input v-model="editAccountForm.label" class="field" placeholder="Label" required />
<div class="grid gap-3 md:grid-cols-2">
<input v-model="editAccountForm.endpoint_url" class="field" placeholder="Endpoint URL" />
<input v-model="editAccountForm.region" class="field" placeholder="Region" />
</div>
<label class="flex items-center gap-2 text-sm text-slate-300">
<input v-model="editAccountForm.force_path_style" type="checkbox" />
Force path style
</label>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Folder</label>
<select v-model="editAccountForm.folder_id" class="field">
<option :value="null">(root)</option>
<option v-for="f in allFolders" :key="f.id" :value="f.id">
{{ folderOptionLabel(f.id) }}
</option>
</select>
</div>
<div class="grid gap-3 md:grid-cols-2">
<input v-model="editAccountForm.access_key" class="field" placeholder="New access key (optional)" />
<input v-model="editAccountForm.secret_key" class="field" placeholder="New secret key (optional)" />
</div>
<input v-model="editAccountForm.session_token" class="field" placeholder="New session token (optional)" />
<div class="flex justify-end gap-2">
<button type="button" class="button-secondary" @click="showEditAccount = false">Cancel</button>
<button type="submit" class="button-primary">Save</button>
</div>
</form>
</div>
</div>
<div v-if="showFolderForm" class="fixed inset-0 z-40 flex items-center justify-center bg-black/50 p-6">
<div class="panel w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-white">New folder</h3>
<form class="mt-4 space-y-4" @submit.prevent="submitFolder">
<input v-model="newFolderLabel" class="field" placeholder="Folder name" required />
<div class="flex justify-end gap-2">
<button type="button" class="button-secondary" @click="showFolderForm = false">Cancel</button>
<button type="submit" class="button-primary">Create</button>
</div>
</form>
</div>
</div>
<div v-if="showEditFolder" class="fixed inset-0 z-40 flex items-center justify-center bg-black/50 p-6">
<div class="panel w-full max-w-md p-6">
<h3 class="text-lg font-semibold text-white">Edit folder</h3>
<form class="mt-4 space-y-4" @submit.prevent="submitEditFolder">
<input v-model="editFolderForm.label" class="field" placeholder="Folder name" required />
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Parent</label>
<select v-model="editFolderForm.parent_id" class="field">
<option :value="null">(root)</option>
<option v-for="f in allFolders" :key="f.id" :value="f.id">
{{ folderOptionLabel(f.id) }}
</option>
</select>
</div>
<div class="flex justify-end gap-2">
<button type="button" class="button-secondary" @click="showEditFolder = false">Cancel</button>
<button type="submit" class="button-primary">Save</button>
</div>
</form>
</div>
</div>
</div>
</template>
+264
View File
@@ -0,0 +1,264 @@
const jsonHeaders = { "Content-Type": "application/json" };
async function handle<T>(res: Response): Promise<T> {
if (res.status === 401) {
throw new Error("unauthorized");
}
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error((data as { error?: string }).error || res.statusText);
}
return data as T;
}
function browseParams(folderId: number | null, q: string): string {
const p = new URLSearchParams();
if (folderId != null) p.set("folder_id", String(folderId));
else p.set("folder_id", "root");
const t = q.trim();
if (t) p.set("q", t);
return p.toString();
}
export const api = {
async me(): Promise<{ logged_in: boolean; app_version?: string }> {
const res = await fetch("/api/me", { credentials: "include" });
return handle(res);
},
async login(username: string, password: string): Promise<void> {
const res = await fetch("/api/login", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ username, password }),
});
await handle(res);
},
async logout(): Promise<void> {
const res = await fetch("/api/logout", {
method: "POST",
credentials: "include",
});
await handle(res);
},
async browse(
folderId: number | null,
q: string,
): Promise<{
breadcrumb: { id: number; label: string }[];
folders: FolderRow[];
accounts: AccountRow[];
search_active: boolean;
}> {
const res = await fetch(`/api/browse?${browseParams(folderId, q)}`, {
credentials: "include",
});
return handle(res);
},
async listFoldersFlat(): Promise<FolderRow[]> {
const res = await fetch("/api/folders", { credentials: "include" });
const d = await handle<{ items: FolderRow[] }>(res);
return d.items;
},
async createFolder(body: {
label: string;
parent_id?: number | null;
}): Promise<{ id: number }> {
const res = await fetch("/api/folders", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
return handle(res);
},
async deleteFolder(id: number): Promise<void> {
const res = await fetch(`/api/folders/${id}`, {
method: "DELETE",
credentials: "include",
});
await handle(res);
},
async updateFolder(
id: number,
body: {
label?: string;
parent_id?: number | null;
},
): Promise<void> {
const res = await fetch(`/api/folders/${id}`, {
method: "PATCH",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
await handle(res);
},
async listAccounts(): Promise<AccountRow[]> {
const res = await fetch("/api/accounts", { credentials: "include" });
const d = await handle<{ items: AccountRow[] }>(res);
return d.items;
},
async createAccount(body: Record<string, unknown>): Promise<{ id: number }> {
const res = await fetch("/api/accounts", {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
return handle(res);
},
async updateAccount(id: number, body: Record<string, unknown>): Promise<void> {
const res = await fetch(`/api/accounts/${id}`, {
method: "PATCH",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
await handle(res);
},
async deleteAccount(id: number): Promise<void> {
const res = await fetch(`/api/accounts/${id}`, {
method: "DELETE",
credentials: "include",
});
await handle(res);
},
async listBuckets(accountId: number): Promise<BucketRow[]> {
const res = await fetch(`/api/s3/${accountId}/buckets`, {
credentials: "include",
});
const d = await handle<{ items: BucketRow[] }>(res);
return d.items;
},
async listObjects(
accountId: number,
bucket: string,
path: string,
q = "",
): Promise<{ prefix: string; folders: S3FolderEntry[]; files: S3FileEntry[] }>
{
const body: Record<string, string> = { bucket, path };
if (q.trim()) body.q = q.trim();
const res = await fetch(`/api/s3/${accountId}/objects/list`, {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify(body),
});
return handle(res);
},
async mkdir(
accountId: number,
bucket: string,
path: string,
name: string,
): Promise<void> {
const res = await fetch(`/api/s3/${accountId}/objects/mkdir`, {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ bucket, path, name }),
});
await handle(res);
},
async deleteObject(
accountId: number,
bucket: string,
key: string,
): Promise<void> {
const res = await fetch(`/api/s3/${accountId}/objects/delete`, {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ bucket, key }),
});
await handle(res);
},
async renameObject(
accountId: number,
bucket: string,
oldKey: string,
newKey: string,
): Promise<void> {
const res = await fetch(`/api/s3/${accountId}/objects/rename`, {
method: "POST",
credentials: "include",
headers: jsonHeaders,
body: JSON.stringify({ bucket, old_key: oldKey, new_key: newKey }),
});
await handle(res);
},
async uploadObject(
accountId: number,
bucket: string,
path: string,
file: File,
): Promise<void> {
const fd = new FormData();
fd.set("bucket", bucket);
fd.set("path", path);
fd.set("file", file);
const res = await fetch(`/api/s3/${accountId}/objects/upload`, {
method: "POST",
credentials: "include",
body: fd,
});
await handle(res);
},
downloadUrl(accountId: number, bucket: string, key: string): string {
const q = new URLSearchParams({ bucket, key });
return `/api/s3/${accountId}/objects/download?${q}`;
},
};
export interface FolderRow {
id: number;
label: string;
parent_id: number | null;
}
export interface AccountRow {
id: number;
folder_id: number | null;
label: string;
endpoint_url: string | null;
region: string | null;
force_path_style: number | boolean;
folder_label?: string | null;
}
export interface BucketRow {
name: string;
created_at?: string | null;
}
export interface S3FolderEntry {
prefix: string;
name: string;
}
export interface S3FileEntry {
key: string;
name: string;
size: number;
last_modified?: string | null;
}
+409
View File
@@ -0,0 +1,409 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import {
Download,
Folder,
File,
RefreshCcw,
Trash2,
Pencil,
Upload,
Search,
Plus,
} from "lucide-vue-next";
import { api, type BucketRow, type S3FileEntry, type S3FolderEntry } from "@/api";
const props = defineProps<{
accountId: number;
accountLabel: string;
visible: boolean;
}>();
const buckets = ref<BucketRow[]>([]);
const bucket = ref("");
const path = ref("/");
const folders = ref<S3FolderEntry[]>([]);
const files = ref<S3FileEntry[]>([]);
const err = ref("");
const searchQuery = ref("");
const bucketsLoading = ref(false);
const objectsLoading = ref(false);
const renameTarget = ref<{
kind: "folder" | "file";
name: string;
key: string;
} | null>(null);
const renameValue = ref("");
let searchTimer: number | undefined;
const currentPrefix = computed(() => {
if (!path.value || path.value === "/") return "";
return path.value.replace(/^\//, "");
});
function joinPath(base: string, name: string): string {
const cleaned = base === "/" ? "" : base.replace(/\/$/, "");
const parts = [cleaned, name].filter(Boolean);
return `/${parts.join("/")}/`;
}
function parentPath(): string {
if (path.value === "/") return "/";
const parts = path.value.replace(/^\//, "").replace(/\/$/, "").split("/");
parts.pop();
return parts.length ? `/${parts.join("/")}/` : "/";
}
function fmtDate(ts?: string | null): string {
if (!ts) return "-";
const d = new Date(ts);
return Number.isNaN(d.getTime()) ? ts : d.toLocaleString();
}
function fmtSize(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
async function loadBuckets() {
err.value = "";
bucketsLoading.value = true;
try {
buckets.value = await api.listBuckets(props.accountId);
if (!bucket.value && buckets.value.length) {
bucket.value = buckets.value[0].name;
await loadObjects();
}
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to load buckets";
} finally {
bucketsLoading.value = false;
}
}
async function loadObjects() {
if (!bucket.value) return;
err.value = "";
objectsLoading.value = true;
try {
const res = await api.listObjects(props.accountId, bucket.value, path.value, searchQuery.value);
folders.value = res.folders;
files.value = res.files;
} catch (e) {
err.value = e instanceof Error ? e.message : "Failed to load objects";
} finally {
objectsLoading.value = false;
}
}
function selectBucket(name: string) {
bucket.value = name;
path.value = "/";
searchQuery.value = "";
loadObjects();
}
function enterFolder(folder: S3FolderEntry) {
const name = folder.name || folder.prefix.replace(/\/$/, "").split("/").pop() || "";
if (!name) return;
path.value = joinPath(path.value, name);
loadObjects();
}
function goUp() {
path.value = parentPath();
loadObjects();
}
async function onUpload(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = "";
if (!file || !bucket.value) return;
try {
await api.uploadObject(props.accountId, bucket.value, path.value, file);
await loadObjects();
} catch (e) {
err.value = e instanceof Error ? e.message : "Upload failed";
}
}
async function downloadFile(entry: S3FileEntry) {
if (!bucket.value) return;
err.value = "";
try {
const res = await fetch(api.downloadUrl(props.accountId, bucket.value, entry.key), {
credentials: "include",
});
if (!res.ok) throw new Error(await res.text());
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = entry.name;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
err.value = e instanceof Error ? e.message : "Download failed";
}
}
async function removeEntry(entry: S3FileEntry | S3FolderEntry, kind: "file" | "folder") {
if (!bucket.value) return;
if (!confirm(`Delete ${"name" in entry ? entry.name : "item"}?`)) return;
const key = kind === "folder" ? (entry as S3FolderEntry).prefix : (entry as S3FileEntry).key;
try {
await api.deleteObject(props.accountId, bucket.value, key);
await loadObjects();
} catch (e) {
err.value = e instanceof Error ? e.message : "Delete failed";
}
}
function startRename(entry: S3FileEntry | S3FolderEntry, kind: "file" | "folder") {
renameTarget.value = {
kind,
name: "name" in entry ? entry.name : "",
key: kind === "folder" ? (entry as S3FolderEntry).prefix : (entry as S3FileEntry).key,
};
renameValue.value = renameTarget.value.name;
}
async function confirmRename() {
if (!renameTarget.value || !bucket.value) return;
const name = renameValue.value.trim();
if (!name) return;
let newKey = name;
if (renameTarget.value.kind === "folder") {
const base = currentPrefix.value;
newKey = base ? `${base}${name}/` : `${name}/`;
} else {
const base = currentPrefix.value;
newKey = base ? `${base}${name}` : name;
}
try {
await api.renameObject(props.accountId, bucket.value, renameTarget.value.key, newKey);
renameTarget.value = null;
await loadObjects();
} catch (e) {
err.value = e instanceof Error ? e.message : "Rename failed";
}
}
async function mkdir() {
if (!bucket.value) return;
const name = prompt("Folder name");
if (!name?.trim()) return;
try {
await api.mkdir(props.accountId, bucket.value, path.value, name.trim());
await loadObjects();
} catch (e) {
err.value = e instanceof Error ? e.message : "Create folder failed";
}
}
function queueSearch() {
window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
void loadObjects();
}, 250);
}
watch(
() => props.accountId,
() => {
bucket.value = "";
path.value = "/";
searchQuery.value = "";
folders.value = [];
files.value = [];
void loadBuckets();
},
{ immediate: true },
);
watch(
() => props.visible,
(visible) => {
if (visible && !buckets.value.length) {
void loadBuckets();
}
},
);
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-4">
<div class="grid gap-4 lg:grid-cols-[280px_minmax(0,1fr)]">
<div class="panel p-4">
<div class="flex items-center justify-between gap-3">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Buckets</p>
<h2 class="font-sans text-base font-semibold text-white">{{ accountLabel }}</h2>
</div>
<button type="button" class="button-secondary" @click="loadBuckets">
<RefreshCcw class="h-4 w-4" />
Refresh
</button>
</div>
<div class="mt-4">
<p v-if="bucketsLoading" class="text-xs text-slate-500">Loading buckets...</p>
<ul v-else class="space-y-2">
<li v-for="b in buckets" :key="b.name">
<button
type="button"
class="w-full rounded-lg border border-slate-800 bg-surface-overlay px-3 py-2 text-left text-sm transition hover:border-slate-600"
:class="bucket === b.name ? 'border-accent bg-accent/15 text-white' : 'text-slate-300'"
@click="selectBucket(b.name)"
>
<div class="flex items-center justify-between gap-2">
<span class="truncate font-medium">{{ b.name }}</span>
<span class="text-[11px] text-slate-500">{{ fmtDate(b.created_at) }}</span>
</div>
</button>
</li>
</ul>
</div>
</div>
<div class="panel flex min-h-[420px] flex-col">
<div class="border-b border-slate-800 px-4 py-3">
<div class="flex flex-wrap items-center gap-3">
<div class="flex-1">
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Bucket path</p>
<p class="font-mono text-xs text-slate-300">
{{ bucket ? `s3://${bucket}${path}` : "Select a bucket" }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<button type="button" class="button-secondary" @click="goUp">
<Folder class="h-4 w-4" />
Up
</button>
<button type="button" class="button-secondary" @click="loadObjects">
<RefreshCcw class="h-4 w-4" />
Refresh
</button>
<button type="button" class="button-secondary" @click="mkdir">
<Plus class="h-4 w-4" />
Folder
</button>
<label class="button-primary cursor-pointer">
<Upload class="h-4 w-4" />
Upload
<input type="file" class="hidden" @change="onUpload" />
</label>
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<div class="relative flex-1">
<Search class="pointer-events-none absolute left-3 top-2.5 h-4 w-4 text-slate-500" />
<input
v-model="searchQuery"
class="field pl-9"
@input="queueSearch"
/>
</div>
<button type="button" class="button-secondary" @click="searchQuery = ''; loadObjects()">
Clear
</button>
</div>
<p v-if="err" class="mt-3 text-sm text-red-400">{{ err }}</p>
</div>
<div class="min-h-0 flex-1 overflow-auto p-4">
<p v-if="objectsLoading" class="text-sm text-slate-500">Loading objects...</p>
<div v-else class="space-y-6">
<div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Folders</p>
<ul class="mt-2 space-y-1">
<li
v-for="f in folders"
:key="f.prefix"
class="group flex items-center gap-2 rounded-lg px-3 py-2 hover:bg-surface-overlay"
>
<button type="button" class="flex-1 truncate text-left text-sm text-slate-200" @click="enterFolder(f)">
<span class="inline-flex items-center gap-2">
<Folder class="h-4 w-4 text-accent" />
{{ f.name }}
</span>
</button>
<button
type="button"
class="text-xs text-slate-500 opacity-0 transition group-hover:opacity-100"
@click="startRename(f, 'folder')"
>
<Pencil class="h-4 w-4" />
</button>
<button
type="button"
class="text-xs text-red-400/80 opacity-0 transition group-hover:opacity-100"
@click="removeEntry(f, 'folder')"
>
<Trash2 class="h-4 w-4" />
</button>
</li>
<li v-if="!folders.length" class="text-xs text-slate-500">No folders</li>
</ul>
</div>
<div>
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">Files</p>
<ul class="mt-2 space-y-1">
<li
v-for="f in files"
:key="f.key"
class="group flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-surface-overlay"
>
<div class="flex-1 truncate text-sm text-slate-200">
<span class="inline-flex items-center gap-2">
<File class="h-4 w-4" />
{{ f.name }}
</span>
</div>
<span class="text-xs text-slate-500">{{ fmtSize(f.size) }}</span>
<button
type="button"
class="text-xs text-slate-400 opacity-0 transition group-hover:opacity-100"
@click="downloadFile(f)"
>
<Download class="h-4 w-4" />
</button>
<button
type="button"
class="text-xs text-slate-400 opacity-0 transition group-hover:opacity-100"
@click="startRename(f, 'file')"
>
<Pencil class="h-4 w-4" />
</button>
<button
type="button"
class="text-xs text-red-400/80 opacity-0 transition group-hover:opacity-100"
@click="removeEntry(f, 'file')"
>
<Trash2 class="h-4 w-4" />
</button>
</li>
<li v-if="!files.length" class="text-xs text-slate-500">No files</li>
</ul>
</div>
</div>
</div>
<div v-if="renameTarget" class="border-t border-slate-800 px-4 py-3">
<div class="flex flex-wrap items-center gap-2">
<input v-model="renameValue" class="field flex-1" @keyup.enter="confirmRename" />
<button type="button" class="button-primary" @click="confirmRename">Save</button>
<button type="button" class="button-secondary" @click="renameTarget = null">Cancel</button>
</div>
</div>
</div>
</div>
</div>
</template>
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { ref } from "vue";
import { api } from "@/api";
const emit = defineEmits<{ loggedIn: [] }>();
const username = ref("");
const password = ref("");
const err = ref("");
const busy = ref(false);
async function submit() {
err.value = "";
busy.value = true;
try {
await api.login(username.value.trim(), password.value);
emit("loggedIn");
} catch (e) {
err.value = e instanceof Error ? e.message : "Login failed";
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="flex min-h-screen items-center justify-center px-6 py-10">
<div class="panel w-full max-w-md p-8">
<h1 class="font-sans text-2xl font-semibold tracking-tight text-white">
S3 Client
</h1>
<p class="mt-2 text-sm text-slate-400">
Sign in to manage accounts, buckets, and objects.
</p>
<form class="mt-6 space-y-4" @submit.prevent="submit">
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">
Username
</label>
<input
v-model="username"
type="text"
autocomplete="username"
class="field"
required
/>
</div>
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">
Password
</label>
<input
v-model="password"
type="password"
autocomplete="current-password"
class="field"
required
/>
</div>
<p v-if="err" class="text-sm text-red-400">{{ err }}</p>
<button type="submit" :disabled="busy" class="button-primary w-full">
{{ busy ? "Signing in..." : "Sign in" }}
</button>
</form>
</div>
</div>
</template>
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import "./style.css";
const app = createApp(App);
app.use(createPinia());
app.mount("#app");
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js").catch(() => {});
});
}
+43
View File
@@ -0,0 +1,43 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html.dark,
html {
color-scheme: dark;
}
body {
background: #0f1419;
}
.app-shell {
background:
radial-gradient(circle at top left, rgba(61, 154, 237, 0.16), transparent 38%),
radial-gradient(circle at 80% 10%, rgba(61, 154, 237, 0.08), transparent 24%),
#0f1419;
}
.panel {
@apply rounded-xl border border-slate-800 bg-surface-raised shadow-float;
}
.field {
@apply w-full rounded-lg border border-slate-700 bg-surface-overlay px-3 py-2 text-sm text-white outline-none ring-accent focus:border-accent focus:ring-1;
}
.button-primary {
@apply inline-flex items-center justify-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-slate-950 transition hover:bg-sky-400 disabled:opacity-50;
}
.button-secondary {
@apply inline-flex items-center justify-center gap-2 rounded-lg border border-slate-700 bg-surface-overlay px-4 py-2 text-sm font-medium text-slate-200 transition hover:border-slate-500;
}
.chip {
@apply inline-flex items-center gap-2 rounded-full border border-slate-700 bg-surface-overlay px-3 py-1 text-xs text-slate-300;
}
.tab-shell {
@apply rounded-lg border border-slate-800 bg-surface-raised shadow-float;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+27
View File
@@ -0,0 +1,27 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
surface: {
DEFAULT: "#0f1419",
raised: "#151c24",
overlay: "#1a232e",
},
accent: {
DEFAULT: "#3d9aed",
muted: "#2a6fa3",
},
},
fontFamily: {
sans: ["IBM Plex Sans", "system-ui", "sans-serif"],
mono: ["IBM Plex Mono", "ui-monospace", "monospace"],
},
boxShadow: {
float: "0 12px 30px rgba(0, 0, 0, 0.35)",
},
},
},
plugins: [],
};
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"paths": { "@/*": ["./src/*"] }
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}
+52
View File
@@ -0,0 +1,52 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig, type Plugin } from "vite";
import vue from "@vitejs/plugin-vue";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const staticRoot = path.resolve(__dirname, "../static");
function servePwaFromStatic(): Plugin {
return {
name: "serve-pwa-from-static",
configureServer(server) {
server.middlewares.use((req, res, next) => {
const url = req.url?.split("?")[0] ?? "";
if (url !== "/manifest.webmanifest" && url !== "/sw.js") {
next();
return;
}
const name = url.slice(1);
const filePath = path.join(staticRoot, name);
if (!fs.existsSync(filePath)) {
next();
return;
}
const body = fs.readFileSync(filePath);
const type = name.endsWith(".webmanifest")
? "application/manifest+json"
: "application/javascript";
res.setHeader("Content-Type", type);
res.end(body);
});
},
};
}
export default defineConfig({
plugins: [vue(), servePwaFromStatic()],
resolve: {
alias: { "@": path.resolve(__dirname, "src") },
},
build: {
outDir: "../static/dist",
emptyOutDir: true,
},
server: {
port: 5173,
proxy: {
"/api": "http://127.0.0.1:5000",
},
},
});