feat: ✨ initial commit
This commit is contained in:
@@ -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>
|
||||
Generated
+2473
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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(() => {});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user