const $ = (id) => document.getElementById(id); let currentPrefix = ""; async function api(path, options = {}) { const response = await fetch(path, { credentials: "same-origin", ...options, headers: { "content-type": "application/json", ...(options.headers || {}), }, }); let body = {}; try { body = await response.json(); } catch {} if (!response.ok) { if (response.status === 401 && path !== "/api/login") showLogin(); throw new Error(body.error || `HTTP ${response.status}`); } return body; } function showLogin() { $("appView").classList.add("hidden"); $("loginView").classList.remove("hidden"); } function showApp() { $("loginView").classList.add("hidden"); $("appView").classList.remove("hidden"); } function notice(message, error = false) { const box = $("notice"); box.textContent = message; box.classList.toggle("error", error); box.classList.remove("hidden"); setTimeout(() => box.classList.add("hidden"), 4500); } function formatSize(bytes) { if (bytes < 1024) return `${bytes} B`; const units = ["KB", "MB", "GB", "TB"]; let value = bytes; let unit = -1; do { value /= 1024; unit += 1; } while (value >= 1024 && unit < units.length - 1); return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unit]}`; } function formatDate(value) { return new Intl.DateTimeFormat("ja-JP", { dateStyle: "medium", timeStyle: "short", }).format(new Date(value)); } function renderBreadcrumbs() { const nav = $("breadcrumbs"); nav.replaceChildren(); const root = document.createElement("button"); root.textContent = "ルート"; root.onclick = () => openPrefix(""); nav.append(root); let path = ""; for (const part of currentPrefix.split("/").filter(Boolean)) { path += `${part}/`; const target = path; nav.append(Object.assign(document.createElement("span"), { textContent: "/" })); const button = document.createElement("button"); button.textContent = part; button.onclick = () => openPrefix(target); nav.append(button); } } function rowForFolder(folder) { const tr = document.createElement("tr"); const name = document.createElement("td"); const button = document.createElement("button"); button.className = "name-button"; button.textContent = `📁 ${folder.name}`; button.onclick = () => openPrefix(folder.key); name.append(button); tr.append(name, document.createElement("td"), document.createElement("td"), document.createElement("td")); return tr; } function rowForFile(file) { const tr = document.createElement("tr"); const nameCell = document.createElement("td"); const name = document.createElement("button"); name.className = "name-button"; name.textContent = file.name; name.onclick = () => download(file.key); nameCell.append(name); const size = Object.assign(document.createElement("td"), { textContent: formatSize(file.size) }); const updated = Object.assign(document.createElement("td"), { textContent: formatDate(file.updatedAt) }); const actions = document.createElement("td"); actions.className = "row-actions"; const renameButton = Object.assign(document.createElement("button"), { textContent: "移動" }); renameButton.className = "quiet"; renameButton.onclick = () => move(file.key); const deleteButton = Object.assign(document.createElement("button"), { textContent: "削除" }); deleteButton.className = "quiet"; deleteButton.onclick = () => remove(file.key); actions.append(renameButton, deleteButton); tr.append(nameCell, size, updated, actions); return tr; } async function loadFiles() { const data = await api(`/api/files?prefix=${encodeURIComponent(currentPrefix)}`); const rows = $("fileRows"); rows.replaceChildren(...data.folders.map(rowForFolder), ...data.files.map(rowForFile)); $("emptyState").classList.toggle("hidden", data.folders.length + data.files.length > 0); renderBreadcrumbs(); } async function openPrefix(prefix) { currentPrefix = prefix; await loadFiles(); } async function uploadFiles(files) { if (!files.length) return; const progress = $("progress"); progress.classList.remove("hidden"); let completed = 0; for (const file of files) { progress.textContent = `${file.name} をアップロード中 (${completed + 1}/${files.length})`; const key = `${currentPrefix}${file.name}`; const ticket = await api("/api/upload-url", { method: "POST", body: JSON.stringify({ key, contentType: file.type || "application/octet-stream", size: file.size }), }); const result = await fetch(ticket.url, { method: "PUT", headers: { "content-type": ticket.contentType }, body: file, }); if (!result.ok) throw new Error(`${file.name} のアップロードに失敗しました`); completed += 1; } progress.classList.add("hidden"); notice(`${completed}件アップロードしました`); await loadFiles(); } async function download(key) { const data = await api("/api/download-url", { method: "POST", body: JSON.stringify({ key }), }); location.href = data.url; } async function remove(key) { if (!confirm(`${key} をゴミ箱へ移動しますか?`)) return; await api(`/api/files?key=${encodeURIComponent(key)}`, { method: "DELETE" }); notice("ゴミ箱へ移動しました"); await loadFiles(); } async function move(sourceKey) { const destinationKey = prompt("新しいパス", sourceKey); if (!destinationKey || destinationKey === sourceKey) return; await api("/api/move", { method: "POST", body: JSON.stringify({ sourceKey, destinationKey }), }); notice("移動しました"); await loadFiles(); } async function createFolder() { const name = prompt("フォルダ名"); if (!name) return; await api("/api/folders", { method: "POST", body: JSON.stringify({ key: `${currentPrefix}${name}/` }), }); await loadFiles(); } async function showTrash() { const data = await api("/api/trash"); const box = $("trashRows"); box.replaceChildren(); if (!data.deleted.length) box.textContent = "ゴミ箱は空です"; for (const item of data.deleted) { const row = document.createElement("div"); row.className = "trash-row"; const details = document.createElement("div"); const name = Object.assign(document.createElement("strong"), { textContent: item.key }); const date = Object.assign(document.createElement("small"), { textContent: formatDate(item.deletedAt) }); details.append(name, date); const restoreButton = Object.assign(document.createElement("button"), { textContent: "復元" }); restoreButton.onclick = async (event) => { event.preventDefault(); await api("/api/restore", { method: "POST", body: JSON.stringify({ key: item.key, versionId: item.versionId }), }); await showTrash(); await loadFiles(); notice("復元しました"); }; row.append(details, restoreButton); box.append(row); } $("trashDialog").showModal(); } $("loginForm").addEventListener("submit", async (event) => { event.preventDefault(); $("loginError").textContent = ""; $("loginNotice").textContent = ""; try { await api("/api/login", { method: "POST", body: JSON.stringify({ username: $("username").value, password: $("password").value }), }); $("password").value = ""; showApp(); await loadFiles(); } catch (error) { $("loginError").textContent = error.message; } }); $("logoutButton").onclick = async () => { await api("/api/logout", { method: "POST" }); showLogin(); }; $("refreshButton").onclick = loadFiles; $("folderButton").onclick = createFolder; $("trashButton").onclick = showTrash; $("passwordButton").onclick = () => $("passwordDialog").showModal(); $("passwordCloseButton").onclick = () => $("passwordDialog").close(); $("passwordForm").addEventListener("submit", async (event) => { event.preventDefault(); $("passwordError").textContent = ""; const password = $("newPassword").value; if (password !== $("confirmPassword").value) { $("passwordError").textContent = "確認用パスワードが一致しません"; return; } try { await api("/api/change-password", { method: "POST", body: JSON.stringify({ password }), }); $("newPassword").value = ""; $("confirmPassword").value = ""; $("passwordDialog").close(); await api("/api/logout", { method: "POST" }); showLogin(); $("loginNotice").textContent = "設定しました。新しいパスワードでログインし、Chromeの保存を選んでください。"; } catch (error) { $("passwordError").textContent = error.message; } }); $("emailLoginButton").onclick = async () => { await api("/api/send-login-link", { method: "POST" }); notice("15分有効のログインリンクをメールへ送りました"); }; $("fileInput").onchange = (event) => uploadFiles([...event.target.files]).catch((error) => notice(error.message, true)); for (const eventName of ["dragenter", "dragover"]) { $("dropZone").addEventListener(eventName, (event) => { event.preventDefault(); $("dropZone").classList.add("active"); }); } for (const eventName of ["dragleave", "drop"]) { $("dropZone").addEventListener(eventName, (event) => { event.preventDefault(); $("dropZone").classList.remove("active"); }); } $("dropZone").addEventListener("drop", (event) => { uploadFiles([...event.dataTransfer.files]).catch((error) => notice(error.message, true)); }); (async () => { try { const session = await api("/api/session"); $("username").value = session.username; showApp(); await loadFiles(); } catch { showLogin(); } })();