From 90e66ee37f96f5cdb333c1ec9b18fd95ec0bc266 Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 4 Oct 2026 12:05:11 +0900 Subject: [PATCH 1/3] Redesign Booskiff drive with folder navigation and transfer queue --- .github/workflows/check.yml | 3 + apps/booskiff-web/README.md | 39 +- apps/booskiff-web/bff/api-hierarchy.test.ts | 96 ++++ apps/booskiff-web/bff/api.test.ts | 8 +- apps/booskiff-web/bff/api.ts | 48 +- apps/booskiff-web/bff/booskiff/client.ts | 9 +- apps/booskiff-web/bff/booskiff/mock.test.ts | 30 + apps/booskiff-web/bff/booskiff/mock.ts | 28 +- apps/booskiff-web/bff/booskiff/real.test.ts | 93 ++- apps/booskiff-web/bff/booskiff/real.ts | 47 +- apps/booskiff-web/e2e/real/tests/auth.spec.ts | 1 + .../e2e/tests/drive-resume.spec.ts | 7 +- apps/booskiff-web/e2e/tests/drive.spec.ts | 132 ++--- apps/booskiff-web/e2e/tests/errors.spec.ts | 1 - apps/booskiff-web/e2e/tests/helpers.ts | 12 +- .../booskiff-web/e2e/tests/pagination.spec.ts | 43 ++ apps/booskiff-web/e2e/tests/transfers.spec.ts | 130 +++++ apps/booskiff-web/src/App/Api/Drive.purs | 8 +- apps/booskiff-web/src/App/Message.purs | 47 +- apps/booskiff-web/src/App/Model.purs | 54 +- apps/booskiff-web/src/App/Route.purs | 2 + apps/booskiff-web/src/App/View/Common.js | 3 + apps/booskiff-web/src/App/View/Common.purs | 59 ++ apps/booskiff-web/src/App/View/Drive.purs | 539 ++++++++---------- .../booskiff-web/src/App/View/FileDetail.purs | 73 ++- apps/booskiff-web/src/App/View/Layout.purs | 81 +-- apps/booskiff-web/src/App/View/Transfers.purs | 99 ++++ apps/booskiff-web/src/Client/Update.purs | 468 +++++++-------- apps/booskiff-web/src/Client/Upload.js | 213 +++++-- apps/booskiff-web/src/Client/Upload.purs | 51 +- apps/booskiff-web/src/style.css | 191 +++++++ apps/booskiff-web/test/Test/Main.purs | 181 ++++-- apps/booskiff-web/test/Upload.test.js | 129 +++++ .../visual/screens/booskiff-stub.ts | 22 +- apps/ui-catalog/visual/screens/capture.ts | 35 +- apps/ui-catalog/visual/screens/fixtures.ts | 4 +- .../ui-catalog/visual/screens/screens.test.ts | 3 + 37 files changed, 2059 insertions(+), 930 deletions(-) create mode 100644 apps/booskiff-web/bff/api-hierarchy.test.ts create mode 100644 apps/booskiff-web/e2e/tests/pagination.spec.ts create mode 100644 apps/booskiff-web/e2e/tests/transfers.spec.ts create mode 100644 apps/booskiff-web/src/App/View/Common.js create mode 100644 apps/booskiff-web/src/App/View/Common.purs create mode 100644 apps/booskiff-web/src/App/View/Transfers.purs create mode 100644 apps/booskiff-web/test/Upload.test.js diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index b5153c7..dc37601 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -65,6 +65,9 @@ jobs: - run: bun test bff/ working-directory: apps/booskiff-web + - run: bun test test/Upload.test.js + working-directory: apps/booskiff-web + - run: >- mkdir -p dist && printf 'export function renderPage(...args: any[]): any;\n' > dist/server.d.ts working-directory: apps/booskiff-web diff --git a/apps/booskiff-web/README.md b/apps/booskiff-web/README.md index 40266ac..216c7bf 100644 --- a/apps/booskiff-web/README.md +++ b/apps/booskiff-web/README.md @@ -29,6 +29,30 @@ COOKIE_SECRET_BASE64="$(openssl rand -base64 32)" ./scripts/dev.sh release Mock ログインのパスワードは `password` です(メールアドレスは任意)。 +## ファイル画面 + +`/drive` はルート、`/drive/folders/` はそのフォルダーの直下を表示します。 +パンくずから親へ戻れ、深いフォルダーの URL を直接開くこともできます。 +リスト表示は名前・サイズ・追加日時、アイコン表示はアイコンと名前だけを表示します。 +表示形式を切り替えても、現在地・名前の絞り込み・並び順は維持されます。 + +複数ファイルのアップロードは右下の転送パネルで順番に処理します。 +保存先はファイルを追加した時点のフォルダーに固定されます。 +送信が 100% になった後も、サーバーが保存成功を返すまでは保存処理中です。 +パネルの縮小やアプリ内のフォルダー移動で転送は中断されません。 +ページの再読み込み・離脱をまたぐ再開には対応していません。 +通信が切れて保存結果を確認できない場合は、保存先を確認してから再送してください。 + +ダウンロードはブラウザーが管理します。転送パネルは要求済みの記録を表示し、 +ダウンロードの完了率や保存完了は表示しません。 +画面から削除できるフォルダーは空のものに限ります。 + +### 対応する core + +この画面には、`folders.parent_id` の migration と `root` / `parent_id` / +`require_empty` に対応した Booskiff core が必要です。先に core を更新してから +Web を更新してください。既存フォルダーはルートに残り、ファイルの所属は維持されます。 + ## 環境変数 `auth-bun` は import 時に環境変数を読み取ります。そのため、`dev.sh` と Compose は `SESSION_COOKIE_NAME`、`OAUTH_COOKIE_NAME`、および必要な認証設定を **import より前** に設定します。個別に `bun index.ts` を起動する場合も、同じく起動前に設定してください。 @@ -67,10 +91,18 @@ Mock ログインのパスワードは `password` です(メールアドレス | `/api/billing/status` | 課金ステータスの取得 | | `/.well-known/jwks.json` | mock / test-JWT モードのみの JWKS 公開。`NODE_ENV=production` では常に 404 | +新しい画面は `/api/files?root=true` または `?folder_id=` で現在地を指定します。 +BFF は core の一覧を 200 件ずつ最後まで取得するため、50 件を超えるファイルも表示対象です。 +`/api/folders` の応答には `parentId` が含まれ、`?root=true` / `?parent_id=` で +直下のフォルダーを取得できます。フィルター省略時の集約一覧は旧 API 利用者向けに維持します。 +フォルダー作成は `{name, parent_id}`、画面からの削除は +`DELETE /api/folders/?require_empty=true` を使用します。 + ## テスト ```bash bun test bff/ # bare `bun test` ではない。e2e Playwright spec を拾わないため +bun test test/Upload.test.js # 転送キューとXHRの状態遷移 spago test ``` @@ -111,12 +143,11 @@ NixOS では `PLAYWRIGHT_CHROMIUM_PATH=$(command -v chromium)` を指定でき **検出した既存不具合と暫定措置:** [#17](https://github.com/ShuttlePub/shuttlepub-frontends/issues/17) / [#20](https://github.com/ShuttlePub/shuttlepub-frontends/issues/20) のログイン導線は修正済みです。 -ログイン成功後に BFF が `next: /auth/oauth/start?return_to=/login` を返し、UI が +ログイン成功後に BFF が `next: /auth/oauth/start?return_to=/drive` を返し、UI が トップレベル遷移で OAuth を開始するため、E2E の `/auth/oauth/start` 明示遷移は解除済みです。 [#19](https://github.com/ShuttlePub/shuttlepub-frontends/issues/19)(認証済み `/drive` の -フルロード時の resume DOM 破損)は未修正のため、`return_to=/login` の SPA 経路は -BFF 側で維持し、永続化は引き続き API で検証します。 -CI は mock/real を別 matrix job で実行し、両方 green です。 +フルロード時の resume DOM 破損)は #21 で修正済みです。永続化は `/drive` の +再読み込み後の UI でも検証します。CI は mock/real を別 matrix job で実行します。 ## 構成 diff --git a/apps/booskiff-web/bff/api-hierarchy.test.ts b/apps/booskiff-web/bff/api-hierarchy.test.ts new file mode 100644 index 0000000..b8b1651 --- /dev/null +++ b/apps/booskiff-web/bff/api-hierarchy.test.ts @@ -0,0 +1,96 @@ +import "./test-setup.ts"; +import { afterEach, expect, test } from "bun:test"; +import { handleApiRequest } from "./api.ts"; +import { createBooskiffClient } from "./booskiff/real.ts"; +import { createTestSessionAdapter } from "./session-test-adapter.ts"; +import { TEST_COOKIE_SECRET_BASE64 } from "./test-setup.ts"; +import { stubFetch, jsonResponse, jsonBody } from "./test-utils.ts"; + +const originalFetch = globalThis.fetch; +afterEach(() => Object.assign(globalThis, { fetch: originalFetch })); + +const adapter = createTestSessionAdapter({ + cookieSecretBase64: TEST_COOKIE_SECRET_BASE64, sessionCookieName: "booskiff_session", + isSecureOrigin: false, hydraPublicUrl: "http://hydra.test", hydraClientId: "test", + hydraClientSecret: "test", refreshSkewSeconds: 60, +}); + +async function request(path: string, method = "GET", body?: unknown): Promise { + const cookie = await adapter.sealSessionCookie({ v: 1, accessToken: "owner-access", tokenType: "Bearer", + scope: "", expiresAt: Math.floor(Date.now() / 1000) + 3600 }); + const result = await handleApiRequest(new Request(`http://localhost:3000/api/${path}`, { + method, headers: { cookie: cookie.split(";")[0] ?? "", origin: "http://localhost:3000", "content-type": "application/json" }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }), { adapter, createClient: (token) => createBooskiffClient({ coreApiUrl: "http://core.test" }, token) }); + if (!result) throw new Error("missing API response"); + return result; +} + +test.each([ + ["files?root=true", "root", "true"], + ["folders?root=true", "root", "true"], + ["folders?parent_id=parent%2F1", "parent_id", "parent/1"], +])("passes hierarchy filter %s with the session owner", async (path, key, value) => { + const { calls } = stubFetch(() => jsonResponse(200, { items: [] })); + expect((await request(path)).status).toBe(200); + expect(new URL(calls[0].url).searchParams.get(key)).toBe(value); + expect(calls[0].headers.Authorization).toBe("Bearer owner-access"); +}); + +test.each([ + "files?root=true&folder_id=f1", "folders?root=true&parent_id=p1", + "files?root=bad", "folders?root=", "folders/f1?require_empty=1", +])("rejects invalid hierarchy query before upstream: %s", async (path) => { + const { calls } = stubFetch(() => { throw new Error("must not call core"); }); + expect((await request(path, path.includes("require_empty") ? "DELETE" : "GET")).status).toBe(400); + expect(calls).toHaveLength(0); +}); + +test.each([null, "parent-1"])("creates a folder with parent_id %s and returns parentId", async (parentId) => { + stubFetch((call) => { + expect(jsonBody(call)).toEqual({ name: "docs", parent_id: parentId }); + return jsonResponse(201, { id: "f1", name: "docs", parent_id: parentId, created_at: "2026-10-04T00:00:00Z" }); + }); + const response = await request("folders", "POST", { name: "docs", parent_id: parentId }); + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ id: "f1", name: "docs", parentId, createdAt: "2026-10-04T00:00:00Z" }); +}); + +test.each([null, [], { name: "docs", parent_id: 123 }, { name: "docs", parent_id: "" }].map((body) => ({ body })))( + "rejects malformed create body without upstream mutation: %j", async ({ body }) => { + const { calls } = stubFetch(() => { throw new Error("must not call core"); }); + expect((await request("folders", "POST", body)).status).toBe(400); + expect(calls).toHaveLength(0); + }, +); + +test("safe folder delete forwards require_empty and preserves conflict body", async () => { + const error = { error: { code: "folder_not_empty", message: "folder contains files" } }; + const { calls } = stubFetch(() => jsonResponse(409, error)); + const response = await request("folders/f1?require_empty=true", "DELETE"); + expect(calls[0].url).toBe("http://core.test/v1/folders/f1?require_empty=true"); + expect(calls[0].method).toBe("DELETE"); + expect(response.status).toBe(409); + expect(await response.json()).toEqual(error); +}); + +test.each(["folders?parent_id=other-owner", "files?folder_id=other-owner"])( + "passes through upstream ownership errors: %s", async (path) => { + const error = { error: { code: "not_found", message: "folder not found" } }; + stubFetch(() => jsonResponse(404, error)); + const response = await request(path); + expect(response.status).toBe(404); + expect(await response.json()).toEqual(error); + }, +); + +test("later file page errors return an error without a partial listing", async () => { + const files = Array.from({ length: 200 }, (_, i) => ({ id: `f${i}`, name: "a", mime_type: "text/plain", + size_bytes: 1, folder_id: null, is_public: false, created_at: "2026-10-04T00:00:00Z" })); + const error = { error: { code: "rate_limited", message: "retry later" } }; + stubFetch((call) => !new URL(call.url).searchParams.has("before_id") + ? jsonResponse(200, { items: files }) : jsonResponse(429, error)); + const response = await request("files?root=true"); + expect(response.status).toBe(429); + expect(await response.json()).toEqual(error); +}); diff --git a/apps/booskiff-web/bff/api.test.ts b/apps/booskiff-web/bff/api.test.ts index a82b9ad..268afee 100644 --- a/apps/booskiff-web/bff/api.test.ts +++ b/apps/booskiff-web/bff/api.test.ts @@ -128,7 +128,7 @@ describe("GET /api/files", () => { test("forwards folder_id, Bearer token, maps snake_case → camelCase", async () => { const { calls } = stubFetch((call) => { expect(call.method).toBe("GET"); - expect(call.url).toBe(`${CORE}/v1/files?folder_id=f1`); + expect(call.url).toBe(`${CORE}/v1/files?folder_id=f1&limit=200`); expect(call.headers.Authorization).toBe("Bearer core-access-token"); return jsonResponse(200, { items: [wireFile] }); }); @@ -141,7 +141,7 @@ describe("GET /api/files", () => { test("no folder_id → no query param; null folder_id maps to null", async () => { stubFetch((call) => { - expect(call.url).toBe(`${CORE}/v1/files`); + expect(call.url).toBe(`${CORE}/v1/files?limit=200`); return jsonResponse(200, { items: [{ ...wireFile, folder_id: null }] }); }); const cookie = await cookieFor(makeSession()); @@ -264,8 +264,8 @@ describe("file download / delete", () => { }); describe("folders CRUD", () => { - const wireFolder = { id: "f1", name: "docs", created_at: "2026-01-01T00:00:00Z" }; - const camelFolder = { id: "f1", name: "docs", createdAt: "2026-01-01T00:00:00Z" }; + const wireFolder = { id: "f1", name: "docs", parent_id: null, created_at: "2026-01-01T00:00:00Z" }; + const camelFolder = { id: "f1", name: "docs", parentId: null, createdAt: "2026-01-01T00:00:00Z" }; test("GET /api/folders → {items:[camelCase]}", async () => { stubFetch((call) => { diff --git a/apps/booskiff-web/bff/api.ts b/apps/booskiff-web/bff/api.ts index 385c5e6..349d415 100644 --- a/apps/booskiff-web/bff/api.ts +++ b/apps/booskiff-web/bff/api.ts @@ -74,7 +74,11 @@ async function dispatch(req: Request, pathname: string, client: BooskiffClient, if (pathname === "/api/files") { if (method === "GET") { const folderId = url.searchParams.get("folder_id") || undefined; - return jsonResponse({ items: await client.listFiles(folderId) }, setCookie); + const root = booleanQuery(url, "root"); + if (root === null || (root && folderId !== undefined)) { + return jsonError(400, "bad_request", "root must be a boolean and cannot be true with folder_id", setCookie); + } + return jsonResponse({ items: await client.listFiles(folderId, root) }, setCookie); } if (method === "POST") return upload(req, url, client, setCookie); } @@ -95,11 +99,18 @@ async function dispatch(req: Request, pathname: string, client: BooskiffClient, } if (pathname === "/api/folders") { - if (method === "GET") return jsonResponse({ items: await client.listFolders() }, setCookie); + if (method === "GET") { + const parentId = url.searchParams.get("parent_id") || undefined; + const root = booleanQuery(url, "root"); + if (root === null || (root && parentId !== undefined)) { + return jsonError(400, "bad_request", "root must be a boolean and cannot be true with parent_id", setCookie); + } + return jsonResponse({ items: await client.listFolders(parentId, root) }, setCookie); + } if (method === "POST") { - const name = await readName(req); - if (name === null) return jsonError(400, "bad_request", `JSON body with non-empty "name" is required`, setCookie); - return jsonResponse(await client.createFolder(name), setCookie, 201); + const body = await readFolderBody(req); + if (body === null) return jsonError(400, "bad_request", `JSON body with non-empty "name" and optional "parent_id" is required`, setCookie); + return jsonResponse(await client.createFolder(body.name, body.parentId), setCookie, 201); } } @@ -113,7 +124,9 @@ async function dispatch(req: Request, pathname: string, client: BooskiffClient, return jsonResponse(await client.renameFolder(id, name), setCookie); } if (method === "DELETE") { - await client.deleteFolder(id); + const requireEmpty = booleanQuery(url, "require_empty"); + if (requireEmpty === null) return jsonError(400, "bad_request", "require_empty must be a boolean", setCookie); + await client.deleteFolder(id, requireEmpty); return emptyResponse(204, setCookie); } } @@ -146,11 +159,28 @@ async function upload(req: Request, url: URL, client: BooskiffClient, setCookie: } async function readName(req: Request): Promise { - let body: { name?: unknown }; + const body = await readFolderBody(req); + return body?.name ?? null; +} + +function booleanQuery(url: URL, key: string): boolean | undefined | null { + const value = url.searchParams.get(key); + if (value === null) return undefined; + if (value === "true") return true; + if (value === "false") return false; + return null; +} + +async function readFolderBody(req: Request): Promise<{ name: string; parentId?: string | null } | null> { + let body: unknown; try { - body = await req.json() as { name?: unknown }; + body = await req.json(); } catch { return null; } - return typeof body.name === "string" && body.name.length > 0 ? body.name : null; + if (typeof body !== "object" || body === null || Array.isArray(body)) return null; + const { name, parent_id: parentId } = body as { name?: unknown; parent_id?: unknown }; + if (typeof name !== "string" || name.length === 0) return null; + if (parentId !== undefined && parentId !== null && (typeof parentId !== "string" || parentId.length === 0)) return null; + return { name, ...(parentId !== undefined ? { parentId: parentId as string | null } : {}) }; } diff --git a/apps/booskiff-web/bff/booskiff/client.ts b/apps/booskiff-web/bff/booskiff/client.ts index e5af904..db55441 100644 --- a/apps/booskiff-web/bff/booskiff/client.ts +++ b/apps/booskiff-web/bff/booskiff/client.ts @@ -18,6 +18,7 @@ export type FileItem = { export type Folder = { readonly id: string; readonly name: string; + readonly parentId: string | null; readonly createdAt: string; }; @@ -53,14 +54,14 @@ export class BooskiffApiError extends Error { export interface BooskiffClient { getFile(id: string): Promise; - listFiles(folderId?: string): Promise; + listFiles(folderId?: string, root?: boolean): Promise; uploadFile(input: UploadInput): Promise; deleteFile(id: string): Promise; getDownloadUrl(id: string): Promise; - listFolders(): Promise; + listFolders(parentId?: string, root?: boolean): Promise; getFolder(id: string): Promise; - createFolder(name: string): Promise; + createFolder(name: string, parentId?: string | null): Promise; renameFolder(id: string, name: string): Promise; - deleteFolder(id: string): Promise; + deleteFolder(id: string, requireEmpty?: boolean): Promise; billingStatus(): Promise; } diff --git a/apps/booskiff-web/bff/booskiff/mock.test.ts b/apps/booskiff-web/bff/booskiff/mock.test.ts index 86fbdcf..67f75bd 100644 --- a/apps/booskiff-web/bff/booskiff/mock.test.ts +++ b/apps/booskiff-web/bff/booskiff/mock.test.ts @@ -98,6 +98,36 @@ describe("upload quota accounting", () => { }); describe("folders", () => { + test("parent hierarchy filters immediate children and permits names in separate folders", async () => { + const client = createMockBooskiffClient(); + const parent = await client.createFolder("docs"); + const child = await client.createFolder("docs", parent.id); + const nested = await client.createFolder("nested", child.id); + expect(child.parentId).toBe(parent.id); + expect(await client.listFolders(undefined, true)).toEqual([parent]); + expect(await client.listFolders(parent.id)).toEqual([child]); + expect(await client.listFolders()).toEqual([parent, child, nested]); + expect((await errorOf(client.createFolder("docs", parent.id))).status).toBe(409); + expect((await errorOf(client.createFolder("missing", "unknown"))).status).toBe(404); + expect((await errorOf(client.deleteFolder(parent.id))).status).toBe(409); + await client.deleteFolder(nested.id, true); + }); + + test("root file filter and safe delete preserve files until emptied", async () => { + const client = createMockBooskiffClient(); + const folder = await client.createFolder("docs"); + const input = { name: "a.txt", mime: null, contentType: "text/plain", contentLength: "1" }; + const rootFile = await client.uploadFile({ ...input, folderId: null, body: streamOfSize(1) }); + const childFile = await client.uploadFile({ ...input, folderId: folder.id, body: streamOfSize(1) }); + expect(await client.listFiles(undefined, true)).toEqual([rootFile]); + expect(await client.listFiles(folder.id)).toEqual([childFile]); + expect((await errorOf(client.deleteFolder(folder.id, true))).status).toBe(409); + expect(await client.getFolder(folder.id)).toEqual(folder); + expect(await client.getFile(childFile.id)).toEqual(childFile); + await client.deleteFile(childFile.id); + await client.deleteFolder(folder.id, true); + }); + test("duplicate folder name → 409 folder_already_exists shaped error", async () => { const client = createMockBooskiffClient(); await client.createFolder("docs"); diff --git a/apps/booskiff-web/bff/booskiff/mock.ts b/apps/booskiff-web/bff/booskiff/mock.ts index bb76860..ab0d6c9 100644 --- a/apps/booskiff-web/bff/booskiff/mock.ts +++ b/apps/booskiff-web/bff/booskiff/mock.ts @@ -59,11 +59,13 @@ export function createMockBooskiffClient(config: MockBooskiffConfig = {}): Boosk return requireFile(id); }, - async listFiles(folderId): Promise { - return folderId === undefined ? [...files] : files.filter((f) => f.folderId === folderId); + async listFiles(folderId, root): Promise { + return root ? files.filter((f) => f.folderId === null) + : folderId === undefined ? [...files] : files.filter((f) => f.folderId === folderId); }, async uploadFile(input: UploadInput): Promise { + if (input.folderId !== null) requireFolder(input.folderId); const bytes = new Uint8Array(await new Response(input.body).arrayBuffer()); if (bytes.byteLength > maxFileBytes) { throw apiError(413, "file_too_large", `file exceeds maxFileBytes: ${bytes.byteLength} > ${maxFileBytes}`); @@ -95,32 +97,42 @@ export function createMockBooskiffClient(config: MockBooskiffConfig = {}): Boosk return `http://mock-storage.local/booskiff/${id}?sig=mock`; }, - async listFolders(): Promise { - return [...folders]; + async listFolders(parentId, root): Promise { + return root ? folders.filter((f) => f.parentId === null) + : parentId === undefined ? [...folders] : folders.filter((f) => f.parentId === parentId); }, async getFolder(id): Promise { return requireFolder(id); }, - async createFolder(name): Promise { - if (folders.some((f) => f.name === name)) { + async createFolder(name, parentId): Promise { + const parent = parentId ?? null; + if (parent !== null) requireFolder(parent); + if (folders.some((f) => f.name === name && f.parentId === parent)) { throw apiError(409, "folder_already_exists", `folder already exists: ${name}`); } - const folder: Folder = { id: nextId("folder"), name, createdAt: new Date().toISOString() }; + const folder: Folder = { id: nextId("folder"), name, parentId: parent, createdAt: new Date().toISOString() }; folders.push(folder); return folder; }, async renameFolder(id, name): Promise { const folder = requireFolder(id); + if (folders.some((f) => f.id !== id && f.name === name && f.parentId === folder.parentId)) { + throw apiError(409, "folder_already_exists", `folder already exists: ${name}`); + } const renamed: Folder = { ...folder, name }; folders[folders.indexOf(folder)] = renamed; return renamed; }, - async deleteFolder(id): Promise { + async deleteFolder(id, requireEmpty): Promise { const folder = requireFolder(id); + if (folders.some((f) => f.parentId === id) + || (requireEmpty && files.some((f) => f.folderId === id))) { + throw apiError(409, "folder_not_empty", "folder is not empty"); + } folders.splice(folders.indexOf(folder), 1); // フォルダ削除で file の folderId は detach (null) される for (const [index, file] of files.entries()) { diff --git a/apps/booskiff-web/bff/booskiff/real.test.ts b/apps/booskiff-web/bff/booskiff/real.test.ts index d4ae8d5..884a7f7 100644 --- a/apps/booskiff-web/bff/booskiff/real.test.ts +++ b/apps/booskiff-web/bff/booskiff/real.test.ts @@ -57,8 +57,8 @@ const camelFile = { isPublic: false, createdAt: "2026-01-01T00:00:00Z", }; -const wireFolder = { id: "f1", name: "docs", created_at: "2026-01-01T00:00:00Z" }; -const camelFolder = { id: "f1", name: "docs", createdAt: "2026-01-01T00:00:00Z" }; +const wireFolder = { id: "f1", name: "docs", parent_id: null, created_at: "2026-01-01T00:00:00Z" }; +const camelFolder = { id: "f1", name: "docs", parentId: null, createdAt: "2026-01-01T00:00:00Z" }; const originalFetch = globalThis.fetch; afterEach(() => { @@ -69,7 +69,7 @@ describe("files", () => { test("listFiles: GET /v1/files?folder_id= with Bearer, maps to camelCase", async () => { const { calls } = stubFetch((call) => { expect(call.method).toBe("GET"); - expect(call.url).toBe(`${CORE}/v1/files?folder_id=f1`); + expect(call.url).toBe(`${CORE}/v1/files?folder_id=f1&limit=200`); return jsonResponse(200, { items: [{ ...wireFile, folder_id: "f1" }] }); }); const result = await makeClient().listFiles("f1"); @@ -77,13 +77,74 @@ describe("files", () => { expect(result).toEqual([{ ...camelFile, folderId: "f1" }]); }); - test("listFiles without folderId → bare /v1/files", async () => { + test("listFiles without folderId → unfiltered /v1/files", async () => { const { calls } = stubFetch(() => jsonResponse(200, { items: [wireFile] })); const result = await makeClient().listFiles(); - expect(calls[0]?.url).toBe(`${CORE}/v1/files`); + expect(calls[0]?.url).toBe(`${CORE}/v1/files?limit=200`); expect(result).toEqual([camelFile]); }); + test.each([undefined, "f1"])("listFiles fetches every page with the same filter: %s", async (folderId) => { + const records = Array.from({ length: 405 }, (_, index) => ({ ...wireFile, id: `file_${index}`, folder_id: folderId ?? null })); + const { calls } = stubFetch((call) => { + const query = new URL(call.url).searchParams; + expect(query.get("limit")).toBe("200"); + expect(query.get("folder_id")).toBe(folderId ?? null); + expect(query.get("root")).toBe(folderId === undefined ? "true" : null); + expect(call.headers.Authorization).toBe(`Bearer ${TOKEN}`); + expect(query.has("offset")).toBe(false); + const cursorId = query.get("before_id"); + const offset = cursorId === null ? 0 : records.findIndex((record) => record.id === cursorId) + 1; + expect(query.get("before_created_at")).toBe(cursorId === null ? null : wireFile.created_at); + return jsonResponse(200, { items: records.slice(offset, offset + 200) }); + }); + const result = await makeClient().listFiles(folderId, folderId === undefined); + expect(result).toHaveLength(405); + expect(result[404]?.id).toBe("file_404"); + expect(calls.map((call) => new URL(call.url).searchParams.get("before_id"))).toEqual([null, "file_199", "file_399"]); + }); + + test("listFiles does not skip surviving rows when a previous page's row is deleted", async () => { + let records = Array.from({ length: 401 }, (_, index) => ({ + ...wireFile, id: `file_${String(401 - index).padStart(3, "0")}`, + created_at: "2026-10-04T00:00:00.123456Z", + })); + stubFetch((call) => { + const query = new URL(call.url).searchParams; + expect(query.has("offset")).toBe(false); + const beforeId = query.get("before_id"); + if (beforeId !== null) expect(query.get("before_created_at")).toBe("2026-10-04T00:00:00.123456Z"); + const page = records.filter((record) => beforeId === null || record.id < beforeId).slice(0, 200); + if (beforeId === null) records = records.slice(1); + return jsonResponse(200, { items: page }); + }); + const result = new Set((await makeClient().listFiles(undefined, true)).map((file) => file.id)); + for (const survivor of records) expect(result.has(survivor.id)).toBe(true); + }); + + test("listFiles removes duplicate IDs across pages", async () => { + const records = Array.from({ length: 200 }, (_, index) => ({ ...wireFile, id: `file_${index}` })); + stubFetch((call) => jsonResponse(200, { items: !new URL(call.url).searchParams.has("before_id") + ? records : [records[199], { ...wireFile, id: "last" }] })); + expect(await makeClient().listFiles()).toHaveLength(201); + }); + + test("listFiles rejects an upstream that endlessly repeats a full page", async () => { + const records = Array.from({ length: 200 }, (_, index) => ({ ...wireFile, id: `file_${index}` })); + const { calls } = stubFetch(() => jsonResponse(200, { items: records })); + await expect(makeClient().listFiles()).rejects.toThrow("pagination made no progress"); + expect(calls).toHaveLength(2); + }); + + test("listFiles rejects a later page error instead of returning partial results", async () => { + const records = Array.from({ length: 200 }, (_, index) => ({ ...wireFile, id: `file_${index}` })); + stubFetch((call) => !new URL(call.url).searchParams.has("before_id") + ? jsonResponse(200, { items: records }) : jsonResponse(403, { error: { code: "forbidden", message: "owner mismatch" } })); + const error = await makeClient().listFiles("f1").catch((err: unknown) => err); + expect(error).toBeInstanceOf(BooskiffApiError); + expect((error as BooskiffApiError).status).toBe(403); + }); + test("uploadFile: POST /v1/files with stream body, duplex half, verbatim Content-Length, encoded query", async () => { const { calls } = stubFetch(() => jsonResponse(201, { ...wireFile, folder_id: "f1" })); const input = uploadInput({ @@ -159,6 +220,28 @@ describe("files", () => { }); describe("folders", () => { + test.each([undefined, "parent/1"])("listFolders passes its root or parent filter: %s", async (parentId) => { + const { calls } = stubFetch(() => jsonResponse(200, { items: [{ ...wireFolder, parent_id: parentId ?? null }] })); + expect(await makeClient().listFolders(parentId, parentId === undefined)).toEqual([{ ...camelFolder, parentId: parentId ?? null }]); + const query = new URL(calls[0].url).searchParams; + expect(query.get("parent_id")).toBe(parentId ?? null); + expect(query.get("root")).toBe(parentId === undefined ? "true" : null); + }); + + test.each([null, "parent-1"])("createFolder sends an explicit parent: %s", async (parentId) => { + stubFetch((call) => { + expect(jsonBody(call)).toEqual({ name: "docs", parent_id: parentId }); + return jsonResponse(201, { ...wireFolder, parent_id: parentId }); + }); + expect((await makeClient().createFolder("docs", parentId)).parentId).toBe(parentId); + }); + + test("deleteFolder passes the safe-delete option and core conflict", async () => { + const { calls } = stubFetch(() => jsonResponse(409, { error: { code: "folder_not_empty", message: "not empty" } })); + const error = await makeClient().deleteFolder("f/1", true).catch((err: unknown) => err); + expect(calls[0].url).toBe(`${CORE}/v1/folders/f%2F1?require_empty=true`); + expect((error as BooskiffApiError).status).toBe(409); + }); test("listFolders: GET /v1/folders → camelCase items", async () => { stubFetch((call) => { expect(call.method).toBe("GET"); diff --git a/apps/booskiff-web/bff/booskiff/real.ts b/apps/booskiff-web/bff/booskiff/real.ts index 5f62b39..27d6c21 100644 --- a/apps/booskiff-web/bff/booskiff/real.ts +++ b/apps/booskiff-web/bff/booskiff/real.ts @@ -35,7 +35,7 @@ type WireFile = { type WireFileList = { items: WireFile[] }; -type WireFolder = { id: string; name: string; created_at: string }; +type WireFolder = { id: string; name: string; parent_id: string | null; created_at: string }; type WireFolderList = { items: WireFolder[] }; @@ -59,7 +59,7 @@ function toFile(wire: WireFile): FileItem { } function toFolder(wire: WireFolder): Folder { - return { id: wire.id, name: wire.name, createdAt: wire.created_at }; + return { id: wire.id, name: wire.name, parentId: wire.parent_id ?? null, createdAt: wire.created_at }; } function toBilling(wire: WireBilling): BillingStatus { @@ -97,13 +97,30 @@ export function createBooskiffClient(config: RealBooskiffConfig, accessToken: st return toFile(await expectJson(resp, [200])); }, - async listFiles(folderId): Promise { + async listFiles(folderId, root): Promise { const query = new URLSearchParams(); if (folderId !== undefined) query.set("folder_id", folderId); - const qs = query.toString(); - const resp = await fetch(`${base}/v1/files${qs ? `?${qs}` : ""}`, { headers: authHeaders() }); - const wire = await expectJson(resp, [200]); - return wire.items.map(toFile); + if (root) query.set("root", "true"); + // Core defaults to 50 rows; every browser listing must include all pages. + const pageSize = 200; + query.set("limit", String(pageSize)); + const files = new Map(); + for (;;) { + const resp = await fetch(`${base}/v1/files?${query}`, { headers: authHeaders() }); + const wire = await expectJson(resp, [200]); + const previousCount = files.size; + for (const file of wire.items) files.set(file.id, toFile(file)); + if (wire.items.length < pageSize) return [...files.values()]; + // Fail instead of returning a silently truncated list if an upstream + // ignores the cursor or endlessly repeats a full page. + if (files.size === previousCount) throw new Error("file pagination made no progress"); + // Preserve the exact timestamp precision from core. A keyset cursor + // avoids skipping surviving rows when earlier rows are deleted while + // this request is fetching the next page. + const last = wire.items[wire.items.length - 1]!; + query.set("before_created_at", last.created_at); + query.set("before_id", last.id); + } }, async uploadFile(input: UploadInput): Promise { @@ -141,8 +158,12 @@ export function createBooskiffClient(config: RealBooskiffConfig, accessToken: st return wire.url; }, - async listFolders(): Promise { - const resp = await fetch(`${base}/v1/folders`, { headers: authHeaders() }); + async listFolders(parentId, root): Promise { + const query = new URLSearchParams(); + if (parentId !== undefined) query.set("parent_id", parentId); + if (root) query.set("root", "true"); + const qs = query.toString(); + const resp = await fetch(`${base}/v1/folders${qs ? `?${qs}` : ""}`, { headers: authHeaders() }); const wire = await expectJson(resp, [200]); return wire.items.map(toFolder); }, @@ -152,11 +173,11 @@ export function createBooskiffClient(config: RealBooskiffConfig, accessToken: st return toFolder(await expectJson(resp, [200])); }, - async createFolder(name): Promise { + async createFolder(name, parentId): Promise { const resp = await fetch(`${base}/v1/folders`, { method: "POST", headers: authHeaders({ "Content-Type": "application/json" }), - body: JSON.stringify({ name }), + body: JSON.stringify({ name, ...(parentId !== undefined ? { parent_id: parentId } : {}) }), }); return toFolder(await expectJson(resp, [201])); }, @@ -170,8 +191,8 @@ export function createBooskiffClient(config: RealBooskiffConfig, accessToken: st return toFolder(await expectJson(resp, [200])); }, - async deleteFolder(id): Promise { - const resp = await fetch(`${base}/v1/folders/${encodeURIComponent(id)}`, { + async deleteFolder(id, requireEmpty): Promise { + const resp = await fetch(`${base}/v1/folders/${encodeURIComponent(id)}${requireEmpty ? "?require_empty=true" : ""}`, { method: "DELETE", headers: authHeaders(), }); diff --git a/apps/booskiff-web/e2e/real/tests/auth.spec.ts b/apps/booskiff-web/e2e/real/tests/auth.spec.ts index b8f1a9f..64edf03 100644 --- a/apps/booskiff-web/e2e/real/tests/auth.spec.ts +++ b/apps/booskiff-web/e2e/real/tests/auth.spec.ts @@ -83,6 +83,7 @@ test("real login grants a cookie session, drive access, and logout", async ({ pa // The core validates Hydra's signed access token before persisting this folder. const folder = `hydra-${crypto.randomUUID()}`; + await page.getByTestId("new-folder-button").click(); await page.getByTestId("folder-name-input").fill(folder); const created = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/folders" && response.request().method() === "POST"); await page.getByTestId("folder-create-submit").click(); diff --git a/apps/booskiff-web/e2e/tests/drive-resume.spec.ts b/apps/booskiff-web/e2e/tests/drive-resume.spec.ts index c4b9273..d8a09be 100644 --- a/apps/booskiff-web/e2e/tests/drive-resume.spec.ts +++ b/apps/booskiff-web/e2e/tests/drive-resume.spec.ts @@ -33,12 +33,15 @@ test("authenticated /drive full reload keeps a single, working folder section", await expect(page.getByTestId("drive-page")).toBeVisible(); // Folder section elements must exist exactly once (no duplication). - await expect(page.getByTestId("folder-create-submit")).toHaveCount(1); + await expect(page.getByTestId("new-folder-button")).toHaveCount(1); await expect(page.getByTestId("folder-list")).toHaveCount(1); - await expect(page.getByTestId("folder-name-input")).toHaveCount(1); + await expect(page.getByTestId("folder-name-input")).toHaveCount(0); // Event handlers must survive resume: folder creation still works. const name = `resume-${runId}`; + await page.getByTestId("new-folder-button").click(); + await expect(page.getByTestId("folder-name-input")).toHaveCount(1); + await expect(page.getByTestId("folder-create-submit")).toHaveCount(1); await page.getByTestId("folder-name-input").fill(name); await page.getByTestId("folder-create-submit").click(); await expect(folderRow(page, name)).toBeVisible({ timeout: 15_000 }); diff --git a/apps/booskiff-web/e2e/tests/drive.spec.ts b/apps/booskiff-web/e2e/tests/drive.spec.ts index 725ed87..c75e9b7 100644 --- a/apps/booskiff-web/e2e/tests/drive.spec.ts +++ b/apps/booskiff-web/e2e/tests/drive.spec.ts @@ -6,49 +6,41 @@ const folderName = `f-${runId}`; const renamedFolderName = `pics-${runId}`; const fileName = "hello.txt"; const helloPath = fileURLToPath(new URL("./assets/hello.txt", import.meta.url)); - -// Serial: the tests form one narrative against shared server-side state -// (create -> upload -> download -> rename -> delete), and every run starts -// from a fresh stack (scripts/e2e.sh tears down volumes). test.describe.configure({ mode: "serial" }); - -async function quotaText(page: Page): Promise { - return page.getByTestId("quota").textContent(); +async function quotaText(page: Page) { return page.getByTestId("quota").textContent(); } +async function createFolder(page: Page, name: string) { + await page.getByTestId("new-folder-button").click(); + await page.getByTestId("folder-name-input").fill(name); + await page.getByTestId("folder-create-submit").click(); + await expect(page.getByTestId("folder-list")).toContainText(name); } -test("empty drive shows list containers and a used/total quota", async ({ - page, -}) => { - await loginViaUi(page); - await expect(page.getByTestId("folder-list")).toBeVisible(); - await expect(page.getByTestId("file-list")).toBeVisible(); +test("empty drive has a single browser, explicit empty state, and quota", async ({ page }) => { + // Other specs intentionally retain files/folders; use a distinct owner for + // the empty state instead of depending on global test execution order. + await loginViaUi(page, `empty-${runId}@example.com`); + await expect(page.getByTestId("folder-list")).toHaveCount(1); + await expect(page.getByTestId("file-list")).toHaveCount(1); + await expect(page.getByTestId("drive-empty")).toBeVisible(); await expect(page.getByTestId("quota")).toContainText("/"); + await expect(page.getByTestId("folder-name-input")).toHaveCount(0); }); test("create folder", async ({ page }) => { await loginViaUi(page); - await page.getByTestId("folder-name-input").fill(folderName); - await page.getByTestId("folder-create-submit").click(); - await expect(page.getByTestId("folder-list")).toContainText(folderName); + await createFolder(page, folderName); }); -test("upload file updates file list and quota", async ({ page }) => { +test("upload file updates file list and quota; completed queue remains", async ({ page }) => { await loginViaUi(page); - const quotaBefore = await quotaText(page); - + const before = await quotaText(page); await page.getByTestId("upload-input").setInputFiles(helloPath); - await page.getByTestId("upload-submit").click(); - - await expect(page.getByTestId("upload-progress")).toBeVisible({ - timeout: 15_000, - }); await expect(page.getByTestId("file-list")).toContainText(fileName); - await expect - .poll(() => quotaText(page), { timeout: 15_000 }) - .not.toBe(quotaBefore); + await expect(page.getByTestId("transfer-panel")).toContainText("完了"); + await expect.poll(() => quotaText(page)).not.toBe(before); }); -test("file detail supports navigation and direct reload", async ({ page }, testInfo) => { +test("file detail is SPA navigation and supports direct reload", async ({ page }, testInfo) => { await loginViaUi(page); await page.getByRole("link", { name: fileName, exact: true }).click(); await expect(page).toHaveURL(/\/drive\/files\/[^/]+$/); @@ -59,25 +51,50 @@ test("file detail supports navigation and direct reload", async ({ page }, testI await page.setViewportSize({ width, height: 900 }); await page.screenshot({ path: testInfo.outputPath(`detail-${width}.png`), fullPage: true }); } - await page.getByRole("link", { name: "Drive に戻る" }).click(); + await page.getByRole("link", { name: "← フォルダに戻る" }).click(); await expect(page.getByTestId("file-list")).toContainText(fileName); }); -test("folder-contained file detail survives direct reload", async ({ page }) => { +test("nested folders preserve parent structure, breadcrumbs, view preferences, and direct reload", async ({ page }) => { await loginViaUi(page); - await folderRow(page, folderName).getByRole("button", { name: folderName, exact: true }).click(); + acceptDialogs(page); + await folderRow(page, folderName).getByRole("link", { name: folderName, exact: true }).click(); + const parentUrl = page.url(); + await expect(page.getByTestId("file-list")).not.toContainText(fileName); + await createFolder(page, "Nested"); + await page.getByRole("link", { name: "Nested", exact: true }).click(); + await expect(page.getByTestId("breadcrumbs")).toContainText(`${folderName}Nested`); await page.getByTestId("upload-input").setInputFiles({ name: "inside.txt", mimeType: "text/plain", buffer: Buffer.from("inside folder") }); - await page.getByTestId("upload-submit").click(); - const detailResponse = page.waitForResponse((response) => /\/api\/files\/[^/?]+$/.test(response.url()) && response.request().method() === "GET"); + await expect(page.getByTestId("file-list")).toContainText("inside.txt"); + await page.getByTestId("drive-search").fill("inside"); + await page.getByRole("combobox", { name: "並び順" }).selectOption("size"); + await page.getByTestId("view-grid").click(); + await expect(page.getByTestId("view-grid")).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByTestId("drive-search")).toHaveValue("inside"); + await expect(page.getByRole("combobox", { name: "並び順" })).toHaveValue("size"); + await expect(page.getByTestId("icon-grid")).toHaveText("inside.txt"); await page.getByRole("link", { name: "inside.txt", exact: true }).click(); - expect((await detailResponse).status()).toBe(200); await expect(page.getByTestId("file-detail-page")).toContainText("inside.txt"); + // Queue survives in-app detail navigation. + await expect(page.getByTestId("transfer-panel")).toContainText("inside.txt"); + await page.getByRole("link", { name: "← フォルダに戻る" }).click(); + await expect(page.getByTestId("view-grid")).toHaveAttribute("aria-pressed", "true"); await page.reload(); - await expect(page.getByTestId("file-detail-page")).toContainText("inside.txt"); - await page.getByRole("link", { name: "Drive に戻る" }).click(); - await folderRow(page, folderName).getByRole("button", { name: folderName, exact: true }).click(); + await expect(page.getByTestId("breadcrumbs")).toContainText("Nested"); + await expect(page.getByTestId("file-list")).toContainText("inside.txt"); + await page.getByTestId("breadcrumbs").getByRole("link", { name: "マイドライブ", exact: true }).click(); + await expect(page.getByTestId("folder-list")).not.toContainText("Nested"); + await expect(page.getByTestId("file-list")).not.toContainText("inside.txt"); + // Nonempty deletion is refused without moving children or files to root. + await folderRow(page, folderName).getByTestId(/^delete-folder-/).click(); + await expect(page.getByRole("alert")).toContainText("中身があるフォルダは削除できません"); + await page.goto(parentUrl); + await page.getByRole("link", { name: "Nested", exact: true }).click(); await page.getByTestId("delete-file-inside.txt").click(); await expect(page.getByTestId("file-list")).not.toContainText("inside.txt"); + await page.getByTestId("breadcrumbs").getByRole("link", { name: folderName, exact: true }).click(); + await folderRow(page, "Nested").getByTestId(/^delete-folder-/).click(); + await expect(page.getByTestId("folder-list")).not.toContainText("Nested"); }); test("missing file detail shows a not-found state", async ({ page }) => { @@ -86,24 +103,20 @@ test("missing file detail shows a not-found state", async ({ page }) => { await expect(page.getByTestId("file-detail-page")).toContainText("ファイルが見つかりません"); }); -test("download file serves the presigned URL", async ({ page }) => { +test("download requests an attachment without leaving the app", async ({ page }) => { await loginViaUi(page); - acceptDialogs(page); - + const url = page.url(); + const download = page.waitForEvent("download"); const presignedRequest = page.context().waitForEvent("request", { - predicate: (request) => request.url().includes(MINIO_HOST), - timeout: 15_000, + predicate: (request) => request.url().includes(MINIO_HOST), timeout: 15_000, }); await page.getByTestId(`download-file-${fileName}`).click(); - const presigned = await presignedRequest; - const response = await presigned.response(); expect(new URL(presigned.url()).host).toBe(MINIO_HOST); - expect(response?.status()).toBe(200); - - if (page.url().includes(MINIO_HOST)) { - await page.goBack(); - } + await download; + expect(page.url()).toBe(url); + await expect(page.getByTestId("transfer-panel")).toContainText("ダウンロードを要求済み"); + await expect(page.getByTestId("transfer-panel")).toContainText("ブラウザで確認"); }); test("rename folder", async ({ page }) => { @@ -111,11 +124,10 @@ test("rename folder", async ({ page }) => { const row = folderRow(page, folderName); const renameId = await row.getByTestId(/^rename-folder-/).getAttribute("data-testid"); if (!renameId) throw new Error("rename button has no stable id"); - const folderId = renameId.slice("rename-folder-".length); + const id = renameId.slice("rename-folder-".length); await row.getByTestId(/^rename-folder-/).click(); - await page.getByTestId(`folder-rename-input-${folderId}`).fill(renamedFolderName); - await page.getByTestId(`folder-rename-save-${folderId}`).click(); - + await page.getByTestId(`folder-rename-input-${id}`).fill(renamedFolderName); + await page.getByTestId(`folder-rename-save-${id}`).click(); await expect(page.getByTestId("folder-list")).toContainText(renamedFolderName); await expect(page.getByTestId("folder-list")).not.toContainText(folderName); }); @@ -123,23 +135,15 @@ test("rename folder", async ({ page }) => { test("delete file updates file list and quota", async ({ page }) => { await loginViaUi(page); acceptDialogs(page); - const quotaBefore = await quotaText(page); - + const before = await quotaText(page); await page.getByTestId(`delete-file-${fileName}`).click(); - await expect(page.getByTestId("file-list")).not.toContainText(fileName); - await expect - .poll(() => quotaText(page), { timeout: 15_000 }) - .not.toBe(quotaBefore); + await expect.poll(() => quotaText(page)).not.toBe(before); }); test("delete empty folder", async ({ page }) => { await loginViaUi(page); acceptDialogs(page); - await folderRow(page, renamedFolderName).getByTestId(/^delete-folder-/).click(); - - await expect(page.getByTestId("folder-list")).not.toContainText( - renamedFolderName, - ); + await expect(page.getByTestId("folder-list")).not.toContainText(renamedFolderName); }); diff --git a/apps/booskiff-web/e2e/tests/errors.spec.ts b/apps/booskiff-web/e2e/tests/errors.spec.ts index 92332ed..fdfd32d 100644 --- a/apps/booskiff-web/e2e/tests/errors.spec.ts +++ b/apps/booskiff-web/e2e/tests/errors.spec.ts @@ -15,7 +15,6 @@ test("upload beyond the max file size shows an error", async ({ page }) => { try { writeFileSync(join(dir, "too-big.bin"), Buffer.alloc(101 * 1024 * 1024)); await page.getByTestId("upload-input").setInputFiles(join(dir, "too-big.bin")); - await page.getByTestId("upload-submit").click(); const error = page.getByTestId("file-upload-error"); await expect(error).toBeVisible({ timeout: 15_000 }); diff --git a/apps/booskiff-web/e2e/tests/helpers.ts b/apps/booskiff-web/e2e/tests/helpers.ts index 1447891..1766b6a 100644 --- a/apps/booskiff-web/e2e/tests/helpers.ts +++ b/apps/booskiff-web/e2e/tests/helpers.ts @@ -11,14 +11,14 @@ export const MINIO_HOST = "127.0.0.1:19000"; // previous run's leftovers on a live stack. export const runId = Date.now(); -export async function loginViaUi(page: Page): Promise { +export async function loginViaUi(page: Page, identity = MOCK_IDENTIFIER): Promise { await page.goto("/login"); const identifier = page.getByTestId("login-identifier"); const password = page.getByTestId("login-password"); const submit = page.getByTestId("login-submit"); await expect(identifier).toBeVisible(); await expect(submit).toBeEnabled(); - await identifier.fill(MOCK_IDENTIFIER); + await identifier.fill(identity); await password.fill(MOCK_PASSWORD); // The login form is hydrated client-side; a click fired before hydration is // inert, so retry the click+wait pair until navigation actually happens. @@ -29,15 +29,11 @@ export async function loginViaUi(page: Page): Promise { await expect(page.getByTestId("drive-page")).toBeVisible(); } -// Rows for folder actions are keyed by opaque ids (rename-folder-, -// delete-folder-), so the row is located by its visible name instead. -// `div, li, tr` matches every container holding the name (row, wrappers, the -// list itself); in document order ancestors precede descendants, so .last() -// is the innermost container = the row. +// Folder actions are keyed by opaque IDs; identify the semantic table row. export function folderRow(page: Page, name: string) { return page .getByTestId("folder-list") - .locator("div, li, tr") + .locator("tr") .filter({ hasText: name }) .last(); } diff --git a/apps/booskiff-web/e2e/tests/pagination.spec.ts b/apps/booskiff-web/e2e/tests/pagination.spec.ts new file mode 100644 index 0000000..d2dbfcd --- /dev/null +++ b/apps/booskiff-web/e2e/tests/pagination.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; + +test("root and folder views retain files beyond the first core page", async ({ page, baseURL }) => { + test.setTimeout(120_000); + const origin = new URL(baseURL!).origin; + // A dedicated account keeps this volume fixture out of the narrative tests. + const login = await page.request.post("/auth/login", { + headers: { origin }, + data: { identifier: `pagination-${crypto.randomUUID()}@example.com`, password: "password" }, + }); + expect(login.ok()).toBe(true); + const created = await page.request.post("/api/folders", { + headers: { origin }, data: { name: "Many files", parent_id: null }, + }); + expect(created.status()).toBe(201); + const folder = await created.json() as { id: string }; + for (let index = 0; index < 205; index++) { + const name = `part-${String(index).padStart(3, "0")}.txt`; + const upload = await page.request.post(`/api/files?folder_id=${folder.id}&name=${name}`, { + headers: { origin, "content-type": "text/plain" }, data: Buffer.from("x"), + }); + expect(upload.status(), name).toBe(201); + } + const rootUpload = await page.request.post("/api/files?name=root-only.txt", { + headers: { origin, "content-type": "text/plain" }, data: Buffer.from("root"), + }); + expect(rootUpload.status()).toBe(201); + + const root = await page.request.get("/api/files?root=true"); + expect((await root.json()).items.map((file: { name: string }) => file.name)).toEqual(["root-only.txt"]); + const children = await page.request.get(`/api/files?folder_id=${folder.id}`); + expect((await children.json()).items).toHaveLength(205); + + await page.goto("/drive"); + await expect(page.getByRole("link", { name: "root-only.txt", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "part-000.txt", exact: true })).toHaveCount(0); + await page.goto(`/drive/folders/${folder.id}`); + // The oldest item is on core's second page (stable created_at DESC order). + await expect(page.getByRole("link", { name: "part-000.txt", exact: true })).toBeVisible(); + await expect(page.getByRole("link", { name: "part-204.txt", exact: true })).toBeVisible(); + await expect(page.locator('a[href^="/drive/files/"]')).toHaveCount(205); + await expect(page.getByRole("link", { name: "root-only.txt", exact: true })).toHaveCount(0); +}); diff --git a/apps/booskiff-web/e2e/tests/transfers.spec.ts b/apps/booskiff-web/e2e/tests/transfers.spec.ts new file mode 100644 index 0000000..f962e04 --- /dev/null +++ b/apps/booskiff-web/e2e/tests/transfers.spec.ts @@ -0,0 +1,130 @@ +import { expect, test } from "@playwright/test"; +import { loginViaUi, runId } from "./helpers"; + +const file = (name: string) => ({ name, mimeType: "text/plain", buffer: Buffer.from("queue regression") }); + +test("serial queue freezes folder destination and cannot complete before the server response", async ({ page }) => { + await loginViaUi(page); + const folderResponse = await page.request.post("/api/folders", { data: { name: `Queue-${runId}` }, headers: { origin: new URL(page.url()).origin } }); + expect(folderResponse.ok()).toBe(true); + const folder = await folderResponse.json(); + await page.goto(`/drive/folders/${folder.id}`); + await expect(page.getByTestId("new-folder-button")).toBeEnabled(); + let release!: () => void; + const held = new Promise((resolve) => { release = resolve; }); + const destinations: string[] = []; + await page.route("**/api/files?*", async (route) => { + if (route.request().method() !== "POST") return route.continue(); + destinations.push(new URL(route.request().url()).searchParams.get("folder_id") || "root"); + const response = await route.fetch(); + if (destinations.length === 1) await held; + await route.fulfill({ response }); + }); + await page.getByTestId("upload-input").setInputFiles([file("queued-first.txt"), file("queued-second.txt")]); + // Route interception can hold XHR's upload-progress events as well as its + // response. This browser test proves no premature completion and seriality; + // test/Upload.test.js separately proves 100% bytes -> saving -> completed. + await expect(page.getByTestId("transfer-panel")).toContainText("送信中"); + await expect(page.getByTestId("transfer-panel")).toContainText("待機中"); + await expect(page.getByTestId("transfer-panel").getByText("完了", { exact: true })).toHaveCount(0); + await expect.poll(() => destinations).toEqual([folder.id]); + await page.getByTestId("transfer-toggle").click(); + await expect(page.getByTestId("transfer-toggle")).toContainText("進行中 1・待機 1"); + await page.getByTestId("breadcrumbs").getByRole("link", { name: "マイドライブ", exact: true }).click(); + await expect(page).toHaveURL(/\/drive$/); + release(); + await page.getByTestId("transfer-toggle").click(); + await expect(page.getByTestId("transfer-panel").getByText("完了", { exact: true })).toHaveCount(2); + expect(destinations).toEqual([folder.id, folder.id]); + await expect(page.getByTestId("file-list")).not.toContainText("queued-first.txt"); + await page.getByTestId("transfer-panel").getByRole("link", { name: `保存先: マイドライブ / ${folder.name}`, exact: true }).first().click(); + await expect(page.getByTestId("file-list")).toContainText("queued-first.txt"); + await expect(page.getByTestId("file-list")).toContainText("queued-second.txt"); +}); + +test("a rejected upload allows later files and explicit retry; network result stays uncertain", async ({ page }) => { + await loginViaUi(page); + let rejected = false; + await page.route("**/api/files?*", async (route) => { + if (route.request().method() !== "POST") return route.continue(); + const name = new URL(route.request().url()).searchParams.get("name"); + if (name === "retry.txt" && !rejected) { + rejected = true; + return route.fulfill({ status: 429, json: { error: { code: "too_many_requests", message: "retry later" } } }); + } + if (name === "uncertain.txt") return route.abort("failed"); + return route.continue(); + }); + await page.getByTestId("upload-input").setInputFiles([file("retry.txt"), file("after-rejection.txt"), file("uncertain.txt")]); + await expect(page.getByTestId("file-list")).toContainText("after-rejection.txt"); + await expect(page.getByTestId("transfer-panel")).toContainText("結果未確認"); + await expect(page.getByTestId("transfer-panel").getByRole("button", { name: "再試行", exact: true })).toHaveCount(1); + await page.getByTestId("transfer-panel").getByRole("button", { name: "再試行", exact: true }).click(); + await expect(page.getByTestId("file-list")).toContainText("retry.txt"); + await expect(page.getByTestId("transfer-panel").getByRole("button", { name: "再試行", exact: true })).toHaveCount(0); + await expect(page.getByTestId("transfer-panel")).toContainText("結果未確認"); +}); + +test("drop enqueues multiple files into the current folder", async ({ page }) => { + await loginViaUi(page); + const dataTransfer = await page.evaluateHandle(() => { + const data = new DataTransfer(); + data.items.add(new File(["one"], "drop-one.txt", { type: "text/plain" })); + data.items.add(new File(["two"], "drop-two.txt", { type: "text/plain" })); + return data; + }); + await expect(page.getByTestId("new-folder-button")).toBeEnabled(); + await page.getByTestId("drive-browser").dispatchEvent("drop", { dataTransfer }); + await expect(page.getByTestId("file-list")).toContainText("drop-one.txt"); + await expect(page.getByTestId("file-list")).toContainText("drop-two.txt"); +}); + +test("a delayed pre-save folder snapshot cannot hide a completed upload", async ({ page }) => { + await loginViaUi(page); + const created = await page.request.post("/api/folders", { + data: { name: `Race-${runId}` }, headers: { origin: new URL(page.url()).origin }, + }); + expect(created.ok()).toBe(true); + const folder = await created.json(); + await page.goto(`/drive/folders/${folder.id}`); + await expect(page.getByTestId("new-folder-button")).toBeEnabled(); + let releaseUpload!: () => void; + let releaseSnapshot!: () => void; + let snapshotRead!: () => void; + const uploadGate = new Promise((resolve) => { releaseUpload = resolve; }); + const snapshotGate = new Promise((resolve) => { releaseSnapshot = resolve; }); + const snapshotReady = new Promise((resolve) => { snapshotRead = resolve; }); + let held = false; + await page.route("**/api/files?*", async (route) => { + const request = route.request(); + if (request.method() === "POST") { + await uploadGate; + return route.continue(); + } + if (new URL(request.url()).searchParams.get("folder_id") === folder.id && !held) { + held = true; + const response = await route.fetch(); + snapshotRead(); + await snapshotGate; + return route.fulfill({ response }); + } + return route.continue(); + }); + try { + await page.getByTestId("upload-input").setInputFiles(file("snapshot-race.txt")); + await expect(page.getByTestId("transfer-panel")).toContainText("snapshot-race.txt"); + await page.getByTestId("breadcrumbs").getByRole("link", { name: "マイドライブ", exact: true }).click(); + await page.getByTestId("folder-list").getByRole("link", { name: folder.name, exact: true }).click(); + await snapshotReady; + releaseUpload(); + // Upload completion schedules a newer GET while the old empty response is held. + await expect(page.getByTestId("file-list")).toContainText("snapshot-race.txt"); + const staleResponse = page.waitForResponse((response) => response.url().includes(`folder_id=${folder.id}`)); + releaseSnapshot(); + await staleResponse; + await expect(page.getByTestId("file-list")).toContainText("snapshot-race.txt"); + } finally { + releaseUpload(); + releaseSnapshot(); + } +}); diff --git a/apps/booskiff-web/src/App/Api/Drive.purs b/apps/booskiff-web/src/App/Api/Drive.purs index 4d48d5f..b788c2a 100644 --- a/apps/booskiff-web/src/App/Api/Drive.purs +++ b/apps/booskiff-web/src/App/Api/Drive.purs @@ -28,7 +28,7 @@ unwrapItems (Items xs) = xs -- | GET /api/files, optionally filtered by folder listFiles :: Maybe String -> Aff (Either String (Array FileItem)) -listFiles mFolderId = map (map unwrapItems) $ Api.get $ maybe "/api/files" (\id -> "/api/files?folder_id=" <> id) mFolderId +listFiles mFolderId = map (map unwrapItems) $ Api.get $ maybe "/api/files?root=true" (\id -> "/api/files?folder_id=" <> id) mFolderId fileDetail :: String -> Aff (Either String (Array FileItem)) fileDetail id = do @@ -47,8 +47,8 @@ listFolders :: Aff (Either String (Array Folder)) listFolders = map (map unwrapItems) $ Api.get "/api/folders" -- | POST /api/folders -createFolder :: String -> Aff (Either String Folder) -createFolder name = Api.postJson "/api/folders" { name } +createFolder :: String -> Maybe String -> Aff (Either String Folder) +createFolder name parentId = Api.postJson "/api/folders" { name, parent_id: parentId } -- | PATCH /api/folders/:id renameFolder :: String -> String -> Aff (Either String Folder) @@ -56,7 +56,7 @@ renameFolder id name = Api.patchJson ("/api/folders/" <> id) { name } -- | DELETE /api/folders/:id deleteFolder :: String -> Aff (Either String Unit) -deleteFolder id = Api.delete_ ("/api/folders/" <> id) +deleteFolder id = Api.delete_ ("/api/folders/" <> id <> "?require_empty=true") -- | DELETE /api/files/:id deleteFile :: String -> Aff (Either String Unit) diff --git a/apps/booskiff-web/src/App/Message.purs b/apps/booskiff-web/src/App/Message.purs index 3dd9e6b..a6e7a24 100644 --- a/apps/booskiff-web/src/App/Message.purs +++ b/apps/booskiff-web/src/App/Message.purs @@ -1,6 +1,6 @@ module App.Message where -import App.Model (Billing, FileItem, Folder) +import App.Model (Billing, FileItem, Folder, UploadState) import App.Route (Route) import Data.Either (Either) import Data.Maybe (Maybe) @@ -8,36 +8,45 @@ import Data.Maybe (Maybe) data Message = Navigate Route | UrlChanged (Maybe Route) - -- Authentication (BFF-based) - | CheckSession -- fire GET /auth/session on startup - | SessionChecked (Maybe String) -- Just username = authenticated + | CheckSession + | SessionChecked (Maybe String) | LoginIdentifierChanged String | LoginPasswordChanged String | SubmitLogin | LoginFailed String | Logout + | LogoutConfirmed | LogoutDone | LogoutFailed String - -- Drive data - | LoadDrive -- fetch files + folders + billing - | FilesLoaded (Either String (Array FileItem)) - | FoldersLoaded (Either String (Array Folder)) - | BillingLoaded (Either String Billing) - | SelectFolder (Maybe String) -- folder filter; Nothing shows all files - -- Folder operations + | LoadDrive + | FilesLoaded Int (Either String (Array FileItem)) + | DetailLoaded Int (Either String (Array FileItem)) + | FoldersLoaded Int (Either String (Array Folder)) + | BillingLoaded Int (Either String Billing) + | SelectFolder (Maybe String) + | SetViewMode String + | SearchChanged String + | SortChanged String + | OpenCreateFolder + | CloseFolderForm | FolderNameChanged String | SubmitCreateFolder | StartRenameFolder String | SubmitRenameFolder | SubmitDeleteFolder String - | FolderSaved (Either String Folder) -- create/rename result - | FolderDeleted (Either String String) -- deleted folder id - -- File operations + | DeleteFolderConfirmed String + | FolderSaved Int (Either String Folder) + | FolderDeleted Int (Either String String) | SubmitDeleteFile String - | FileDeleted (Either String String) -- deleted file id - -- Upload (FFI-driven, see Client.Upload) + | DeleteFileConfirmed String + | FileDeleted Int (Either String String) + | ChooseUpload | StartUpload - | UploadProgress { loaded :: Number, total :: Number } - | UploadFinished (Either String FileItem) - -- Common + | UploadsChanged (Array UploadState) + | UploadCommitted FileItem + | RetryUpload String + | DismissTransfer String + | ToggleTransfers + | ClearTransfers + | DownloadRequested String String | DismissError diff --git a/apps/booskiff-web/src/App/Model.purs b/apps/booskiff-web/src/App/Model.purs index 688f85e..8539dbb 100644 --- a/apps/booskiff-web/src/App/Model.purs +++ b/apps/booskiff-web/src/App/Model.purs @@ -94,6 +94,7 @@ instance DecodeJson FileItem where newtype Folder = Folder { id :: String , name :: String + , parentId :: Maybe String , createdAt :: String } @@ -104,6 +105,7 @@ instance EncodeJson Folder where encodeJson (Folder r) = "id" := r.id ~> "name" := r.name + ~> "parentId" := r.parentId ~> "createdAt" := r.createdAt ~> jsonEmptyObject @@ -112,8 +114,9 @@ instance DecodeJson Folder where obj <- decodeJson json vId <- obj .: "id" vName <- obj .: "name" + vParentId <- obj .:? "parentId" vCreatedAt <- obj .: "createdAt" - pure $ Folder { id: vId, name: vName, createdAt: vCreatedAt } + pure $ Folder { id: vId, name: vName, parentId: vParentId, createdAt: vCreatedAt } newtype Billing = Billing { usedBytes :: Number @@ -173,11 +176,19 @@ emptyFolderForm = { name: "", editing: Nothing } -- | Upload progress state (Nothing = no upload in flight) type UploadState = - { name :: String + { id :: String + , name :: String + , destinationId :: String + , destinationName :: String + , status :: String , loaded :: Number , total :: Number + , error :: String + , retryable :: Boolean } +type DownloadRequest = { id :: String, name :: String } + type Model = { route :: Maybe Route , page :: PageModel @@ -185,9 +196,19 @@ type Model = , session :: Maybe SessionInfo , loginForm :: LoginForm , files :: RemoteData (Array FileItem) + , detail :: RemoteData (Array FileItem) , folders :: RemoteData (Array Folder) , billing :: RemoteData Billing - , upload :: Maybe UploadState + , uploads :: Array UploadState + , downloads :: Array DownloadRequest + , transferExpanded :: Boolean + , viewMode :: String + , search :: String + , sort :: String + , dataEpoch :: Int + , filesEpoch :: Int + , billingEpoch :: Int + , folderFormOpen :: Boolean , selectedFolder :: Maybe String , folderForm :: FolderForm , errorMessage :: Maybe String @@ -204,10 +225,20 @@ initialModel mRoute = , session: Nothing , loginForm: emptyLoginForm , files: NotAsked + , detail: NotAsked , folders: NotAsked , billing: NotAsked - , upload: Nothing - , selectedFolder: Nothing + , uploads: [] + , downloads: [] + , transferExpanded: true + , viewMode: "list" + , search: "" + , sort: "name" + , dataEpoch: 0 + , filesEpoch: 0 + , billingEpoch: 0 + , folderFormOpen: false + , selectedFolder: folderForRoute mRoute , folderForm: emptyFolderForm , errorMessage: Nothing , busy: false @@ -217,6 +248,7 @@ pageForRoute :: Route -> PageModel pageForRoute = case _ of Route.Login -> Login Route.Drive -> Drive + Route.FolderDetail _ -> Drive Route.FileDetail fileId -> FileDetail fileId -- | Check if a route requires authentication @@ -224,7 +256,19 @@ isProtectedRoute :: Route -> Boolean isProtectedRoute = case _ of Route.Login -> false Route.Drive -> true + Route.FolderDetail _ -> true Route.FileDetail _ -> true pageForMaybeRoute :: Maybe Route -> PageModel pageForMaybeRoute = maybe NotFound pageForRoute + +folderForRoute :: Maybe Route -> Maybe String +folderForRoute = case _ of + Just (Route.FolderDetail id) -> Just id + _ -> Nothing + +isDriveRoute :: Maybe Route -> Boolean +isDriveRoute = case _ of + Just Route.Drive -> true + Just (Route.FolderDetail _) -> true + _ -> false diff --git a/apps/booskiff-web/src/App/Route.purs b/apps/booskiff-web/src/App/Route.purs index 911e2eb..2793dc7 100644 --- a/apps/booskiff-web/src/App/Route.purs +++ b/apps/booskiff-web/src/App/Route.purs @@ -14,6 +14,7 @@ import Routing.Duplex.Generic (noArgs, sum) data Route = Login | Drive + | FolderDetail String | FileDetail String derive instance Generic Route _ @@ -35,5 +36,6 @@ routeCodec :: RouteDuplex' Route routeCodec = root $ sum { "Login": prefix "login" noArgs , "Drive": prefix "drive" noArgs + , "FolderDetail": prefix "drive" (prefix "folders" segment) , "FileDetail": prefix "drive" (prefix "files" segment) } diff --git a/apps/booskiff-web/src/App/View/Common.js b/apps/booskiff-web/src/App/View/Common.js new file mode 100644 index 0000000..fbce7cf --- /dev/null +++ b/apps/booskiff-web/src/App/View/Common.js @@ -0,0 +1,3 @@ +// Keep modifier-click and auxiliary-click browser behavior for genuine links. +export const shouldFollowInApp = (event) => event.button === 0 && !event.defaultPrevented + && !event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey; diff --git a/apps/booskiff-web/src/App/View/Common.purs b/apps/booskiff-web/src/App/View/Common.purs new file mode 100644 index 0000000..02722d2 --- /dev/null +++ b/apps/booskiff-web/src/App/View/Common.purs @@ -0,0 +1,59 @@ +module App.View.Common where + +import Prelude + +import App.Message (Message(..)) +import App.Route (Route, routeCodec) +import Data.Maybe (Maybe(..)) +import Flame (Html) +import Flame.Html.Attribute as HA +import Flame.Html.Element as HE +import Routing.Duplex (print) +import Web.Event.Event (Event, preventDefault) + +foreign import shouldFollowInApp :: Event -> Boolean + +-- Keep a real DOM node for every conditional SSR slot; empty text disappears +-- during HTML parsing and shifts Flame resumeMount's positional child mapping. +emptySlot :: Html Message +emptySlot = HE.span [ HA.hidden true ] [] + +icon :: String -> Html Message +icon kind = HE.svg + [ HA.viewBox "0 0 24 24" + , HA.fill "none" + , HA.stroke "currentColor" + , HA.strokeWidth "1.6" + , HA.createAttribute "aria-hidden" "true" + , HA.class' ("drive-icon icon-" <> kind) + ] + [ HE.path [ HA.d path, HA.strokeLinecap "round", HA.strokeLinejoin "round" ] [] ] + where + path = case kind of + "folder" -> "M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v10H3Z" + "upload" -> "M12 16V3m-5 5 5-5 5 5M4 15v6h16v-6" + "download" -> "M12 3v13m-5-5 5 5 5-5M4 16v5h16v-5" + "grid" -> "M3 3h7v7H3Zm11 0h7v7h-7ZM3 14h7v7H3Zm11 0h7v7h-7Z" + "list" -> "M8 5h13M8 12h13M8 19h13M3 5h.1M3 12h.1M3 19h.1" + "search" -> "M21 21l-5-5M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0" + "image" -> "M3 3h18v18H3ZM3 17l6-6 4 4 3-3 5 5M8 7h.1" + "check" -> "m4 12 5 5L20 6" + "chevron" -> "m9 5 7 7-7 7" + "close" -> "m6 6 12 12M6 18 18 6" + _ -> "M6 3h8l5 5v13H6ZM14 3v6h5M9 13h7M9 17h7" + +routeLink :: String -> Route -> Array (Html Message) -> Html Message +routeLink className route children = HE.a + [ HA.href (print routeCodec route) + , HA.class' className + , HA.createRawEvent "click" (\event -> if shouldFollowInApp event then preventDefault event $> Just (Navigate route) else pure Nothing) + ] + children + +errorBanner :: Maybe String -> Html Message +errorBanner = case _ of + Nothing -> emptySlot + Just message -> HE.div [ HA.class' "drive-error", HA.createAttribute "role" "alert" ] + [ HE.span_ [ HE.text message ] + , HE.button [ HA.onClick DismissError, HA.class' "icon-button", HA.createAttribute "aria-label" "エラーを閉じる" ] [ icon "close" ] + ] diff --git a/apps/booskiff-web/src/App/View/Drive.purs b/apps/booskiff-web/src/App/View/Drive.purs index 4d18e23..b1e4259 100644 --- a/apps/booskiff-web/src/App/View/Drive.purs +++ b/apps/booskiff-web/src/App/View/Drive.purs @@ -4,328 +4,283 @@ import Prelude import App.Format (humanize) import App.Message (Message(..)) -import App.Route (Route(..), routeCodec) -import App.Model (Billing(..), FileItem(..), Folder(..), Model, RemoteData(..), UploadState) -import Data.Int (floor) +import App.Model (FileItem(..), Folder(..), Model, RemoteData(..)) +import App.Route (Route(..)) +import App.View.Common (emptySlot, errorBanner, icon, routeLink) +import Data.Array (filter, find, length, null, sortBy) import Data.Maybe (Maybe(..), isJust, maybe) -import Data.String as S +import Data.String (Pattern(..), contains, joinWith, take, toLower) import Data.String.Common (trim) import Flame (Html) import Flame.Html.Attribute as HA import Flame.Html.Element as HE -import ShuttlePub.UI.Theme as T -import Routing.Duplex (print) view :: Model -> Html Message -view model = - HE.div - [ HA.key "drive" - , HA.class' "space-y-8" - , HA.createAttribute "data-testid" "drive-page" - ] - [ HE.h1 - [ HA.class' ("text-4xl font-bold tracking-tight " <> T.textHeading) ] - [ HE.text "Drive" ] - , errorBanner model.errorMessage - , quotaView model.billing - , uploadSection model - , folderSection model - , fileSection model - ] - --- | Invisible stand-in for "render nothing" slots. An empty text node never --- | survives HTML parsing, but Flame's hydration matches SSR DOM to virtual --- | children purely by index — a vanished placeholder shifts every following --- | sibling onto the wrong node and later patches then corrupt the DOM --- | (duplicated sections, leaked children, dead handlers; #19/#21). -emptySlot :: Html Message -emptySlot = HE.div [ HA.style { display: "none" } ] [] - -errorBanner :: Maybe String -> Html Message -errorBanner = case _ of - Nothing -> emptySlot - Just msg -> - HE.div - [ HA.class' - ( "px-4 py-3 text-sm " <> T.roundedTheme <> " " <> T.textError <> " border " - <> T.borderTheme - <> " bg-red-500/10" - ) - ] - [ HE.text msg ] - -quotaView :: RemoteData Billing -> Html Message -quotaView = case _ of - Loaded (Billing b) -> - HE.div - [ HA.class' ("text-sm " <> T.textSecondary) - , HA.createAttribute "data-testid" "quota" +view model = HE.div [ HA.key "drive", HA.class' "drive-page", HA.createAttribute "data-testid" "drive-page" ] + [ HE.div [ HA.class' "drive-heading" ] + [ HE.div_ + [ HE.p [ HA.class' "eyebrow" ] [ HE.text "YOUR WORKSPACE" ] + , HE.h1_ [ HE.text (currentFolderName model) ] + ] + , HE.div [ HA.class' "drive-primary-actions" ] + [ HE.button [ HA.class' "drive-button secondary", HA.onClick OpenCreateFolder, HA.disabled (not (folderReady model)), HA.createAttribute "data-testid" "new-folder-button" ] + [ icon "folder", HE.text "新規フォルダ" ] + , HE.button [ HA.class' "drive-button primary", HA.onClick ChooseUpload, HA.disabled (not (folderReady model)), HA.createAttribute "data-testid" "upload-submit" ] + [ icon "upload", HE.text "アップロード" ] + , HE.input + [ HA.type' "file" + , HA.multiple true + , HA.hidden true + , HA.onChange StartUpload + , HA.createAttribute "data-testid" "upload-input" + , HA.createAttribute "data-folder-id" (maybe "" identity model.selectedFolder) + , HA.createAttribute "data-folder-name" (currentFolderPath model) + ] + ] ] - [ HE.text - ( humanize b.usedBytes - <> " / " - <> humanize b.storageQuotaBytes - <> " · max " - <> humanize b.maxFileBytes - <> "/file" - ) + , breadcrumbs model + , errorBanner model.errorMessage + , if model.folderFormOpen then folderForm model else emptySlot + , HE.div [ HA.class' "drive-toolbar" ] + [ HE.label [ HA.class' "drive-search" ] + [ icon "search" + , HE.input + [ HA.type' "search" + , HA.placeholder "このフォルダ内を検索" + , HA.value model.search + , HA.onInput SearchChanged + , HA.createAttribute "aria-label" "このフォルダ内を検索" + , HA.createAttribute "data-testid" "drive-search" + ] + ] + , HE.div [ HA.class' "drive-display-tools" ] + [ HE.label [ HA.class' "drive-sort" ] + [ HE.span [ HA.class' "sr-only" ] [ HE.text "並び順" ] + , HE.select [ HA.value model.sort, HA.onInput SortChanged, HA.createAttribute "aria-label" "並び順" ] + [ HE.option [ HA.value "name" ] [ HE.text "名前順" ] + , HE.option [ HA.value "newest" ] [ HE.text "追加日時順" ] + , HE.option [ HA.value "size" ] [ HE.text "サイズ順" ] + ] + ] + , HE.div [ HA.class' "view-switch", HA.createAttribute "role" "group", HA.createAttribute "aria-label" "表示形式" ] + [ viewButton model "list" "リスト表示", viewButton model "grid" "アイコン表示" ] + ] ] - _ -> - HE.div - [ HA.class' ("text-sm " <> T.textSecondary) - , HA.createAttribute "data-testid" "quota" + , HE.div + ( [ HA.class' "drive-browser" + , HA.createAttribute "data-testid" "drive-browser" + , HA.createAttribute "data-folder-id" (maybe "" identity model.selectedFolder) + , HA.createAttribute "data-folder-name" (currentFolderPath model) + ] <> if folderReady model then [ HA.createAttribute "data-upload-dropzone" "true" ] else [] + ) + [ browserContents model + , HE.div [ HA.class' "drop-overlay", HA.createAttribute "aria-hidden" "true" ] + [ icon "upload", HE.strong_ [ HE.text (currentFolderName model <> " にアップロード") ] ] ] - [ HE.text "-- / --" ] + , HE.p [ HA.class' "drive-hint" ] [ HE.text "ファイルをここにドラッグしてアップロード" ] + ] -uploadSection :: Model -> Html Message -uploadSection model = - HE.div [ HA.class' ("p-6 space-y-4 " <> T.surface) ] - [ HE.h2 - [ HA.class' ("text-lg font-semibold " <> T.textHeading) ] - [ HE.text "アップロード" ] - , HE.div [ HA.class' "flex items-center gap-3" ] - [ HE.input - [ HA.type' "file" - , HA.class' ("block w-full text-sm " <> T.textPrimary) - , HA.createAttribute "data-testid" "upload-input" - , HA.createAttribute "data-folder-id" (maybe "" identity model.selectedFolder) - ] - , HE.button - [ HA.class' - ( "px-4 py-2 text-sm font-medium text-white " <> T.bgAccent <> " " - <> T.hoverBgAccent - <> " " - <> T.roundedTheme - <> if model.busy || isJust model.upload then " opacity-50 cursor-not-allowed" else "" - ) - , HA.onClick StartUpload - , HA.disabled (model.busy || isJust model.upload) - , HA.createAttribute "data-testid" "upload-submit" - ] - [ HE.text "アップロード" ] - ] - , maybe emptySlot progressView model.upload - , maybe emptySlot uploadErrorView (uploadErrorFor model) - ] +folderReady :: Model -> Boolean +folderReady model = case model.folders of + Loaded folders -> maybe true (\id -> isJust (find (\(Folder f) -> f.id == id) folders)) model.selectedFolder + _ -> false + +currentFolderName :: Model -> String +currentFolderName model = case model.selectedFolder of + Nothing -> "マイドライブ" + Just id -> case model.folders of + Loaded folders -> maybe "フォルダが見つかりません" (\(Folder f) -> f.name) (find (\(Folder f) -> f.id == id) folders) + _ -> "フォルダ" + +currentFolderPath :: Model -> String +currentFolderPath model = joinWith " / " + ( [ "マイドライブ" ] <> case model.folders of + Loaded folders -> map (\(Folder f) -> f.name) (folderAncestors folders model.selectedFolder) + _ -> [] + ) + +-- Limit traversal to the available folder count, even if malformed API data +-- contains a cycle; valid trees always reach root earlier. +folderAncestors :: Array Folder -> Maybe String -> Array Folder +folderAncestors folders start = walk (length folders) start + where + walk remaining selected + | remaining <= 0 = [] + | otherwise = case selected >>= \id -> find (\(Folder f) -> f.id == id) folders of + Nothing -> [] + Just folder@(Folder f) -> walk (remaining - 1) f.parentId <> [ folder ] + +breadcrumbs :: Model -> Html Message +breadcrumbs model = HE.nav [ HA.class' "breadcrumbs", HA.createAttribute "aria-label" "パンくず", HA.createAttribute "data-testid" "breadcrumbs" ] + ( [ routeLink "breadcrumb-root" Drive [ HE.text "マイドライブ" ] ] <> case model.folders of + Loaded folders -> map + ( \(Folder f) -> HE.span [ HA.class' "breadcrumb-part" ] + [ icon "chevron", routeLink "" (FolderDetail f.id) [ HE.text f.name ] ] + ) + (folderAncestors folders model.selectedFolder) + _ -> [] + ) -uploadErrorFor :: Model -> Maybe String -uploadErrorFor model = model.errorMessage +viewButton :: Model -> String -> String -> Html Message +viewButton model mode label = HE.button + [ HA.class' ("icon-button" <> if model.viewMode == mode then " selected" else "") + , HA.onClick (SetViewMode mode) + , HA.title label + , HA.createAttribute "aria-label" label + , HA.createAttribute "aria-pressed" (if model.viewMode == mode then "true" else "false") + , HA.createAttribute "data-testid" ("view-" <> mode) + ] + [ icon mode ] -progressView :: UploadState -> Html Message -progressView upload = - let - percent = - if upload.total > 0.0 then floor (upload.loaded * 100.0 / upload.total) - else 0 - in - HE.div - [ HA.class' "w-full" - , HA.createAttribute "data-testid" "upload-progress" +folderForm :: Model -> Html Message +folderForm model = HE.form + [ HA.class' "folder-form" + , HA.onSubmit (if isJust model.folderForm.editing then SubmitRenameFolder else SubmitCreateFolder) + , HA.createAttribute "aria-label" (if isJust model.folderForm.editing then "フォルダ名の変更" else "フォルダの作成") + ] + [ HE.label [ HA.for "folder-name" ] [ HE.text (if isJust model.folderForm.editing then "フォルダ名の変更" else "新しいフォルダ") ] + , HE.input + [ HA.id "folder-name" + , HA.type' "text" + , HA.required true + , HA.autofocus true + , HA.value model.folderForm.name + , HA.onInput FolderNameChanged + , HA.placeholder "フォルダ名" + , HA.maxlength 255 + , HA.createAttribute "data-testid" (maybe "folder-name-input" ("folder-rename-input-" <> _) model.folderForm.editing) ] - [ HE.div [ HA.class' "flex justify-between text-xs mb-1" ] - [ HE.span [ HA.class' T.textSecondary ] [ HE.text upload.name ] - , HE.span [ HA.class' T.textSecondary ] [ HE.text (show percent <> "%") ] - ] - , HE.div - [ HA.class' ("w-full h-2 overflow-hidden rounded-theme " <> T.bgSecondary) ] - [ HE.div - [ HA.class' ("h-full transition-all " <> T.bgAccent) - , HA.style { width: show percent <> "%" } - ] - [] - ] + , HE.button + [ HA.type' "submit" + , HA.class' "drive-button primary" + , HA.disabled (model.busy || trim model.folderForm.name == "") + , HA.createAttribute "data-testid" (maybe "folder-create-submit" ("folder-rename-save-" <> _) model.folderForm.editing) ] + [ HE.text (if model.busy then "保存中…" else if isJust model.folderForm.editing then "保存" else "作成") ] + , HE.button [ HA.type' "button", HA.class' "drive-button secondary", HA.onClick CloseFolderForm, HA.disabled model.busy ] [ HE.text "キャンセル" ] + ] -uploadErrorView :: String -> Html Message -uploadErrorView msg = - HE.div - [ HA.class' - ( "px-4 py-3 text-sm " <> T.roundedTheme <> " " <> T.textError <> " border " - <> T.borderTheme - <> " bg-red-500/10" - ) - , HA.createAttribute "data-testid" "file-upload-error" - ] - [ HE.text msg ] +visibleFolders :: Model -> Array Folder -> Array Folder +visibleFolders model = sortBy order <<< filter (\(Folder f) -> f.parentId == model.selectedFolder && matches model.search f.name) + where + order (Folder a) (Folder b) = if model.sort == "newest" then compare b.createdAt a.createdAt else compare (toLower a.name) (toLower b.name) -folderSection :: Model -> Html Message -folderSection model = - HE.div [ HA.class' ("p-6 space-y-4 " <> T.surface) ] - [ HE.div [ HA.class' "flex items-center justify-between" ] - [ HE.h2 - [ HA.class' ("text-lg font-semibold " <> T.textHeading) ] - [ HE.text "フォルダ" ] - , allButton model.selectedFolder - ] - , createFolderForm model - , HE.ul - [ HA.class' "space-y-2" - , HA.createAttribute "data-testid" "folder-list" - ] - ( case folders of - [] -> [ HE.li [ HA.class' ("text-sm " <> T.textSecondary) ] [ HE.text "フォルダはまだありません" ] ] - _ -> map (folderRow model) folders - ) - ] +visibleFiles :: Model -> Array FileItem -> Array FileItem +visibleFiles model = sortBy order <<< filter (\(FileItem f) -> f.folderId == model.selectedFolder && matches model.search f.name) where - folders = case model.folders of - Loaded fs -> fs - _ -> [] + order (FileItem a) (FileItem b) = case model.sort of + "newest" -> compare b.createdAt a.createdAt + "size" -> compare b.sizeBytes a.sizeBytes + _ -> compare (toLower a.name) (toLower b.name) -allButton :: Maybe String -> Html Message -allButton = case _ of - Nothing -> emptySlot - Just _ -> - HE.button - [ HA.class' ("text-sm " <> T.navLink) - , HA.onClick (SelectFolder Nothing) - ] - [ HE.text "すべて" ] +matches :: String -> String -> Boolean +matches search name = contains (Pattern (toLower (trim search))) (toLower name) -createFolderForm :: Model -> Html Message -createFolderForm model = - HE.div [ HA.class' "flex items-center gap-3" ] - [ HE.input - [ HA.class' (inputClass <> " flex-1") - , HA.type' "text" - , HA.placeholder "新しいフォルダ名" - , HA.value model.folderForm.name - , HA.onInput FolderNameChanged - , HA.createAttribute "data-testid" "folder-name-input" - ] - , HE.button - [ HA.class' - ( "px-4 py-2 text-sm font-medium text-white " <> T.bgAccent <> " " - <> T.hoverBgAccent - <> " " - <> T.roundedTheme - <> if disabled then " opacity-50 cursor-not-allowed" else "" - ) - , HA.onClick SubmitCreateFolder - , HA.disabled disabled - , HA.createAttribute "data-testid" "folder-create-submit" +browserContents :: Model -> Html Message +browserContents model = case model.folders, model.files of + Failed err, _ -> failure err + _, Failed err -> failure err + Loaded folders, Loaded files -> + if not (folderReady model) then stateView "フォルダが見つかりません" "削除されたか、アクセスできないフォルダです。" + else + let + fs = visibleFolders model folders + items = visibleFiles model files + in + HE.div_ + [ if model.viewMode == "grid" then grid fs items else list fs items + , if null fs && null items then + if trim model.search /= "" then stateView "一致する項目がありません" "検索語を変えるか、検索欄を空にしてください。" + else stateView "このフォルダは空です" "ファイルをアップロードするか、新しいフォルダを作成できます。" + else emptySlot + , HE.div [ HA.class' "drive-item-count" ] [ HE.text (show (length fs + length items) <> " 項目") ] + ] + _, _ -> HE.div [ HA.class' "drive-state", HA.createAttribute "role" "status", HA.createAttribute "data-testid" "drive-loading" ] + [ HE.div [ HA.class' "loading-spinner" ] [], HE.p_ [ HE.text "読み込み中…" ] ] + where + failure err = HE.div [ HA.class' "drive-state", HA.createAttribute "role" "alert" ] + [ HE.h2_ [ HE.text "読み込めませんでした" ] + , HE.p_ [ HE.text err ] + , HE.button [ HA.class' "drive-button secondary", HA.onClick LoadDrive, HA.disabled model.busy ] [ HE.text "再読み込み" ] + ] + list folders files = HE.div [ HA.class' "drive-table-scroll" ] + [ HE.table [ HA.class' "drive-table", HA.createAttribute "aria-label" "フォルダとファイル" ] + [ HE.thead_ + [ HE.tr_ + [ HE.th [ HA.scope "col" ] [ HE.text "名前" ] + , HE.th [ HA.scope "col" ] [ HE.text "サイズ" ] + , HE.th [ HA.scope "col", HA.class' "date-column" ] [ HE.text "追加日時" ] + , HE.th [ HA.scope "col" ] [ HE.span [ HA.class' "sr-only" ] [ HE.text "操作" ] ] + ] + ] + , HE.tbody [ HA.createAttribute "data-testid" "folder-list" ] (map folderRow folders) + , HE.tbody [ HA.createAttribute "data-testid" "file-list" ] (map fileRow files) ] - [ HE.text "作成" ] ] - where - disabled = model.busy || isJust model.folderForm.editing || trim model.folderForm.name == "" + grid folders files = HE.div [ HA.class' "drive-grid", HA.createAttribute "data-testid" "icon-grid" ] + [ HE.div [ HA.class' "grid-group", HA.createAttribute "data-testid" "folder-list" ] (map folderTile folders) + , HE.div [ HA.class' "grid-group", HA.createAttribute "data-testid" "file-list" ] (map fileTile files) + ] -folderRow :: Model -> Folder -> Html Message -folderRow model (Folder folder) = - if model.folderForm.editing == Just folder.id then - HE.li - [ HA.class' - ( "flex items-center gap-3 p-3 rounded-theme border " <> T.borderTheme <> " " - <> T.bgSecondary - ) - ] - [ HE.input - [ HA.class' (inputClass <> " flex-1") - , HA.type' "text" - , HA.value model.folderForm.name - , HA.onInput FolderNameChanged - , HA.createAttribute "data-testid" ("folder-rename-input-" <> folder.id) +stateView :: String -> String -> Html Message +stateView title description = HE.div [ HA.class' "drive-state", HA.createAttribute "data-testid" "drive-empty" ] + [ icon "folder", HE.h2_ [ HE.text title ], HE.p_ [ HE.text description ] ] + +folderRow :: Folder -> Html Message +folderRow (Folder folder) = HE.tr [ HA.key folder.id, HA.createAttribute "data-folder-row" folder.id ] + [ HE.td_ [ routeLink "item-name" (FolderDetail folder.id) [ icon "folder", HE.span_ [ HE.text folder.name ] ] ] + , HE.td [ HA.class' "muted" ] [ HE.text "—" ] + , HE.td [ HA.class' "muted date-column" ] [ HE.text (take 10 folder.createdAt) ] + , HE.td [ HA.class' "row-actions" ] + [ HE.button + [ HA.onClick (StartRenameFolder folder.id) + , HA.class' "text-button" + , HA.createAttribute "data-testid" ("rename-folder-" <> folder.id) + , HA.createAttribute "aria-label" (folder.name <> " の名前を変更") ] + [ HE.text "名前変更" ] , HE.button - [ HA.class' - ( "px-3 py-1.5 text-sm text-white " <> T.bgAccent <> " " <> T.roundedTheme - ) - , HA.onClick SubmitRenameFolder - , HA.createAttribute "data-testid" ("folder-rename-save-" <> folder.id) + [ HA.onClick (SubmitDeleteFolder folder.id) + , HA.class' "text-button danger" + , HA.createAttribute "data-testid" ("delete-folder-" <> folder.id) + , HA.createAttribute "aria-label" (folder.name <> " を削除") ] - [ HE.text "保存" ] + [ HE.text "削除" ] ] - else - HE.li - [ HA.class' - ( "flex items-center justify-between p-3 rounded-theme border " <> T.borderTheme - <> " " - <> T.bgSecondary - ) - ] - [ HE.button - [ HA.class' ("text-sm font-medium " <> T.textPrimary <> " hover:text-accent") - , HA.onClick (SelectFolder (Just folder.id)) + ] + +fileRow :: FileItem -> Html Message +fileRow (FileItem file) = HE.tr [ HA.key file.id ] + [ HE.td_ [ routeLink "item-name" (FileDetail file.id) [ fileIcon file.mimeType, HE.span_ [ HE.text file.name ] ] ] + , HE.td [ HA.class' "muted" ] [ HE.text (humanize file.sizeBytes) ] + , HE.td [ HA.class' "muted date-column" ] [ HE.text (take 10 file.createdAt) ] + , HE.td [ HA.class' "row-actions" ] + [ HE.a + [ HA.href ("/api/files/" <> file.id <> "/download") + , HA.target "_blank" + , HA.rel "noopener" + , HA.onClick (DownloadRequested file.id file.name) + , HA.class' "icon-button" + , HA.title "ダウンロード" + , HA.createAttribute "aria-label" (file.name <> " をダウンロード") + , HA.createAttribute "data-testid" ("download-file-" <> file.name) ] - [ HE.text folder.name ] - , HE.div [ HA.class' "flex items-center gap-2" ] - [ HE.button - [ HA.class' ("px-2 py-1 text-xs " <> T.navLink) - , HA.onClick (StartRenameFolder folder.id) - , HA.createAttribute "data-testid" ("rename-folder-" <> folder.id) - ] - [ HE.text "rename" ] - , HE.button - [ HA.class' ("px-2 py-1 text-xs " <> T.navLink) - , HA.onClick (SubmitDeleteFolder folder.id) - , HA.createAttribute "data-testid" ("delete-folder-" <> folder.id) - ] - [ HE.text "delete" ] + [ icon "download" ] + , HE.button + [ HA.onClick (SubmitDeleteFile file.id) + , HA.class' "text-button danger" + , HA.createAttribute "data-testid" ("delete-file-" <> file.name) + , HA.createAttribute "aria-label" (file.name <> " を削除") ] + [ HE.text "削除" ] ] + ] -fileSection :: Model -> Html Message -fileSection model = - HE.div [ HA.class' ("p-6 space-y-4 " <> T.surface) ] - [ HE.h2 - [ HA.class' ("text-lg font-semibold " <> T.textHeading) ] - [ HE.text "ファイル" ] - , HE.ul - [ HA.class' "space-y-2" - , HA.createAttribute "data-testid" "file-list" - ] - ( case files of - [] -> [ HE.li [ HA.class' ("text-sm " <> T.textSecondary) ] [ HE.text "ファイルはまだありません" ] ] - _ -> map fileRow files - ) - ] - where - files = case model.files of - Loaded fs -> fs - _ -> [] +folderTile :: Folder -> Html Message +folderTile (Folder folder) = routeLink "drive-tile" (FolderDetail folder.id) [ icon "folder", HE.span_ [ HE.text folder.name ] ] -fileRow :: FileItem -> Html Message -fileRow (FileItem file) = - HE.li - [ HA.class' - ( "flex items-center justify-between p-3 rounded-theme border " <> T.borderTheme - <> " " - <> T.bgSecondary - ) - ] - [ HE.div [ HA.class' "flex items-center gap-3 min-w-0" ] - [ HE.a - [ HA.class' ("text-sm font-medium truncate " <> T.navLink) - , HA.href (print routeCodec (FileDetail file.id)) - ] - [ HE.text file.name ] - , HE.span - [ HA.class' ("text-xs " <> T.textMuted) ] - [ HE.text (humanize file.sizeBytes) ] - , HE.span - [ HA.class' ("text-xs " <> T.textMuted) ] - [ HE.text (S.take 10 file.createdAt) ] - ] - , HE.div [ HA.class' "flex items-center gap-2 shrink-0" ] - [ HE.a - [ HA.href ("/api/files/" <> file.id <> "/download") - , HA.class' ("px-2 py-1 text-xs " <> T.navLink) - , HA.createAttribute "data-testid" ("download-file-" <> file.name) - ] - [ HE.text "download" ] - , HE.button - [ HA.class' ("px-2 py-1 text-xs " <> T.navLink) - , HA.onClick (SubmitDeleteFile file.id) - , HA.createAttribute "data-testid" ("delete-file-" <> file.name) - ] - [ HE.text "delete" ] - ] - ] +fileTile :: FileItem -> Html Message +fileTile (FileItem file) = routeLink "drive-tile" (FileDetail file.id) [ fileIcon file.mimeType, HE.span_ [ HE.text file.name ] ] -inputClass :: String -inputClass = - "px-3 py-2 text-sm border " <> T.borderTheme <> " " <> T.bgSurface <> " " - <> T.textPrimary - <> " " - <> T.roundedTheme - <> " focus:outline-none focus:ring-2 focus:ring-accent/50" +fileIcon :: String -> Html Message +fileIcon mime = icon (if take 6 mime == "image/" then "image" else "file") diff --git a/apps/booskiff-web/src/App/View/FileDetail.purs b/apps/booskiff-web/src/App/View/FileDetail.purs index 38f82bd..f0b2299 100644 --- a/apps/booskiff-web/src/App/View/FileDetail.purs +++ b/apps/booskiff-web/src/App/View/FileDetail.purs @@ -6,48 +6,45 @@ import App.Format (humanize) import App.Message (Message(..)) import App.Route (Route(..)) import App.Model (FileItem(..), Model, RemoteData(..)) +import App.View.Common (routeLink, icon) import Data.Array (find) -import Data.Maybe (Maybe(..), fromMaybe) +import Data.Maybe (Maybe(..), maybe) import Flame (Html) import Flame.Html.Attribute as HA import Flame.Html.Element as HE -import ShuttlePub.UI.Theme as T -import ShuttlePub.UI.Link (navLink) view :: String -> Model -> Html Message -view fileId model = - HE.div - [ HA.key "file-detail", HA.class' "space-y-8", HA.createAttribute "data-testid" "file-detail-page" ] - [ navLink "/drive" (Navigate Drive) [ HE.text "Drive に戻る" ] - , HE.h1 [ HA.class' ("text-4xl font-bold tracking-tight " <> T.textHeading) ] [ HE.text "ファイル詳細" ] - , HE.div [ HA.class' ("p-6 space-y-4 break-words " <> T.surface) ] - [ case model.files of - NotAsked -> HE.p_ [ HE.text "読み込み待ち" ] - Loading -> HE.p_ [ HE.text "読み込み中…" ] - Failed message -> HE.p [ HA.class' T.textError, HA.createAttribute "role" "alert" ] [ HE.text message ] - Loaded files -> case find (\(FileItem file) -> file.id == fileId) files of - Nothing -> HE.p_ [ HE.text "ファイルが見つかりません" ] - Just (FileItem file) -> - HE.div [ HA.class' "space-y-4" ] - [ HE.h2 [ HA.class' ("text-lg font-semibold " <> T.textHeading) ] [ HE.text file.name ] - , HE.dl [ HA.class' "space-y-3 text-sm" ] - ( map - ( \field -> HE.div_ - [ HE.dt [ HA.class' T.textSecondary ] [ HE.text field.label ] - , HE.dd [ HA.class' T.textPrimary ] [ HE.text field.value ] - ] - ) - [ { label: "ID", value: file.id } - , { label: "種類", value: file.mimeType } - , { label: "サイズ", value: humanize file.sizeBytes } - , { label: "作成日時", value: file.createdAt } - , { label: "フォルダ ID", value: fromMaybe "ルート" file.folderId } - , { label: "公開範囲", value: if file.isPublic then "公開" else "非公開" } - ] - ) - , HE.a - [ HA.href ("/api/files/" <> file.id <> "/download"), HA.class' T.navLink ] - [ HE.text "ダウンロード" ] +view fileId model = HE.div + [ HA.key "file-detail", HA.class' "file-detail", HA.createAttribute "data-testid" "file-detail-page" ] + [ case model.detail of + Loaded files -> case find (\(FileItem f) -> f.id == fileId) files of + Just (FileItem f) -> routeLink "detail-back" (maybe Drive FolderDetail f.folderId) [ HE.text "← フォルダに戻る" ] + _ -> routeLink "detail-back" Drive [ HE.text "← マイドライブに戻る" ] + _ -> routeLink "detail-back" Drive [ HE.text "← マイドライブに戻る" ] + , case model.detail of + NotAsked -> HE.p_ [ HE.text "読み込み中…" ] + Loading -> HE.p [ HA.createAttribute "role" "status" ] [ HE.text "読み込み中…" ] + Failed message -> HE.p [ HA.class' "drive-error", HA.createAttribute "role" "alert" ] [ HE.text message ] + Loaded files -> case find (\(FileItem file) -> file.id == fileId) files of + Nothing -> HE.div [ HA.class' "drive-state" ] [ HE.h1_ [ HE.text "ファイルが見つかりません" ] ] + Just (FileItem file) -> HE.article [ HA.class' "detail-card" ] + [ icon "file" + , HE.h1_ [ HE.text file.name ] + , HE.dl_ + ( map (\field -> HE.div_ [ HE.dt_ [ HE.text field.label ], HE.dd_ [ HE.text field.value ] ]) + [ { label: "種類", value: file.mimeType } + , { label: "サイズ", value: humanize file.sizeBytes } + , { label: "追加日時", value: file.createdAt } + , { label: "公開範囲", value: if file.isPublic then "公開" else "非公開" } ] - ] - ] + ) + , HE.a + [ HA.href ("/api/files/" <> file.id <> "/download") + , HA.target "_blank" + , HA.rel "noopener" + , HA.class' "drive-button primary" + , HA.onClick (DownloadRequested file.id file.name) + ] + [ icon "download", HE.text "ダウンロード" ] + ] + ] diff --git a/apps/booskiff-web/src/App/View/Layout.purs b/apps/booskiff-web/src/App/View/Layout.purs index 539a31d..c328d4d 100644 --- a/apps/booskiff-web/src/App/View/Layout.purs +++ b/apps/booskiff-web/src/App/View/Layout.purs @@ -2,45 +2,60 @@ module App.View.Layout where import Prelude +import App.Format (humanize) import App.Message (Message(..)) -import App.Model (Model) -import App.Route (Route(..)) +import App.Model (Billing(..), Model, PageModel(..), RemoteData(..)) +import App.Route as Route +import App.View.Common (emptySlot, icon, routeLink) +import App.View.Transfers as Transfers +import Data.Array (null) +import Data.Int (floor) import Data.Maybe (Maybe(..)) import Flame (Html) import Flame.Html.Attribute as HA import Flame.Html.Element as HE -import ShuttlePub.UI.Layout as UI -import ShuttlePub.UI.Link as Link -import ShuttlePub.UI.Theme as T page :: Model -> Array (Html Message) -> Html Message -page model content = - UI.shell [ nav model, UI.contentArea content ] - -nav :: Model -> Html Message -nav model = - UI.navBar brand - ( [ HE.li_ [ Link.navLink "/drive" (Navigate Drive) [ HE.text "Drive" ] ] ] - <> authSection model - ) - -brand :: Html Message -brand = - HE.span [ HA.class' ("text-lg font-bold tracking-tight " <> T.textHeading) ] [ HE.text "Booskiff" ] +page model content = HE.main [ HA.id "app", HA.lang "ja", HA.class' ("booskiff-app" <> if model.transferExpanded && (not (null model.uploads) || not (null model.downloads)) then " has-transfers" else "") ] + [ HE.header [ HA.class' "app-header" ] + [ routeLink "app-brand" Route.Drive [ HE.span [ HA.class' "brand-symbol" ] [ icon "folder" ], HE.text "Booskiff" ] + , HE.div [ HA.class' "app-account" ] + ( case model.session of + Just session -> + [ HE.span [ HA.class' "account-name" ] [ HE.text session.username ] + , HE.button [ HA.class' "text-button", HA.onClick Logout, HA.createAttribute "data-testid" "logout-button", HA.disabled model.busy ] [ HE.text "Logout" ] + ] + Nothing -> [ emptySlot ] + ) + ] + , HE.div [ HA.class' (if workspace then "workspace-layout" else "login-layout") ] + [ if workspace then sidebar model else emptySlot + , HE.section [ HA.id "content", HA.class' "workspace-content" ] content + ] + , Transfers.view model + ] + where + workspace = case model.page of + Drive -> true + FileDetail _ -> true + _ -> false -authSection :: Model -> Array (Html Message) -authSection model = - case model.session of - Just session -> - [ HE.li_ [ HE.span [ HA.class' ("px-3 py-2 text-sm " <> T.textMuted) ] [ HE.text session.username ] ] - , HE.li_ - [ HE.button - [ HA.class' T.navLink - , HA.onClick Logout - , HA.createAttribute "data-testid" "logout-button" - ] - [ HE.text "Logout" ] - ] +sidebar :: Model -> Html Message +sidebar model = HE.aside [ HA.class' "drive-sidebar", HA.createAttribute "aria-label" "ドライブナビゲーション" ] + [ HE.div_ + [ HE.p [ HA.class' "eyebrow" ] [ HE.text "LIBRARY" ] + , routeLink "sidebar-link selected" Route.Drive [ icon "folder", HE.text "マイドライブ" ] ] - Nothing -> - [ HE.li_ [ Link.navLink "/login" (Navigate Login) [ HE.text "Login" ] ] ] + , HE.div [ HA.class' "storage-card", HA.createAttribute "data-testid" "quota" ] + ( case model.billing of + Loaded (Billing b) -> + [ HE.span [ HA.class' "storage-label" ] [ HE.text "ストレージ" ] + , HE.div [ HA.class' "storage-track" ] + [ HE.div [ HA.style { width: show (if b.storageQuotaBytes > 0.0 then min 100 (floor (b.usedBytes * 100.0 / b.storageQuotaBytes)) else 0) <> "%" } ] [] ] + , HE.p_ [ HE.text (humanize b.usedBytes <> " / " <> humanize b.storageQuotaBytes) ] + , HE.p [ HA.class' "storage-limit" ] [ HE.text ("1 ファイル最大 " <> humanize b.maxFileBytes) ] + ] + Failed _ -> [ HE.p [ HA.class' "muted" ] [ HE.text "使用容量を取得できません" ] ] + _ -> [ HE.p [ HA.class' "muted" ] [ HE.text "ストレージ — / —" ] ] + ) + ] diff --git a/apps/booskiff-web/src/App/View/Transfers.purs b/apps/booskiff-web/src/App/View/Transfers.purs new file mode 100644 index 0000000..4ad21f5 --- /dev/null +++ b/apps/booskiff-web/src/App/View/Transfers.purs @@ -0,0 +1,99 @@ +module App.View.Transfers where + +import Prelude + +import App.Message (Message(..)) +import App.Model (Model, UploadState) +import App.Route (Route(..)) +import App.View.Common (emptySlot, icon, routeLink) +import Data.Array (filter, length, null) +import Data.Int (floor) +import Flame (Html) +import Flame.Html.Attribute as HA +import Flame.Html.Element as HE + +view :: Model -> Html Message +view model + | null model.uploads && null model.downloads = emptySlot + | otherwise = + HE.aside + [ HA.class' ("transfer-panel" <> if model.transferExpanded then " expanded" else "") + , HA.createAttribute "aria-label" "転送" + , HA.createAttribute "data-testid" "transfer-panel" + ] + [ HE.button + [ HA.class' "transfer-heading" + , HA.onClick ToggleTransfers + , HA.createAttribute "data-testid" "transfer-toggle" + , HA.createAttribute "aria-expanded" (if model.transferExpanded then "true" else "false") + , HA.createAttribute "aria-controls" "transfer-content" + ] + [ icon "upload" + , HE.strong_ [ HE.text summary ] + , HE.span [ HA.class' "transfer-chevron" ] [ HE.text (if model.transferExpanded then "⌄" else "⌃") ] + ] + , HE.div [ HA.class' "transfer-summary sr-only", HA.createAttribute "role" "status" ] [ HE.text summary ] + , HE.div [ HA.id "transfer-content", HA.hidden (not model.transferExpanded) ] + [ HE.div [ HA.class' "transfer-items" ] + (map uploadRow model.uploads <> map downloadRow model.downloads) + , HE.div [ HA.class' "transfer-footer" ] + [ HE.span_ [ HE.text "完了・要求済みの履歴" ] + , HE.button [ HA.class' "text-button", HA.onClick ClearTransfers, HA.createAttribute "data-testid" "clear-transfers" ] [ HE.text "クリア" ] + ] + ] + ] + where + active = length (filter (\u -> u.status == "sending" || u.status == "saving") model.uploads) + queued = length (filter (\u -> u.status == "queued") model.uploads) + issues = length (filter (\u -> u.status == "failed" || u.status == "uncertain") model.uploads) + summary = + if active > 0 || queued > 0 then "進行中 " <> show active <> "・待機 " <> show queued <> (if issues > 0 then "・要確認 " <> show issues else "") + else if issues > 0 then "転送・要確認 " <> show issues <> " 件" + else "転送 " <> show (length model.uploads + length model.downloads) <> " 件" + downloadRow download = HE.div [ HA.class' "transfer-item", HA.key ("download-" <> download.id) ] + [ HE.div [ HA.class' "transfer-item-title" ] [ icon "download", HE.strong_ [ HE.text download.name ] ] + , HE.p [ HA.class' "muted" ] [ HE.text "ダウンロードを要求済み" ] + , HE.p [ HA.class' "transfer-note" ] [ HE.text "進行状況と保存結果はブラウザで確認してください" ] + ] + +uploadRow :: UploadState -> Html Message +uploadRow upload = HE.div [ HA.class' "transfer-item", HA.key upload.id, HA.createAttribute "data-testid" upload.id ] + [ HE.div [ HA.class' "transfer-item-title" ] + [ icon (if upload.status == "completed" then "check" else "upload"), HE.strong_ [ HE.text upload.name ] ] + , HE.div [ HA.class' "transfer-meta" ] + [ routeLink "transfer-destination" (if upload.destinationId == "" then Drive else FolderDetail upload.destinationId) + [ HE.text ("保存先: " <> upload.destinationName) ] + , HE.span [ HA.class' (if upload.status == "completed" then "transfer-success" else "muted") ] [ HE.text (statusLabel upload) ] + ] + , if upload.status == "sending" || upload.status == "saving" then + HE.div + [ HA.class' "transfer-progress" + , HA.createAttribute "data-testid" "upload-progress" + , HA.createAttribute "role" "progressbar" + , HA.createAttribute "aria-label" (upload.name <> " の送信") + , HA.createAttribute "aria-valuemin" "0" + , HA.createAttribute "aria-valuemax" "100" + , HA.createAttribute "aria-valuenow" (show (percent upload)) + , HA.createAttribute "aria-valuetext" (statusLabel upload) + ] + [ HE.div [ HA.style { width: show (percent upload) <> "%" } ] [] ] + else emptySlot + , if upload.error /= "" then HE.p [ HA.class' "transfer-error", HA.createAttribute "data-testid" "file-upload-error" ] [ HE.text upload.error ] else emptySlot + , if upload.retryable then HE.button [ HA.class' "text-button", HA.onClick (RetryUpload upload.id), HA.createAttribute "data-testid" ("retry-" <> upload.id) ] [ HE.text "再試行" ] else emptySlot + , if upload.status == "failed" || upload.status == "uncertain" then + HE.button [ HA.class' "text-button", HA.onClick (DismissTransfer upload.id), HA.createAttribute "aria-label" (upload.name <> " を履歴から削除") ] [ HE.text "履歴から削除" ] + else emptySlot + ] + +percent :: UploadState -> Int +percent upload = if upload.total > 0.0 then min 100 (max 0 (floor (upload.loaded * 100.0 / upload.total))) else 0 + +statusLabel :: UploadState -> String +statusLabel upload = case upload.status of + "queued" -> "待機中" + "sending" -> "送信中 " <> show (percent upload) <> "%" + "saving" -> "保存中…" + "completed" -> "完了" + "failed" -> "失敗" + "uncertain" -> "結果未確認" + _ -> "待機中" diff --git a/apps/booskiff-web/src/Client/Update.purs b/apps/booskiff-web/src/Client/Update.purs index c5e5d8b..7ce75df 100644 --- a/apps/booskiff-web/src/Client/Update.purs +++ b/apps/booskiff-web/src/Client/Update.purs @@ -1,22 +1,18 @@ --- allow: SIZE_OK — single Flame state machine mirroring emumet-web's --- Client/Update.purs layout (one case branch per message, plus the Aff --- helpers producing messages); splitting it would deviate from the --- architecture template the Drive UX task builds on. module Client.Update where import Prelude import App.Api.Auth as Auth -import App.Api.Drive as Drive import App.Api.Auth (LoginResponse(..), SessionResponse(..)) -import App.Format as Format +import App.Api.Drive as Drive import App.Message (Message(..)) -import App.Model (Billing(..), FileItem(..), Folder(..), Model, RemoteData(..), emptyFolderForm, emptyLoginForm, isProtectedRoute, pageForMaybeRoute) +import App.Model (Billing(..), FileItem(..), Folder(..), Model, RemoteData(..), emptyFolderForm, emptyLoginForm, folderForRoute, initialModel, isDriveRoute, isProtectedRoute, pageForMaybeRoute) import App.Route (Route(..), routeCodec) import Client.Upload as Upload import Data.Array (filter, find) import Data.Either (Either(..)) import Data.Maybe (Maybe(..), isJust, isNothing, maybe) +import Data.String (Pattern(..), contains) import Data.String.Common (trim) import Data.Tuple (Tuple(..)) import Effect (Effect) @@ -30,279 +26,238 @@ import Web.HTML (window) import Web.HTML.Location as Location import Web.HTML.Window (location) --- | File input the FFI upload flow reads from (see Client.Upload and the --- | Drive view owned by the follow-up UX task). -uploadInputSelector :: String -uploadInputSelector = "[data-testid='upload-input']" - -maxFileBytes :: RemoteData Billing -> Maybe Number -maxFileBytes = case _ of - Loaded (Billing b) -> Just b.maxFileBytes - _ -> Nothing - mkUpdate :: PushStateInterface -> (Message -> Effect Unit) -> Update Model Message mkUpdate nav sendMessage model = case _ of - Navigate route -> - Tuple model - [ liftEffect (nav.pushState (unsafeToForeign {}) (print routeCodec route)) $> Nothing ] - + Navigate route -> Tuple model [ liftEffect (nav.pushState (unsafeToForeign {}) (print routeCodec route)) $> Nothing ] UrlChanged mRoute -> if not model.isHydrated then noMessages $ model { isHydrated = true } else let - -- Redirect to login when navigating to a protected route while - -- unauthenticated (the initial hydration call is exempt: the server - -- cannot know the session, so CheckSession decides below). - needsAuth = case mRoute of - Just r -> isProtectedRoute r && isNothing model.session - Nothing -> false + needsAuth = maybe false isProtectedRoute mRoute && isNothing model.session effectiveRoute = if needsAuth then Just Login else mRoute + epoch = model.dataEpoch + 1 base = model { route = effectiveRoute , page = pageForMaybeRoute effectiveRoute + , selectedFolder = folderForRoute effectiveRoute , folderForm = emptyFolderForm + , folderFormOpen = false , errorMessage = Nothing , busy = false + , dataEpoch = epoch + , filesEpoch = model.filesEpoch + 1 } in - if needsAuth then - Tuple base - [ liftEffect (nav.replaceState (unsafeToForeign {}) (print routeCodec Login)) $> Nothing ] + if needsAuth then Tuple base [ replace Login ] + else if isDriveRoute mRoute then Tuple (base { files = Loading, folders = Loading }) [ pure $ Just LoadDrive ] else case mRoute of - Just Drive -> - Tuple base [ pure $ Just LoadDrive ] - Just (FileDetail fileId) -> - Tuple (base { files = Loading }) [ loadFileDetailAff fileId ] - Just Login -> - -- Redirect authenticated users away from the login page - if isJust model.session then - Tuple (base { route = Just Drive, page = pageForMaybeRoute (Just Drive) }) - [ liftEffect (nav.replaceState (unsafeToForeign {}) (print routeCodec Drive)) $> Nothing ] - else - noMessages $ base { loginForm = emptyLoginForm } + Just (FileDetail fileId) -> Tuple (base { detail = Loading }) [ loadFileDetailAff epoch fileId ] + Just Login | isJust model.session -> Tuple base [ replace Drive ] + Just Login -> noMessages $ base { loginForm = emptyLoginForm } _ -> noMessages base - -- Authentication (BFF-based) - CheckSession -> - Tuple model [ checkSessionAff ] - - SessionChecked mUsername -> - case mUsername of - Just username -> - let - m = model { session = Just { username }, busy = false } - in - case m.route of - Just Login -> - -- Authenticated user on the login page → Drive - Tuple m - [ liftEffect (nav.replaceState (unsafeToForeign {}) (print routeCodec Drive)) $> Nothing ] - Just Drive -> - -- Session established after hydration → load drive data now - Tuple m [ pure $ Just LoadDrive ] - Just (FileDetail fileId) -> - Tuple (m { files = Loading }) [ loadFileDetailAff fileId ] - _ -> noMessages m - Nothing -> - let - m = model { session = Nothing, busy = false } - in - case m.route of - Just r | isProtectedRoute r -> - -- Unauthenticated on a protected route → login - Tuple - (m { route = Just Login, page = pageForMaybeRoute (Just Login), loginForm = emptyLoginForm }) - [ liftEffect (nav.replaceState (unsafeToForeign {}) (print routeCodec Login)) $> Nothing ] - _ -> noMessages m - - LoginIdentifierChanged identifier -> - noMessages $ model { loginForm = model.loginForm { identifier = identifier } } - - LoginPasswordChanged password -> - noMessages $ model { loginForm = model.loginForm { password = password } } - + CheckSession -> Tuple model [ checkSessionAff ] + SessionChecked mUsername -> case mUsername of + Just username -> + let + m = model { session = Just { username }, busy = false } + setup = liftEffect (Upload.initialize (sendMessage <<< UploadsChanged) (sendMessage <<< UploadCommitted)) $> Nothing + in + if isDriveRoute m.route then Tuple m [ setup, pure $ Just LoadDrive ] + else case m.route of + Just Login -> Tuple m [ setup, replace Drive ] + Just (FileDetail fileId) -> Tuple (m { detail = Loading }) [ setup, loadFileDetailAff m.dataEpoch fileId ] + _ -> Tuple m [ setup ] + Nothing -> + let + needsAuth = maybe false isProtectedRoute model.route + route = if needsAuth then Just Login else model.route + m = (initialModel route) + { isHydrated = true + , dataEpoch = model.dataEpoch + 1 + , filesEpoch = model.filesEpoch + 1 + , billingEpoch = model.billingEpoch + 1 + , loginForm = if model.route == Just Login then model.loginForm else emptyLoginForm + } + in + Tuple m [ liftEffect Upload.reset $> Nothing, if needsAuth then replace Login else pure Nothing ] + LoginIdentifierChanged identifier -> noMessages $ model { loginForm = model.loginForm { identifier = identifier } } + LoginPasswordChanged password -> noMessages $ model { loginForm = model.loginForm { password = password } } SubmitLogin -> let - form = model.loginForm - identifier = trim form.identifier + identifier = trim model.loginForm.identifier in - if identifier == "" || form.password == "" || model.busy then noMessages model - else - Tuple (model { errorMessage = Nothing, busy = true }) - [ submitLoginAff identifier form.password ] - - LoginFailed msg -> - if model.route == Just Login then noMessages $ model { errorMessage = Just msg, busy = false } - else noMessages $ model { busy = false } - - Logout -> - Tuple model [ logoutAff ] - - LogoutDone -> - Tuple - ( model - { session = Nothing - , loginForm = emptyLoginForm - , route = Just Login - , page = pageForMaybeRoute (Just Login) - } - ) - [ liftEffect (nav.replaceState (unsafeToForeign {}) (print routeCodec Login)) $> Nothing ] - - LogoutFailed msg -> - noMessages $ model { errorMessage = Just ("Logout failed: " <> msg), busy = false } + if identifier == "" || model.loginForm.password == "" || model.busy then noMessages model + else Tuple (model { errorMessage = Nothing, busy = true }) [ submitLoginAff identifier model.loginForm.password ] + LoginFailed msg -> noMessages $ model { errorMessage = Just msg, busy = false } + Logout -> Tuple model + [ do + allowed <- liftEffect Upload.confirmLogout + pure $ if allowed then Just LogoutConfirmed else Nothing + ] + LogoutConfirmed -> Tuple (model { uploads = [], downloads = [], dataEpoch = model.dataEpoch + 1, filesEpoch = model.filesEpoch + 1, billingEpoch = model.billingEpoch + 1, busy = true }) + [ liftEffect Upload.reset *> logoutAff ] + LogoutDone -> Tuple ((initialModel (Just Login)) { isHydrated = true, dataEpoch = model.dataEpoch + 1, filesEpoch = model.filesEpoch + 1, billingEpoch = model.billingEpoch + 1 }) [ replace Login ] + LogoutFailed msg -> Tuple (model { errorMessage = Just ("ログアウトに失敗しました: " <> msg), busy = false }) + [ liftEffect (Upload.initialize (sendMessage <<< UploadsChanged) (sendMessage <<< UploadCommitted)) $> Nothing ] - -- Drive data LoadDrive -> - Tuple (model { files = Loading, folders = Loading, billing = Loading }) - [ loadFilesAff model.selectedFolder, foldersAff, billingAff ] - - FilesLoaded result -> - if maybe false isProtectedRoute model.route then noMessages $ case result of - Right files -> model { files = Loaded files } - Left err -> model { files = Failed err, errorMessage = Just err } + if not (isDriveRoute model.route) || isNothing model.session || model.busy then noMessages model + else + let + epoch = model.dataEpoch + 1 + filesEpoch = model.filesEpoch + 1 + billingEpoch = model.billingEpoch + 1 + in + Tuple (model { files = Loading, folders = Loading, dataEpoch = epoch, filesEpoch = filesEpoch, billingEpoch = billingEpoch }) + [ loadFilesAff filesEpoch model.selectedFolder, foldersAff epoch, billingAff billingEpoch ] + FilesLoaded epoch result -> + if epoch == model.filesEpoch && isDriveRoute model.route then noMessages $ model { files = remote result } else noMessages model - - FoldersLoaded result -> - if model.route == Just Drive then noMessages $ case result of - Right folders -> model { folders = Loaded folders } - Left err -> model { folders = Failed err, errorMessage = Just err } + DetailLoaded epoch result -> + if epoch == model.dataEpoch then noMessages $ model { detail = remote result } else noMessages model - - BillingLoaded result -> - if model.route == Just Drive then noMessages $ case result of - Right billing -> model { billing = Loaded billing } - Left err -> model { billing = Failed err, errorMessage = Just err } + FoldersLoaded epoch result -> + if epoch == model.dataEpoch && isDriveRoute model.route then noMessages $ model { folders = remote result } else noMessages model - - SelectFolder mFolderId -> - Tuple (model { selectedFolder = mFolderId, files = Loading }) - [ loadFilesAff mFolderId ] - - -- Folder operations - FolderNameChanged name -> - noMessages $ model { folderForm = model.folderForm { name = name } } - + BillingLoaded epoch result -> + -- Capacity is shared across routes, but every fetch has its own generation: + -- rapid uploads/deletions must not let an older snapshot replace the latest. + if epoch /= model.billingEpoch || isNothing model.session then noMessages model + else Tuple (model { billing = remote result }) + [ case result of + Right (Billing b) -> liftEffect (Upload.configureLimit b.maxFileBytes) $> Nothing + Left _ -> pure Nothing + ] + SelectFolder folder -> Tuple model [ pure $ Just $ Navigate (maybe Drive FolderDetail folder) ] + SetViewMode mode -> noMessages $ model { viewMode = if mode == "grid" then "grid" else "list" } + SearchChanged search -> noMessages $ model { search = search } + SortChanged sort -> noMessages $ model { sort = sort } + + OpenCreateFolder -> noMessages $ model { folderFormOpen = true, folderForm = emptyFolderForm, errorMessage = Nothing } + CloseFolderForm -> noMessages $ model { folderFormOpen = false, folderForm = emptyFolderForm } + FolderNameChanged name -> noMessages $ model { folderForm = model.folderForm { name = name } } SubmitCreateFolder -> let name = trim model.folderForm.name in if name == "" || model.busy then noMessages model - else - Tuple (model { errorMessage = Nothing, busy = true }) - [ createFolderAff name ] - - StartRenameFolder folderId -> + else Tuple (model { busy = true, errorMessage = Nothing }) + [ Just <<< FolderSaved model.dataEpoch <$> Drive.createFolder name model.selectedFolder ] + StartRenameFolder id -> let - form = case model.folders of - Loaded folders | Just (Folder folder) <- find (\(Folder f) -> f.id == folderId) folders -> - { name: folder.name, editing: Just folderId } - _ -> { name: "", editing: Just folderId } + name = case model.folders of + Loaded folders -> maybe "" (\(Folder f) -> f.name) (find (\(Folder f) -> f.id == id) folders) + _ -> "" in - noMessages $ model { folderForm = form, errorMessage = Nothing } - - SubmitRenameFolder -> - case model.folderForm.editing of - Nothing -> noMessages model - Just folderId -> - let - name = trim model.folderForm.name - in - if name == "" || model.busy then noMessages model - else - Tuple (model { errorMessage = Nothing, busy = true }) - [ renameFolderAff folderId name ] - - SubmitDeleteFolder folderId -> + noMessages $ model { folderFormOpen = true, folderForm = { name, editing: Just id }, errorMessage = Nothing } + SubmitRenameFolder -> case model.folderForm.editing of + Just id | trim model.folderForm.name /= "" && not model.busy -> + Tuple (model { busy = true, errorMessage = Nothing }) + [ Just <<< FolderSaved model.dataEpoch <$> Drive.renameFolder id (trim model.folderForm.name) ] + _ -> noMessages model + SubmitDeleteFolder id -> Tuple model [ confirmAff "空のフォルダを削除しますか?この操作は取り消せません。" (DeleteFolderConfirmed id) ] + DeleteFolderConfirmed id -> if model.busy then noMessages model - else - Tuple (model { errorMessage = Nothing, busy = true }) - [ deleteFolderAff folderId ] - - FolderSaved result -> - if model.route == Just Drive then case result of + else Tuple (model { busy = true, errorMessage = Nothing }) + [ Just <<< FolderDeleted model.dataEpoch <<< map (const id) <$> Drive.deleteFolder id ] + FolderSaved epoch result -> + if epoch /= model.dataEpoch then noMessages model + else case result of Right folder@(Folder fr) -> let - wasRenaming = isJust model.folderForm.editing folders = case model.folders of - Loaded fs -> Loaded - ( if wasRenaming then - map (\f@(Folder fr2) -> if fr2.id == fr.id then folder else f) fs - else - fs <> [ folder ] - ) + Loaded fs -> Loaded (filter (\(Folder f) -> f.id /= fr.id) fs <> [ folder ]) st -> st in - noMessages $ model { folders = folders, folderForm = emptyFolderForm, busy = false } + noMessages $ model { folders = folders, folderFormOpen = false, folderForm = emptyFolderForm, busy = false } Left err -> noMessages $ model { errorMessage = Just err, busy = false } - else noMessages $ model { busy = false } - - FolderDeleted result -> - if model.route == Just Drive then case result of - Right folderId -> - let - folders = case model.folders of - Loaded fs -> Loaded (filter (\(Folder f) -> f.id /= folderId) fs) + FolderDeleted epoch result -> + if epoch /= model.dataEpoch then noMessages model + else case result of + Right id -> noMessages $ model + { folders = case model.folders of + Loaded fs -> Loaded (filter (\(Folder f) -> f.id /= id) fs) st -> st - mSelected = if model.selectedFolder == Just folderId then Nothing else model.selectedFolder - m = model { folders = folders, selectedFolder = mSelected, busy = false } - in - -- Files of the deleted folder may be gone or reassigned: refetch. - Tuple (m { files = Loading }) [ loadFilesAff mSelected ] - Left err -> noMessages $ model { errorMessage = Just err, busy = false } - else noMessages $ model { busy = false } - - -- File operations - SubmitDeleteFile fileId -> + , busy = false + } + Left err -> noMessages $ model + { busy = false + , errorMessage = Just + ( if contains (Pattern "409") err || contains (Pattern "not empty") err || contains (Pattern "conflict") err then "中身があるフォルダは削除できません。ファイルと子フォルダを確認してください。" + else err + ) + } + + SubmitDeleteFile id -> Tuple model [ confirmAff "ファイルを削除しますか?この操作は取り消せません。" (DeleteFileConfirmed id) ] + DeleteFileConfirmed id -> if model.busy then noMessages model - else - Tuple (model { errorMessage = Nothing, busy = true }) - [ deleteFileAff fileId ] - - FileDeleted result -> - if model.route == Just Drive then case result of - Right fileId -> + else Tuple (model { busy = true, errorMessage = Nothing }) + [ Just <<< FileDeleted model.dataEpoch <<< map (const id) <$> Drive.deleteFile id ] + FileDeleted epoch result -> + if epoch /= model.dataEpoch then noMessages model + else case result of + Right id -> let - files = case model.files of - Loaded fs -> Loaded (filter (\(FileItem f) -> f.id /= fileId) fs) - st -> st + billingEpoch = model.billingEpoch + 1 in - -- Quota usage changed: refetch billing. - Tuple (model { files = files, busy = false }) [ billingAff ] + Tuple + ( model + { files = case model.files of + Loaded fs -> Loaded (filter (\(FileItem f) -> f.id /= id) fs) + st -> st + , busy = false + , billingEpoch = billingEpoch + } + ) + [ billingAff billingEpoch ] Left err -> noMessages $ model { errorMessage = Just err, busy = false } - else noMessages $ model { busy = false } - -- Upload (FFI-driven) - StartUpload -> - if model.busy then noMessages model + ChooseUpload -> Tuple model [ liftEffect Upload.chooseFiles $> Nothing ] + StartUpload -> Tuple model [ liftEffect Upload.enqueueInput $> Nothing ] + UploadsChanged uploads -> + if isNothing model.session then noMessages model + else noMessages $ model { uploads = uploads } + UploadCommitted file@(FileItem f) -> + if isNothing model.session then noMessages model + else if isDriveRoute model.route && f.folderId == model.selectedFolder && model.files == Loading then + -- The in-flight list may have been read before this save committed. + -- Start a new generation so that older response cannot hide the new file. + -- Keep CRUD's navigation epoch intact so its busy flag can settle. + let + filesEpoch = model.filesEpoch + 1 + billingEpoch = model.billingEpoch + 1 + in + Tuple (model { filesEpoch = filesEpoch, billingEpoch = billingEpoch }) + [ loadFilesAff filesEpoch model.selectedFolder, billingAff billingEpoch ] else - Tuple (model { upload = Just { name: "", loaded: 0.0, total: 0.0 }, errorMessage = Nothing, busy = true }) - [ uploadAff sendMessage ] - - UploadProgress p -> - noMessages $ model { upload = map (\u -> u { loaded = p.loaded, total = p.total }) model.upload } - - UploadFinished result -> - if model.route == Just Drive then let - m = model { upload = Nothing, busy = false } + billingEpoch = model.billingEpoch + 1 + files = case model.files of + Loaded fs | isDriveRoute model.route && f.folderId == model.selectedFolder -> + Loaded (filter (\(FileItem existing) -> existing.id /= f.id) fs <> [ file ]) + st -> st in - case result of - Right file -> - let - files = case model.files of - Loaded fs -> Loaded (fs <> [ file ]) - st -> st - in - Tuple (m { files = files }) [ billingAff ] - Left err -> noMessages $ m { errorMessage = Just (Format.uploadErrorMessage (maxFileBytes model.billing) err) } - else noMessages $ model { upload = Nothing, busy = false } - - -- Common - DismissError -> - noMessages $ model { errorMessage = Nothing } - --- Aff helpers: API calls that produce Messages + Tuple (model { files = files, billingEpoch = billingEpoch }) [ billingAff billingEpoch ] + RetryUpload id -> Tuple model [ liftEffect (Upload.retry id) $> Nothing ] + DismissTransfer id -> Tuple model [ liftEffect (Upload.dismiss id) $> Nothing ] + ToggleTransfers -> noMessages $ model { transferExpanded = not model.transferExpanded } + ClearTransfers -> Tuple (model { downloads = [] }) [ liftEffect Upload.clearFinished $> Nothing ] + DownloadRequested id name -> noMessages $ model + { downloads = filter (\download -> download.id /= id) model.downloads <> [ { id, name } ] } + DismissError -> noMessages $ model { errorMessage = Nothing } + where + replace route = liftEffect (nav.replaceState (unsafeToForeign {}) (print routeCodec route)) $> Nothing + +remote :: forall a. Either String a -> RemoteData a +remote = case _ of + Right value -> Loaded value + Left err -> Failed err + +confirmAff :: String -> Message -> Aff (Maybe Message) +confirmAff prompt message = do + allowed <- liftEffect $ Upload.confirmAction prompt + pure $ if allowed then Just message else Nothing checkSessionAff :: Aff (Maybe Message) checkSessionAff = do @@ -317,8 +272,6 @@ submitLoginAff identifier password = do case result of Right (LoginResponse r) | r.authenticated -> case r.next of Just url -> do - -- Real mode hands off to /auth/oauth/start via full-page navigation: - -- XHR cannot drive the cross-origin Hydra redirect chain. liftEffect (window >>= location >>= Location.assign url) pure Nothing Nothing -> pure $ Just $ SessionChecked (Just r.username) @@ -332,47 +285,14 @@ logoutAff = do Right _ -> LogoutDone Left err -> LogoutFailed err -loadFilesAff :: Maybe String -> Aff (Maybe Message) -loadFilesAff mFolderId = do - result <- Drive.listFiles mFolderId - pure $ Just $ FilesLoaded result - -loadFileDetailAff :: String -> Aff (Maybe Message) -loadFileDetailAff fileId = do - result <- Drive.fileDetail fileId - pure $ Just $ FilesLoaded result - -foldersAff :: Aff (Maybe Message) -foldersAff = do - result <- Drive.listFolders - pure $ Just $ FoldersLoaded result - -billingAff :: Aff (Maybe Message) -billingAff = do - result <- Drive.billingStatus - pure $ Just $ BillingLoaded result - -createFolderAff :: String -> Aff (Maybe Message) -createFolderAff name = do - result <- Drive.createFolder name - pure $ Just $ FolderSaved result - -renameFolderAff :: String -> String -> Aff (Maybe Message) -renameFolderAff folderId name = do - result <- Drive.renameFolder folderId name - pure $ Just $ FolderSaved result +loadFilesAff :: Int -> Maybe String -> Aff (Maybe Message) +loadFilesAff epoch folder = Just <<< FilesLoaded epoch <$> Drive.listFiles folder -deleteFolderAff :: String -> Aff (Maybe Message) -deleteFolderAff folderId = do - result <- Drive.deleteFolder folderId - pure $ Just $ FolderDeleted (const folderId <$> result) +loadFileDetailAff :: Int -> String -> Aff (Maybe Message) +loadFileDetailAff epoch id = Just <<< DetailLoaded epoch <$> Drive.fileDetail id -deleteFileAff :: String -> Aff (Maybe Message) -deleteFileAff fileId = do - result <- Drive.deleteFile fileId - pure $ Just $ FileDeleted (const fileId <$> result) +foldersAff :: Int -> Aff (Maybe Message) +foldersAff epoch = Just <<< FoldersLoaded epoch <$> Drive.listFolders -uploadAff :: (Message -> Effect Unit) -> Aff (Maybe Message) -uploadAff sendMessage = do - result <- Upload.uploadFile uploadInputSelector (\p -> sendMessage (UploadProgress p)) - pure $ Just $ UploadFinished result +billingAff :: Int -> Aff (Maybe Message) +billingAff epoch = Just <<< BillingLoaded epoch <$> Drive.billingStatus diff --git a/apps/booskiff-web/src/Client/Upload.js b/apps/booskiff-web/src/Client/Upload.js index c8c9593..316484b 100644 --- a/apps/booskiff-web/src/Client/Upload.js +++ b/apps/booskiff-web/src/Client/Upload.js @@ -1,45 +1,188 @@ -export const uploadInputImpl = (selector) => (onProgress) => (onSuccess) => (onError) => () => { - const input = document.querySelector(selector); - const file = input && input.files && input.files[0]; - - if (!file) { - onError("No file selected")(); - return; - } - - const folderId = input.getAttribute("data-folder-id"); - - const params = new URLSearchParams(); - params.set("name", file.name); - if (file.type) { - params.set("mime", file.type); - } - if (folderId) { - params.set("folder_id", folderId); - } - +// A session-scoped serial queue. Only serializable snapshots cross the FFI. +// File objects are retained solely while queued, sending, or safely retryable. +let generation = 0; +let sequence = 0; +let jobs = []; +let active = null; +let onChange = null; +let onCommit = null; +let limit = 0; +let removeListeners = null; +const pending = (job) => ["queued", "sending", "saving"].includes(job.status); +const snapshot = (job) => ({ + id: job.id, name: job.name, destinationId: job.destinationId, + destinationName: job.destinationName, status: job.status, + loaded: job.loaded, total: job.total, error: job.error, retryable: job.retryable, +}); +const emit = () => onChange?.(jobs.map(snapshot))(); +const message = (body, status) => { + try { + const error = JSON.parse(body).error; + if (error?.code === "payload_too_large") return "ファイルのサイズ上限を超えています (size limit)"; + if (error?.code === "insufficient_storage") return "ストレージ容量が不足しています"; + if (status === 401) return "認証が切れました。再度ログインしてください"; + return error?.message || `アップロードが拒否されました (HTTP ${status})`; + } catch { return `アップロードが拒否されました (HTTP ${status})`; } +}; +function pump() { + if (active || !onChange) return; + const job = jobs.find((candidate) => candidate.status === "queued"); + if (!job) return; + const epoch = generation; const xhr = new XMLHttpRequest(); - xhr.open("POST", "/api/files?" + params.toString()); + active = xhr; + job.status = "sending"; + emit(); + const params = new URLSearchParams({ name: job.name }); + if (job.file.type) params.set("mime", job.file.type); + if (job.destinationId) params.set("folder_id", job.destinationId); + const current = () => epoch === generation && active === xhr; + const finish = (status, error = "", retryable = false) => { + if (!current()) return; + job.status = status; + job.error = error; + job.retryable = retryable; + if (!retryable) job.file = null; + active = null; + emit(); + pump(); + }; + xhr.open("POST", `/api/files?${params}`); xhr.responseType = "text"; - xhr.setRequestHeader("Content-Type", file.type || "application/octet-stream"); - - xhr.upload.onprogress = (e) => { - if (e.lengthComputable) { - onProgress({ loaded: e.loaded, total: e.total })(); - } + xhr.setRequestHeader("Content-Type", job.file.type || "application/octet-stream"); + xhr.upload.onprogress = (event) => { + if (!current()) return; + job.loaded = event.loaded; + if (event.lengthComputable) job.total = event.total; + if (event.lengthComputable && event.loaded >= event.total) job.status = "saving"; + emit(); + }; + xhr.upload.onload = () => { + if (!current()) return; + job.loaded = job.total; + job.status = "saving"; + emit(); }; - xhr.onload = () => { + if (!current()) return; if (xhr.status >= 200 && xhr.status < 300) { - onSuccess(xhr.responseText || "null")(); + // An unreadable successful response could already have persisted the file. + // Never offer a blind retry for an ambiguous result. + try { + const file = JSON.parse(xhr.responseText); + if (!file || typeof file.id !== "string" || !file.id || typeof file.name !== "string" + || typeof file.mimeType !== "string" || typeof file.sizeBytes !== "number" || !Number.isFinite(file.sizeBytes) + || file.sizeBytes < 0 || typeof file.isPublic !== "boolean" || typeof file.createdAt !== "string" + || (file.folderId != null && typeof file.folderId !== "string")) throw new Error("invalid file"); + job.loaded = job.total; + onCommit?.(xhr.responseText)(); + finish("completed"); + } catch { finish("uncertain", "保存結果を確認できません。保存先を開き、再送前にファイルを確認してください"); } + } else if ([400, 401, 403, 404, 409, 413, 415, 422, 429, 507].includes(xhr.status)) { + finish("failed", message(xhr.responseText, xhr.status), [409, 429, 507].includes(xhr.status)); } else { - onError(xhr.responseText || ("Upload failed: HTTP " + xhr.status))(); + finish("uncertain", "保存結果を確認できません。保存先を開き、再送前にファイルを確認してください"); } }; - - xhr.onerror = () => { - onError("Upload failed: network error")(); + xhr.onerror = () => finish("uncertain", "通信が途切れました。保存先を開き、再送前にファイルを確認してください"); + xhr.onabort = () => finish("uncertain", "送信が中断されました。保存先で結果を確認してください"); + try { xhr.send(job.file); } + catch { finish("failed", "ファイルを送信できませんでした。もう一度選択してください"); } +} +function enqueue(files, destinationId, destinationName) { + if (!onChange) return; + for (const file of files) { + if (!(file instanceof File)) continue; + const tooLarge = limit > 0 && file.size > limit; + jobs.push({ id: `upload-${++sequence}`, name: file.name, file: tooLarge ? null : file, + destinationId, destinationName, loaded: 0, total: file.size, + status: tooLarge ? "failed" : "queued", retryable: false, + error: tooLarge ? "ファイルのサイズ上限を超えています (size limit)" : "" }); + } + emit(); + pump(); +} +export const initializeImpl = (change) => (commit) => () => { + onChange = change; + onCommit = commit; + if (removeListeners) return; + const beforeUnload = (event) => { + if (!jobs.some(pending)) return; + event.preventDefault(); + event.returnValue = ""; + }; + const dragOver = (event) => { + if (!event.dataTransfer?.types.includes("Files")) return; + event.preventDefault(); + const zone = event.target.closest?.("[data-upload-dropzone]"); + document.querySelectorAll(".is-dragging").forEach((node) => { if (node !== zone) node.classList.remove("is-dragging"); }); + zone?.classList.add("is-dragging"); + event.dataTransfer.dropEffect = zone ? "copy" : "none"; }; - - xhr.send(file); + const dragLeave = (event) => { + const zone = event.target.closest?.("[data-upload-dropzone]"); + if (zone && !zone.contains(event.relatedTarget)) zone.classList.remove("is-dragging"); + }; + const drop = (event) => { + if (!event.dataTransfer?.types.includes("Files")) return; + event.preventDefault(); + document.querySelectorAll(".is-dragging").forEach((node) => node.classList.remove("is-dragging")); + const zone = event.target.closest?.("[data-upload-dropzone]"); + if (!zone) return; + // Browsers expose directories inconsistently; do not turn them into empty files. + const files = Array.from(event.dataTransfer.items || []).filter((item) => item.kind === "file") + .filter((item) => !item.webkitGetAsEntry?.()?.isDirectory).map((item) => item.getAsFile()).filter(Boolean); + enqueue(files, zone.dataset.folderId || "", zone.dataset.folderName || "マイドライブ"); + }; + window.addEventListener("beforeunload", beforeUnload); + document.addEventListener("dragover", dragOver); + document.addEventListener("dragleave", dragLeave); + document.addEventListener("drop", drop); + removeListeners = () => { + window.removeEventListener("beforeunload", beforeUnload); + document.removeEventListener("dragover", dragOver); + document.removeEventListener("dragleave", dragLeave); + document.removeEventListener("drop", drop); + }; +}; +export const configureLimit = (bytes) => () => { limit = bytes; }; +export const chooseFiles = () => document.querySelector("[data-testid='upload-input']")?.click(); +export const enqueueInput = () => { + const input = document.querySelector("[data-testid='upload-input']"); + if (!input) return; + enqueue(Array.from(input.files || []), input.dataset.folderId || "", input.dataset.folderName || "マイドライブ"); + input.value = ""; +}; +export const retry = (id) => () => { + const job = jobs.find((candidate) => candidate.id === id); + if (!job?.retryable || !job.file || job.status !== "failed") return; + job.status = "queued"; + job.loaded = 0; + job.error = ""; + job.retryable = false; + emit(); + pump(); +}; +export const clearFinished = () => { + jobs = jobs.filter((job) => pending(job) || job.status === "failed" || job.status === "uncertain"); + emit(); +}; +export const dismiss = (id) => () => { + // History-only removal; queued and active transfers cannot be cancelled here. + jobs = jobs.filter((job) => job.id !== id || pending(job)); + emit(); +}; +export const reset = () => { + generation++; + const xhr = active; + active = null; + jobs = []; + onChange = null; + onCommit = null; + limit = 0; + xhr?.abort(); + removeListeners?.(); + removeListeners = null; }; +export const confirmAction = (text) => () => window.confirm(text); +export const confirmLogout = () => !jobs.some(pending) || window.confirm("転送が進行中です。ログアウトすると待機中の転送は破棄され、送信中の保存結果が確認できなくなることがあります。ログアウトしますか?"); diff --git a/apps/booskiff-web/src/Client/Upload.purs b/apps/booskiff-web/src/Client/Upload.purs index d1a1e0e..a1e7bdb 100644 --- a/apps/booskiff-web/src/Client/Upload.purs +++ b/apps/booskiff-web/src/Client/Upload.purs @@ -2,41 +2,32 @@ module Client.Upload where import Prelude -import Data.Argonaut.Core (Json) -import Data.Argonaut.Decode (class DecodeJson, decodeJson, printJsonDecodeError) +import App.Model (FileItem, UploadState) +import Data.Argonaut.Decode (decodeJson) import Data.Argonaut.Parser (jsonParser) -import Data.Bifunctor (lmap) import Data.Either (Either(..)) import Effect (Effect) -import Effect.Aff (Aff, makeAff) --- | Progress reported by the XHR upload. -type Progress = { loaded :: Number, total :: Number } - --- | FFI: starts uploading the file currently selected in the file input --- | matched by `selector`. The JS side performs the multipart XHR and reports --- | back through plain callbacks: progress events, the success response body, --- | or an error message. See Client/Upload.js. -foreign import uploadInputImpl - :: String - -> (Progress -> Effect Unit) - -> (String -> Effect Unit) +-- Browser File/XHR references deliberately never enter the SSR model. +foreign import initializeImpl + :: (Array UploadState -> Effect Unit) -> (String -> Effect Unit) -> Effect Unit --- | Upload the selected file, reporting progress and finishing with the --- | parsed response JSON (Left = error message). -uploadInput :: String -> (Progress -> Effect Unit) -> (Either String Json -> Effect Unit) -> Effect Unit -uploadInput selector onProgress onDone = - uploadInputImpl selector onProgress - (onDone <<< jsonParser) - (onDone <<< Left) +foreign import configureLimit :: Number -> Effect Unit +foreign import enqueueInput :: Effect Unit +foreign import chooseFiles :: Effect Unit +foreign import retry :: String -> Effect Unit +foreign import dismiss :: String -> Effect Unit +foreign import clearFinished :: Effect Unit +foreign import reset :: Effect Unit +foreign import confirmAction :: String -> Effect Boolean +foreign import confirmLogout :: Effect Boolean --- | Run the upload as an Aff, decoding the response body into `a`. --- | The file input is expected at `selector` (e.g. "[data-testid='upload-input']") --- | and the upload is triggered by the StartUpload message from the update loop. -uploadFile :: forall a. DecodeJson a => String -> (Progress -> Effect Unit) -> Aff (Either String a) -uploadFile selector onProgress = makeAff \resolve -> do - uploadInput selector onProgress - (\result -> resolve (Right (result >>= \json -> lmap printJsonDecodeError (decodeJson json)))) - pure mempty +initialize :: (Array UploadState -> Effect Unit) -> (FileItem -> Effect Unit) -> Effect Unit +initialize onChange onCommit = initializeImpl onChange \body -> + case jsonParser body of + Left _ -> pure unit + Right json -> case decodeJson json of + Left _ -> pure unit + Right file -> onCommit file diff --git a/apps/booskiff-web/src/style.css b/apps/booskiff-web/src/style.css index ce8d736..66a1010 100644 --- a/apps/booskiff-web/src/style.css +++ b/apps/booskiff-web/src/style.css @@ -4,3 +4,194 @@ @source "../../../packages/auth-core/src/**/*.ts"; @source "../../../packages/auth-bun/src/**/*.ts"; @source "../../../packages/auth-bun/*.test.ts"; + +/* The drive shell belongs to this app; shared ShuttlePub layouts stay narrow. */ +.booskiff-app { + --drive-bg: var(--color-bg-primary, #1e1e2e); + --drive-panel: var(--color-bg-surface, #242436); + --drive-line: color-mix(in srgb, currentColor 13%, transparent); + min-height: 100dvh; + color: var(--color-text-primary, #cdd6f4); + background: var(--drive-bg); + font-size: 14px; +} +.booskiff-app button, .booskiff-app a, .booskiff-app input, .booskiff-app select { -webkit-tap-highlight-color: transparent; } +.booskiff-app button, .booskiff-app a { touch-action: manipulation; } +.booskiff-app button:not(:disabled), .booskiff-app select { cursor: pointer; } +.booskiff-app button:disabled { opacity: .45; cursor: not-allowed; } +.booskiff-app :focus-visible { outline: 2px solid var(--color-accent, #cba6f7); outline-offset: 4px; } +.booskiff-app [hidden] { display: none !important; } +.booskiff-app .muted { opacity: .65; } +.app-header { height: 76px; padding: 0 32px; border-bottom: 1px solid var(--drive-line); display: flex; align-items: center; justify-content: space-between; gap: 24px; } +.app-brand { display: flex; align-items: center; gap: 12px; font-size: 19px; font-weight: 750; letter-spacing: -.6px; } +.brand-symbol { width: 34px; height: 34px; border-radius: 11px; background: color-mix(in srgb, var(--color-accent, #cba6f7) 18%, transparent); color: var(--color-accent, #cba6f7); display: grid; place-items: center; } +.app-account { display: flex; align-items: center; gap: 20px; min-width: 0; } +.account-name { opacity: .7; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.workspace-layout { display: grid; grid-template-columns: 224px minmax(0, 1fr); min-height: calc(100dvh - 76px); } +.drive-sidebar { border-right: 1px solid var(--drive-line); padding: 30px 18px; display: flex; flex-direction: column; justify-content: space-between; gap: 48px; } +.eyebrow { font-size: 10px; font-weight: 700; letter-spacing: .14em; opacity: .45; margin: 0 0 16px; } +.drive-sidebar .eyebrow { padding-left: 16px; } +.sidebar-link { display: flex; align-items: center; gap: 11px; padding: 13px 16px; border-radius: 10px; font-weight: 600; } +.sidebar-link.selected { background: color-mix(in srgb, var(--color-accent, #cba6f7) 10%, transparent); color: var(--color-accent, #cba6f7); } +.storage-card { padding: 16px; font-size: 11px; } +.storage-label { font-size: 12px; font-weight: 650; } +.storage-card p { margin: 10px 0 0; } +.storage-limit { opacity: .5; } +.storage-track { height: 4px; background: var(--drive-line); border-radius: 4px; margin-top: 14px; overflow: hidden; } +.storage-track > div { height: 100%; background: var(--color-accent, #cba6f7); } +.workspace-content { min-width: 0; padding: 40px clamp(24px, 4vw, 64px) 150px; } +.login-layout .workspace-content { max-width: 560px; margin: auto; } +.drive-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 16px; } +.drive-heading .eyebrow { margin-bottom: 8px; } +.drive-heading h1 { font-size: 29px; letter-spacing: -.7px; font-weight: 650; word-break: break-word; } +.drive-primary-actions { display: flex; gap: 10px; flex-shrink: 0; } +.drive-button { display: inline-flex; justify-content: center; align-items: center; gap: 9px; border-radius: 9px; padding: 10px 16px; font-size: 12px; font-weight: 650; min-height: 40px; white-space: nowrap; border: 1px solid transparent; } +.drive-button.primary { background: var(--color-accent, #cba6f7); color: var(--color-bg-primary, #1e1e2e); } +.drive-button.secondary { border-color: var(--drive-line); background: color-mix(in srgb, currentColor 3%, transparent); } +.drive-button:hover { filter: brightness(1.1); } +.drive-icon { width: 20px; height: 20px; flex: 0 0 auto; } +.icon-folder { color: #a9bafa; fill: color-mix(in srgb, #a9bafa 10%, transparent); } +.icon-image { color: #a6e3a1; } +.icon-file { color: #89b4fa; } +.breadcrumbs { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; font-size: 12px; margin-bottom: 28px; opacity: .75; } +.breadcrumbs a:hover { text-decoration: underline; } +.breadcrumb-part { display: flex; align-items: center; gap: 6px; min-width: 0; overflow-wrap: anywhere; } +.breadcrumb-part .drive-icon { width: 12px; height: 12px; opacity: .4; } +.drive-toolbar { display: flex; justify-content: space-between; gap: 16px; align-items: center; padding-bottom: 20px; } +.drive-search { display: flex; align-items: center; gap: 10px; min-width: 120px; max-width: 320px; width: 100%; padding: 10px 12px; background: color-mix(in srgb, currentColor 3%, transparent); border: 1px solid var(--drive-line); border-radius: 9px; } +.drive-search .drive-icon { width: 16px; height: 16px; opacity: .5; } +.drive-search input { width: 100%; min-width: 0; background: transparent; outline: none; font-size: 12px; } +.drive-search:focus-within { border-color: var(--color-accent, #cba6f7); } +.drive-display-tools { display: flex; align-items: center; gap: 16px; flex-shrink: 0; } +.drive-sort select { background: var(--drive-bg); border: 0; padding: 8px; font-size: 12px; } +.view-switch { display: flex; gap: 2px; padding: 3px; border: 1px solid var(--drive-line); border-radius: 9px; } +.icon-button { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; border-radius: 6px; } +.icon-button .drive-icon { width: 17px; height: 17px; } +.icon-button.selected { color: var(--color-accent, #cba6f7); background: color-mix(in srgb, var(--color-accent, #cba6f7) 13%, transparent); } +.icon-button:hover, .text-button:hover { background: color-mix(in srgb, currentColor 7%, transparent); } +.drive-browser { position: relative; min-height: 380px; border: 1px solid var(--drive-line); border-radius: 13px; background: color-mix(in srgb, currentColor 1%, transparent); } +.drive-table-scroll { overflow-x: auto; } +.drive-table { width: 100%; border-collapse: collapse; text-align: left; table-layout: fixed; } +.drive-table th { height: 43px; font-weight: 500; font-size: 11px; opacity: .55; } +.drive-table th, .drive-table td { padding: 12px 18px; border-bottom: 1px solid var(--drive-line); } +.drive-table td { font-size: 12px; height: 62px; } +.drive-table th:first-child { width: 48%; } +.drive-table th:nth-child(2) { width: 14%; } +.drive-table th:nth-child(3) { width: 18%; } +.drive-table th:last-child { width: 20%; min-width: 130px; } +.drive-table tr:hover td { background: color-mix(in srgb, currentColor 3%, transparent); } +.item-name { display: flex; align-items: center; gap: 14px; font-weight: 550; min-width: 0; } +.item-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.item-name:hover span { text-decoration: underline; } +.row-actions { white-space: nowrap; text-align: right; } +.text-button { display: inline-flex; align-items: center; justify-content: center; padding: 7px 8px; font-size: 11px; border-radius: 6px; } +.danger { color: #f38ba8; } +.drive-item-count { font-size: 11px; opacity: .4; padding: 14px 18px; } +.drive-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 14px; padding: 24px; } +.grid-group { display: contents; } +.drive-tile { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 22px 12px 18px; min-width: 0; border: 1px solid transparent; border-radius: 10px; text-align: center; } +.drive-tile .drive-icon { width: 52px; height: 52px; } +.drive-tile span { width: 100%; font-size: 12px; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.drive-tile:hover { background: color-mix(in srgb, currentColor 4%, transparent); border-color: var(--drive-line); } +.drive-state { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 310px; text-align: center; padding: 28px; gap: 12px; } +.drive-state > .drive-icon { width: 46px; height: 46px; opacity: .5; margin-bottom: 10px; } +.drive-state h2 { font-size: 16px; font-weight: 600; } +.drive-state p { font-size: 12px; opacity: .6; max-width: 480px; overflow-wrap: anywhere; } +.drive-hint { font-size: 11px; opacity: .4; text-align: center; margin: 20px 0; } +.drop-overlay { display: none; position: absolute; inset: 0; z-index: 3; background: color-mix(in srgb, var(--drive-bg) 95%, transparent); border: 2px dashed var(--color-accent, #cba6f7); border-radius: 12px; align-items: center; justify-content: center; flex-direction: column; gap: 16px; pointer-events: none; } +.drop-overlay .drive-icon { width: 42px; height: 42px; } +.is-dragging > .drop-overlay { display: flex; } +.drive-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; background: #f38ba810; color: #f38ba8; border: 1px solid #f38ba833; padding: 12px 16px; border-radius: 9px; margin-bottom: 20px; overflow-wrap: anywhere; } +.folder-form { display: flex; align-items: center; gap: 10px; padding: 16px; border: 1px solid var(--drive-line); border-radius: 10px; margin-bottom: 20px; } +.folder-form label { font-size: 12px; white-space: nowrap; } +.folder-form input { min-width: 0; flex: 1; padding: 9px 12px; border: 1px solid var(--drive-line); border-radius: 7px; background: var(--drive-bg); } +.transfer-panel { position: fixed; z-index: 20; right: 24px; bottom: 20px; width: 374px; max-width: calc(100% - 32px); background: var(--drive-bg); border: 1px solid var(--drive-line); border-radius: 13px; box-shadow: 0 16px 56px #0005; overflow: hidden; } +.transfer-heading { width: 100%; display: flex; align-items: center; gap: 10px; padding: 16px 18px; text-align: left; background: color-mix(in srgb, currentColor 4%, transparent); } +.transfer-heading strong { flex: 1; font-size: 12px; font-weight: 600; } +.transfer-heading .drive-icon { width: 17px; height: 17px; color: var(--color-accent, #cba6f7); } +.transfer-chevron { opacity: .6; } +.transfer-items { max-height: min(340px, 45dvh); overflow-y: auto; overscroll-behavior: contain; } +.transfer-item { padding: 16px 18px; border-top: 1px solid var(--drive-line); } +.transfer-item-title { display: flex; gap: 10px; align-items: center; margin-bottom: 9px; } +.transfer-item-title .drive-icon { width: 17px; height: 17px; opacity: .8; } +.transfer-item-title strong { font-size: 12px; font-weight: 500; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.transfer-meta { display: flex; gap: 10px; justify-content: space-between; align-items: baseline; font-size: 10px; } +.transfer-destination { opacity: .65; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.transfer-destination:hover { text-decoration: underline; } +.transfer-meta > span { white-space: nowrap; } +.transfer-success { color: #a6e3a1; } +.transfer-progress { margin-top: 10px; height: 3px; border-radius: 3px; background: var(--drive-line); overflow: hidden; } +.transfer-progress > div { height: 100%; background: var(--color-accent, #cba6f7); transition: width .15s ease; } +.transfer-error { margin-top: 10px; color: #f38ba8; font-size: 11px; line-height: 1.6; overflow-wrap: anywhere; } +.transfer-item > .muted { font-size: 11px; } +.transfer-note { font-size: 10px; opacity: .55; line-height: 1.6; margin-top: 4px; } +.transfer-footer { border-top: 1px solid var(--drive-line); display: flex; align-items: center; justify-content: space-between; padding: 8px 16px; font-size: 10px; } +.transfer-footer > span { opacity: .5; } +.file-detail { max-width: 800px; margin: auto; } +.detail-back { display: inline-block; margin-bottom: 24px; font-size: 12px; opacity: .7; } +.detail-card { padding: 36px; border: 1px solid var(--drive-line); border-radius: 15px; } +.detail-card > .drive-icon { width: 64px; height: 64px; margin-bottom: 24px; } +.detail-card h1 { font-size: 24px; font-weight: 650; overflow-wrap: anywhere; } +.detail-card dl { margin: 28px 0; } +.detail-card dl > div { display: grid; grid-template-columns: 120px 1fr; gap: 20px; border-bottom: 1px solid var(--drive-line); padding: 14px 0; font-size: 13px; } +.detail-card dt { opacity: .5; } +.detail-card dd { overflow-wrap: anywhere; } +.loading-spinner { width: 22px; height: 22px; border: 2px solid var(--drive-line); border-top-color: var(--color-accent, #cba6f7); border-radius: 50%; animation: drive-spin 1s linear infinite; } +@keyframes drive-spin { to { transform: rotate(360deg); } } +@media (min-width: 1700px) { .workspace-content { padding-right: max(64px, calc((100vw - 1540px) / 2)); } } +@media (max-width: 1100px) { + .workspace-layout { grid-template-columns: 190px minmax(0, 1fr); } + .drive-table th:first-child { width: 42%; } + .drive-table th:nth-child(3) { width: 20%; } + .drive-table th:last-child { width: 24%; } + .drive-table th, .drive-table td { padding-left: 12px; padding-right: 12px; } + .drive-heading { align-items: flex-start; flex-direction: column; gap: 16px; } +} +@media (max-width: 760px) { + .app-header { height: 64px; padding: 0 18px; gap: 16px; } + .app-brand { font-size: 17px; gap: 8px; } + .app-account { gap: 6px; max-width: 48%; font-size: 11px; } + .workspace-layout { display: flex; flex-direction: column; min-height: calc(100dvh - 64px); } + .drive-sidebar { flex-direction: row; border-right: 0; border-bottom: 1px solid var(--drive-line); padding: 10px 16px; gap: 12px; align-items: center; } + .drive-sidebar .eyebrow, .storage-label, .storage-track, .storage-limit { display: none; } + .sidebar-link { padding: 8px 10px; font-size: 12px; gap: 7px; } + .sidebar-link .drive-icon { width: 17px; height: 17px; } + .storage-card { padding: 0; font-size: 10px; } + .storage-card p { margin: 0; } + .workspace-content { padding: 26px 16px 170px; } + .drive-heading h1 { font-size: 25px; } + .drive-primary-actions { width: 100%; } + .drive-primary-actions .drive-button { flex: 1; padding: 10px 8px; } + .drive-heading .eyebrow { font-size: 9px; } + .breadcrumbs { margin-bottom: 22px; } + .drive-toolbar { flex-wrap: wrap; gap: 12px; } + .drive-search { max-width: none; flex: 1 0 100%; } + .drive-display-tools { gap: 12px; width: 100%; justify-content: space-between; } + .drive-sort select { padding: 8px; min-height: 44px; font-size: 12px; } + .view-switch .icon-button { width: 44px; height: 44px; } + .date-column { display: none; } + .drive-table th:first-child { width: 48%; } + .drive-table th:nth-child(2) { width: 19%; } + .drive-table th:last-child { width: 33%; } + .drive-table th, .drive-table td { padding: 10px; font-size: 11px; } + .row-actions .text-button { padding: 8px 4px; font-size: 10px; } + .row-actions .icon-button { width: 28px; } + .item-name { gap: 8px; } + .item-name .drive-icon { width: 17px; height: 17px; } + .folder-form { flex-wrap: wrap; } + .folder-form label { width: 100%; } + .folder-form input { flex-basis: calc(100% - 160px); } + .folder-form .drive-button { padding: 8px; } + .drive-grid { grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: 4px; padding: 12px; } + .drive-tile { padding: 18px 10px; } + .transfer-panel { right: 16px; bottom: 16px; } + .transfer-items { max-height: 30dvh; } + .detail-card { padding: 24px; } + .detail-card dl > div { grid-template-columns: 80px 1fr; gap: 10px; font-size: 12px; } +} +@media (prefers-reduced-motion: reduce) { + .loading-spinner { animation: none; } + .transfer-progress > div { transition: none; } +} +.has-transfers .workspace-content { padding-bottom: 460px; } +@media (max-width: 760px) { .has-transfers .workspace-content { padding-bottom: calc(30dvh + 130px); } } diff --git a/apps/booskiff-web/test/Test/Main.purs b/apps/booskiff-web/test/Test/Main.purs index 2979a81..a2fc560 100644 --- a/apps/booskiff-web/test/Test/Main.purs +++ b/apps/booskiff-web/test/Test/Main.purs @@ -8,6 +8,7 @@ import App.Route (Route(..), routeCodec) import App.Model as Model import App.Message (Message(..)) import App.View as View +import App.View.Drive (folderAncestors, visibleFiles, visibleFolders) import Client.Update (mkUpdate) import Data.Argonaut.Decode (class DecodeJson, decodeJson) import Data.Argonaut.Encode (class EncodeJson, encodeJson) @@ -37,10 +38,109 @@ main = do testFileDetailLoaded testFileDetailLink testFolderRenameId + testHierarchy + testGrid + testRequestIsolation + testQueueFolderIsolation + testBillingRequestIsolation + +testBillingRequestIsolation :: Effect Unit +testBillingRequestIsolation = do + let capacity usedBytes = Billing { usedBytes, storageQuotaBytes: 10240.0, maxFileBytes: 1024.0, rateLimitRpm: 60 } + let first = update sampleModel (UploadCommitted sampleFile) + let second = update first (UploadCommitted sampleFile) + assertEqual "each upload creates a distinct capacity request" second.billingEpoch (first.billingEpoch + 1) + let latest = update second (BillingLoaded second.billingEpoch (Right (capacity 200.0))) + assertEqual "late capacity response cannot roll back usage" + (update latest (BillingLoaded first.billingEpoch (Right (capacity 100.0)))).billing + (Loaded (capacity 200.0)) + let deleted = update latest (FileDeleted latest.dataEpoch (Right "f1")) + assertEqual "deletion also advances capacity request generation" deleted.billingEpoch (latest.billingEpoch + 1) + let afterDelete = update deleted (BillingLoaded deleted.billingEpoch (Right (capacity 50.0))) + assertEqual "the latest deletion may legitimately lower usage" afterDelete.billing (Loaded (capacity 50.0)) + assertEqual "old upload result cannot overwrite a newer deletion" + (update afterDelete (BillingLoaded second.billingEpoch (Right (capacity 200.0)))).billing + (Loaded (capacity 50.0)) + let reloaded = update afterDelete LoadDrive + assertEqual "drive reload advances capacity request generation" reloaded.billingEpoch (afterDelete.billingEpoch + 1) + assertEqual "late failure cannot overwrite a newer successful capacity result" + (update afterDelete (BillingLoaded first.billingEpoch (Left "late network failure"))).billing + afterDelete.billing + let loading = update (sampleModel { files = Loading }) (UploadCommitted sampleFile) + assertEqual "upload during list loading advances capacity generation" loading.billingEpoch (sampleModel.billingEpoch + 1) + let logout = update reloaded LogoutDone + assertEqual "logout invalidates outstanding capacity requests" + (update logout (BillingLoaded reloaded.billingEpoch (Right (capacity 200.0)))).billing + NotAsked + +update :: Model -> Message -> Model +update model message = fst (mkUpdate nav (const (pure unit)) model message) + where + nav = + { pushState: \_ _ -> pure unit + , replaceState: \_ _ -> pure unit + , locationState: pure { state: unsafeToForeign {}, path: "", pathname: "", search: "", hash: "" } + , listen: \_ -> pure (pure unit) + } + +testRequestIsolation :: Effect Unit +testRequestIsolation = do + let model = sampleModel { dataEpoch = 4, filesEpoch = 4 } + assertEqual "stale folder request cannot overwrite current files" + (update model (FilesLoaded 3 (Right []))).files + model.files + assertEqual "list request cannot replace dedicated detail state" + (update (model { route = Just (FileDetail "f1") }) (FilesLoaded 4 (Right []))).detail + model.detail + let logout = update model LogoutDone + assertEqual "logout erases protected data" logout.files NotAsked + assertEqual "logout ignores earlier detail response" + (update logout (DetailLoaded 4 (Right [ sampleFile ]))).detail + NotAsked + let saving = model { busy = true, files = Failed "network" } + assertEqual "manual reload cannot invalidate a pending mutation" (update saving LoadDrive).dataEpoch saving.dataEpoch + assertEqual "mutation can still settle after ignored reload" + (update (update saving LoadDrive) (FolderSaved 4 (Right sampleFolder))).busy + false + +testQueueFolderIsolation :: Effect Unit +testQueueFolderIsolation = do + let inFolder = sampleModel { selectedFolder = Just "fold1", route = Just (FolderDetail "fold1"), files = Loaded [] } + assertEqual "completed root upload never enters another folder" + (update inFolder (UploadCommitted sampleFile)).files + (Loaded []) + let root = sampleModel { files = Loaded [] } + assertEqual "completed matching upload updates current folder" + (update root (UploadCommitted sampleFile)).files + (Loaded [ sampleFile ]) + let loading = sampleModel { files = Loading, filesEpoch = 9, dataEpoch = 4, busy = true } + let saved = update loading (UploadCommitted sampleFile) + assertEqual "upload supersedes any earlier list snapshot" saved.filesEpoch 10 + assertEqual "upload does not invalidate concurrent CRUD" saved.dataEpoch 4 + assertEqual "late pre-upload snapshot is ignored" (update saved (FilesLoaded 9 (Right []))).files Loading + assertEqual "fresh post-upload list can settle" (update saved (FilesLoaded 10 (Right [ sampleFile ]))).files (Loaded [ sampleFile ]) + assertEqual "concurrent folder mutation still clears busy" (update saved (FolderSaved 4 (Right sampleFolder))).busy false + +testHierarchy :: Effect Unit +testHierarchy = do + let child = Folder { id: "child", name: "Nested", parentId: Just "fold1", createdAt: "2026-09-01" } + assertEqual "root shows only direct folders" (visibleFolders sampleModel [ sampleFolder, child ]) [ sampleFolder ] + assertEqual "nested folder contents are scoped" (visibleFolders (sampleModel { selectedFolder = Just "fold1" }) [ sampleFolder, child ]) [ child ] + assertEqual "breadcrumb contains the complete parent chain" (folderAncestors [ sampleFolder, child ] (Just "child")) [ sampleFolder, child ] + assertEqual "folder cannot leak root files" (visibleFiles (sampleModel { selectedFolder = Just "fold1" }) [ sampleFile ]) [] + assertEqual "view switch preserves filter" (update (sampleModel { search = "hello", sort = "size" }) (SetViewMode "grid")).search "hello" + +testGrid :: Effect Unit +testGrid = do + html <- render (View.view (sampleModel { viewMode = "grid", folderFormOpen = false })) + assertEqual "grid links to file" (contains (Pattern "href=\"/drive/files/f1\"") html) true + assertEqual "grid excludes item size" (contains (Pattern "123 B") html) false + assertEqual "grid excludes item date" (contains (Pattern "2026-08-30") html) false + assertEqual "list excludes location column" (contains (Pattern ">保存場所<") html) false testFolderRenameId :: Effect Unit testFolderRenameId = do - html <- render (View.view sampleModel) + html <- render (View.view (sampleModel { folderFormOpen = true, folderForm = { name: "New", editing: Just "fold1" } })) assertEqual "rename input uses stable folder id" (contains (Pattern "folder-rename-input-fold1") html) true assertEqual "rename save uses stable folder id" (contains (Pattern "folder-rename-save-fold1") html) true @@ -51,20 +151,13 @@ testFileDetailLink = do testFileDetailLoaded :: Effect Unit testFileDetailLoaded = do - let - nav = - { pushState: \_ _ -> pure unit - , replaceState: \_ _ -> pure unit - , locationState: pure { state: unsafeToForeign {}, path: "", pathname: "", search: "", hash: "" } - , listen: \_ -> pure (pure unit) - } - let model = (initialModel (Just (FileDetail "f1"))) { isHydrated = true, files = Loading } - let result = fst (mkUpdate nav (const (pure unit)) model (FilesLoaded (Right [ sampleFile ]))) - assertEqual "file result is accepted on detail route" result.files (Loaded [ sampleFile ]) + let model = (initialModel (Just (FileDetail "f1"))) { isHydrated = true, detail = Loading } + let result = update model (DetailLoaded 0 (Right [ sampleFile ])) + assertEqual "file result is accepted on detail route" result.detail (Loaded [ sampleFile ]) testFileDetailView :: Effect Unit testFileDetailView = do - let model = sampleModel { route = Just (FileDetail "f1"), page = Model.FileDetail "f1" } + let model = sampleModel { route = Just (FileDetail "f1"), page = Model.FileDetail "f1", detail = Loaded [ sampleFile ] } html <- render (View.view model) assertEqual "detail view renders selected file MIME type" (contains (Pattern "text/plain") html) true assertEqual "detail view is separate from list" (contains (Pattern "data-testid=\"file-detail-page\"") html) true @@ -74,13 +167,11 @@ assertEqual label actual expected = unless (actual == expected) (throw (label <> " — expected " <> show expected <> ", got " <> show actual)) --- | encode ∘ decode must be the identity. roundTrip :: forall a. Eq a => Show a => EncodeJson a => DecodeJson a => String -> a -> Effect Unit roundTrip label value = case hush (decodeJson (encodeJson value)) of Just decoded -> assertEqual label decoded value Nothing -> throw (label <> " — decode failed") --- | Parse a JSON string and decode it, discarding error details. decodeBody :: forall a. DecodeJson a => String -> Maybe a decodeBody s = hush (jsonParser s) >>= (hush <<< decodeJson) @@ -88,15 +179,11 @@ testRouteCodec :: Effect Unit testRouteCodec = do assertEqual "parse /login" (hush (parse routeCodec "/login")) (Just Login) assertEqual "parse /drive" (hush (parse routeCodec "/drive")) (Just Drive) - assertEqual "file detail path resolves when id exists in URL" - (map show (hush (parse routeCodec "/drive/files/f1"))) - (Just "(FileDetail \"f1\")") + assertEqual "file detail path" (hush (parse routeCodec "/drive/files/f1")) (Just (FileDetail "f1")) + assertEqual "nested folder path" (hush (parse routeCodec "/drive/folders/fold1")) (Just (FolderDetail "fold1")) assertEqual "parse / is unknown" (hush (parse routeCodec "/")) Nothing assertEqual "parse unknown path" (hush (parse routeCodec "/nope")) Nothing - assertEqual "print Login" (print routeCodec Login) "/login" - assertEqual "print Drive" (print routeCodec Drive) "/drive" - assertEqual "round-trip Login" (hush (parse routeCodec (print routeCodec Login))) (Just Login) - assertEqual "round-trip Drive" (hush (parse routeCodec (print routeCodec Drive))) (Just Drive) + assertEqual "folder route roundtrip" (hush (parse routeCodec (print routeCodec (FolderDetail "fold1")))) (Just (FolderDetail "fold1")) sampleFile :: FileItem sampleFile = FileItem @@ -110,15 +197,10 @@ sampleFile = FileItem } sampleFolder :: Folder -sampleFolder = Folder { id: "fold1", name: "Docs", createdAt: "2026-08-30T00:00:00Z" } +sampleFolder = Folder { id: "fold1", name: "Docs", parentId: Nothing, createdAt: "2026-08-30T00:00:00Z" } sampleBilling :: Billing -sampleBilling = Billing - { usedBytes: 2048.0 - , storageQuotaBytes: 10240.0 - , maxFileBytes: 1024.0 - , rateLimitRpm: 60 - } +sampleBilling = Billing { usedBytes: 2048.0, storageQuotaBytes: 10240.0, maxFileBytes: 1024.0, rateLimitRpm: 60 } sampleModel :: Model sampleModel = (initialModel (Just Drive)) @@ -127,11 +209,7 @@ sampleModel = (initialModel (Just Drive)) , files = Loaded [ sampleFile ] , folders = Loaded [ sampleFolder ] , billing = Loaded sampleBilling - , upload = Just { name: "hello.txt", loaded: 50.0, total: 123.0 } - , selectedFolder = Just "fold1" - , folderForm = { name: "New", editing: Just "fold1" } - , errorMessage = Just "boom" - , busy = true + , uploads = [ { id: "upload-1", name: "hello.txt", destinationId: "", destinationName: "マイドライブ", status: "sending", loaded: 50.0, total: 123.0, error: "", retryable: false } ] } testModelRoundTrip :: Effect Unit @@ -139,6 +217,7 @@ testModelRoundTrip = do roundTrip "initial model (unknown route)" (initialModel Nothing) roundTrip "initial model (login)" (initialModel (Just Login)) roundTrip "initial model (drive)" (initialModel (Just Drive)) + roundTrip "initial model (folder)" (initialModel (Just (FolderDetail "fold1"))) roundTrip "populated model" sampleModel testRemoteDataRoundTrip :: Effect Unit @@ -150,28 +229,16 @@ testRemoteDataRoundTrip = do testFileItemDecode :: Effect Unit testFileItemDecode = do - assertEqual "decode file (folderId null)" - (decodeBody fileJsonNull) - (Just sampleFile) - assertEqual "decode file (folderId absent)" - (decodeBody fileJsonAbsent) - (Just sampleFile) + assertEqual "decode file (folderId null)" (decodeBody fileJsonNull) (Just sampleFile) + assertEqual "decode file (folderId absent)" (decodeBody fileJsonAbsent) (Just sampleFile) where - fileJsonNull = - "{\"id\":\"f1\",\"name\":\"hello.txt\",\"mimeType\":\"text/plain\",\"sizeBytes\":123,\"folderId\":null,\"isPublic\":false,\"createdAt\":\"2026-08-30T00:00:00Z\"}" + fileJsonNull = "{\"id\":\"f1\",\"name\":\"hello.txt\",\"mimeType\":\"text/plain\",\"sizeBytes\":123,\"folderId\":null,\"isPublic\":false,\"createdAt\":\"2026-08-30T00:00:00Z\"}" + fileJsonAbsent = "{\"id\":\"f1\",\"name\":\"hello.txt\",\"mimeType\":\"text/plain\",\"sizeBytes\":123,\"isPublic\":false,\"createdAt\":\"2026-08-30T00:00:00Z\"}" - fileJsonAbsent = - "{\"id\":\"f1\",\"name\":\"hello.txt\",\"mimeType\":\"text/plain\",\"sizeBytes\":123,\"isPublic\":false,\"createdAt\":\"2026-08-30T00:00:00Z\"}" - --- | The camelCase decode used by App.Api.Drive for GET /api/billing. testBillingCamelCaseDecode :: Effect Unit -testBillingCamelCaseDecode = - assertEqual "billing camelCase decode" - (decodeBody billingJson) - (Just sampleBilling) - where - billingJson = - "{\"usedBytes\":2048,\"storageQuotaBytes\":10240,\"maxFileBytes\":1024,\"rateLimitRpm\":60}" +testBillingCamelCaseDecode = assertEqual "billing camelCase decode" + (decodeBody "{\"usedBytes\":2048,\"storageQuotaBytes\":10240,\"maxFileBytes\":1024,\"rateLimitRpm\":60}") + (Just sampleBilling) testHumanize :: Effect Unit testHumanize = do @@ -182,13 +249,9 @@ testHumanize = do testUploadErrorMessage :: Effect Unit testUploadErrorMessage = do - let - sizeMsg = uploadErrorMessage (Just (100.0 * 1024.0 * 1024.0)) - "{\"error\":{\"code\":\"payload_too_large\",\"message\":\"too big\"}}" - assertEqual "payload_too_large mentions size limit" (mentionsSizeLimit sizeMsg) true + assertEqual "payload_too_large mentions size limit" + (mentionsSizeLimit (uploadErrorMessage (Just 104857600.0) "{\"error\":{\"code\":\"payload_too_large\"}}")) + true assertEqual "insufficient_storage maps to storage message" (uploadErrorMessage (Just 1024.0) "{\"error\":{\"code\":\"insufficient_storage\"}}") "ストレージ容量が不足しています (size limit: 1.0 KiB total)" - assertEqual "unauthorized maps to login message" - (uploadErrorMessage Nothing "{\"error\":{\"code\":\"unauthorized\"}}") - "認証が必要です。再度ログインしてください。" diff --git a/apps/booskiff-web/test/Upload.test.js b/apps/booskiff-web/test/Upload.test.js new file mode 100644 index 0000000..3de3f2d --- /dev/null +++ b/apps/booskiff-web/test/Upload.test.js @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { initializeImpl, configureLimit, enqueueInput, retry, reset, clearFinished, dismiss } from "../src/Client/Upload.js"; + +// Check behavior at the transport boundary, independently of rendering. +const original = { window: globalThis.window, document: globalThis.document, xhr: globalThis.XMLHttpRequest }; +let input, changes, commits, listeners; +class Xhr { + static requests = []; + upload = {}; + status = 0; + responseText = ""; + open(method, url) { this.url = url; this.method = method; } + setRequestHeader() {} + send(file) { this.file = file; Xhr.requests.push(this); } + abort() { this.onabort?.(); } + progress(loaded, total) { this.upload.onprogress?.({ loaded, total, lengthComputable: true }); } + complete(status, body) { this.status = status; this.responseText = JSON.stringify(body); this.onload?.(); } +} +const persisted = (id, name, folderId = null) => ({ id, name, folderId, mimeType: "text/plain", sizeBytes: 4, createdAt: "2026-10-04", isPublic: false }); +const enqueue = (names, folder = "", destination = "マイドライブ") => { + input.files = names.map((name) => new File(["test"], name, { type: "text/plain" })); + input.dataset = { folderId: folder, folderName: destination }; + enqueueInput(); +}; +beforeEach(() => { + reset(); + Xhr.requests = []; + changes = []; + commits = []; + listeners = new Map(); + input = { files: [], dataset: {}, value: "", click() {} }; + const eventTarget = { addEventListener: (name, fn) => listeners.set(name, fn), removeEventListener: (name) => listeners.delete(name) }; + globalThis.window = { ...eventTarget, confirm: () => true }; + globalThis.document = { ...eventTarget, querySelector: () => input, querySelectorAll: () => [] }; + globalThis.XMLHttpRequest = Xhr; + initializeImpl((value) => () => { changes = value; })((body) => () => { commits.push(JSON.parse(body)); })(); +}); +afterEach(() => { + reset(); + globalThis.window = original.window; + globalThis.document = original.document; + globalThis.XMLHttpRequest = original.xhr; +}); + +describe("session upload queue", () => { + test("serial upload freezes destinations and waits for persistence", () => { + enqueue(["first.txt", "second.txt"], "folder-a", "マイドライブ / A"); + expect(Xhr.requests).toHaveLength(1); + expect(changes.map((job) => job.status)).toEqual(["sending", "queued"]); + enqueue(["third.txt"], "folder-b", "マイドライブ / B"); + Xhr.requests[0].progress(4, 4); + expect(changes[0].status).toBe("saving"); + expect(commits).toHaveLength(0); + expect(Xhr.requests).toHaveLength(1); + Xhr.requests[0].complete(201, persisted("one", "first.txt", "folder-a")); + expect(changes[0].status).toBe("completed"); + expect(Xhr.requests).toHaveLength(2); + expect(Xhr.requests[1].url).toContain("folder_id=folder-a"); + Xhr.requests[1].complete(201, persisted("two", "second.txt", "folder-a")); + expect(Xhr.requests[2].url).toContain("folder_id=folder-b"); + expect(commits).toHaveLength(2); + }); + test("definite rejection continues the queue and permits explicit safe retry", () => { + enqueue(["first.txt", "second.txt"]); + Xhr.requests[0].complete(429, { error: { code: "too_many_requests" } }); + expect(changes[0].retryable).toBe(true); + expect(Xhr.requests).toHaveLength(2); + const first = changes[0].id; + retry(first)(); + expect(Xhr.requests).toHaveLength(2); + Xhr.requests[1].complete(201, persisted("two", "second.txt")); + expect(Xhr.requests).toHaveLength(3); + expect(changes.find((job) => job.id === first).status).toBe("sending"); + }); + test("network loss and server errors are uncertain, with no blind retry", () => { + enqueue(["first.txt", "second.txt"]); + Xhr.requests[0].onerror(); + expect(changes[0].status).toBe("uncertain"); + expect(changes[0].retryable).toBe(false); + retry(changes[0].id)(); + expect(Xhr.requests).toHaveLength(2); + Xhr.requests[1].complete(500, { error: { code: "internal" } }); + expect(changes[1].status).toBe("uncertain"); + }); + test("invalid successful response never becomes completed", () => { + enqueue(["first.txt"]); + Xhr.requests[0].complete(201, { id: "partial", name: "first.txt" }); + expect(changes[0].status).toBe("uncertain"); + expect(commits).toHaveLength(0); + }); + test("logout discards references and isolates stale callbacks from the next session", () => { + enqueue(["first.txt", "second.txt"]); + const oldRequest = Xhr.requests[0]; + reset(); + initializeImpl((value) => () => { changes = value; })((body) => () => { commits.push(body); })(); + enqueue(["new-session.txt"], "new-folder"); + oldRequest.complete(201, persisted("old", "first.txt")); + expect(changes.map((job) => job.name)).toEqual(["new-session.txt"]); + expect(commits).toHaveLength(0); + expect(Xhr.requests).toHaveLength(2); + }); + test("oversize is rejected before transport; leaving warns only during live uploads", () => { + configureLimit(3)(); + enqueue(["large.txt"]); + expect(Xhr.requests).toHaveLength(0); + expect(changes[0].status).toBe("failed"); + let warned = false; + listeners.get("beforeunload")({ preventDefault() { warned = true; } }); + expect(warned).toBe(false); + configureLimit(100)(); + enqueue(["small.txt"]); + listeners.get("beforeunload")({ preventDefault() { warned = true; } }); + expect(warned).toBe(true); + Xhr.requests[0].complete(201, persisted("small", "small.txt")); + clearFinished(); + expect(changes.map((job) => job.name)).toEqual(["large.txt"]); + }); + test("history removal releases rejected files but cannot cancel active transfers", () => { + enqueue(["active.txt", "waiting.txt"]); + const activeId = changes[0].id; + dismiss(activeId)(); + expect(changes).toHaveLength(2); + Xhr.requests[0].complete(429, { error: { code: "too_many_requests" } }); + dismiss(activeId)(); + retry(activeId)(); + expect(changes.map((job) => job.name)).toEqual(["waiting.txt"]); + expect(Xhr.requests).toHaveLength(2); + }); +}); diff --git a/apps/ui-catalog/visual/screens/booskiff-stub.ts b/apps/ui-catalog/visual/screens/booskiff-stub.ts index f6b0980..16c0baf 100644 --- a/apps/ui-catalog/visual/screens/booskiff-stub.ts +++ b/apps/ui-catalog/visual/screens/booskiff-stub.ts @@ -10,7 +10,7 @@ function wireFile(file: FileItem) { } function wireFolder(folder: Folder) { - return { id: folder.id, name: folder.name, created_at: folder.createdAt }; + return { id: folder.id, name: folder.name, parent_id: folder.parentId, created_at: folder.createdAt }; } /** Test-only HTTP boundary: app SSR, auth, REST mapping and hydration remain real. */ @@ -34,10 +34,24 @@ export function startBooskiffStub() { if (request.method === "GET") { if (url.pathname === "/v1/files") { const folderId = url.searchParams.get("folder_id"); - const selected = folderId === null ? files : files.filter((file) => file.folderId === folderId); - return Response.json({ items: selected.map(wireFile) }); + const root = url.searchParams.get("root") === "true"; + let selected = root ? files.filter((file) => file.folderId === null) + : folderId === null ? files : files.filter((file) => file.folderId === folderId); + const beforeAt = url.searchParams.get("before_created_at"); + const beforeId = url.searchParams.get("before_id"); + if (beforeAt !== null && beforeId !== null) selected = selected.filter((file) => + file.createdAt < beforeAt || (file.createdAt === beforeAt && file.id < beforeId)); + const limit = Number(url.searchParams.get("limit") ?? 50); + const offset = Number(url.searchParams.get("offset") ?? 0); + return Response.json({ items: selected.slice(offset, offset + limit).map(wireFile) }); + } + if (url.pathname === "/v1/folders") { + const parentId = url.searchParams.get("parent_id"); + const selected = url.searchParams.get("root") === "true" + ? folders.filter((folder) => folder.parentId === null) + : parentId === null ? folders : folders.filter((folder) => folder.parentId === parentId); + return Response.json({ items: selected.map(wireFolder) }); } - if (url.pathname === "/v1/folders") return Response.json({ items: folders.map(wireFolder) }); if (url.pathname === "/v1/billing/status") return Response.json({ used_bytes: state === "empty" ? 0 : BILLING.usedBytes, storage_quota_bytes: BILLING.storageQuotaBytes, diff --git a/apps/ui-catalog/visual/screens/capture.ts b/apps/ui-catalog/visual/screens/capture.ts index e6f6b02..35fe9f5 100644 --- a/apps/ui-catalog/visual/screens/capture.ts +++ b/apps/ui-catalog/visual/screens/capture.ts @@ -8,6 +8,7 @@ import { startApp, type AppServer } from "./servers.ts"; async function prepareScreen(page: Page, item: ScreenCase) { const content = page.locator("#content"); + let expectedPath: string = item.path; // App chrome/copy changes should produce a visual diff, not make the current // driver incompatible with main. Wait for structure and seeded data instead. await expect(content.locator("h1")).toBeVisible(); @@ -22,24 +23,31 @@ async function prepareScreen(page: Page, item: ScreenCase) { if (item.id.startsWith("drive-")) { await expect(page.getByTestId("quota")).toContainText(/\d/); if (item.data === "empty") { - // Existing views keep one placeholder row for each empty collection. - await expect(page.getByTestId("file-list").locator("li")).toHaveCount(1); - await expect(page.getByTestId("folder-list").locator("li")).toHaveCount(1); - await expect(page.getByTestId("file-list").locator("a")).toHaveCount(0); - await expect(page.getByTestId("folder-list").locator("button")).toHaveCount(0); + // The main baseline has separate empty lists; the hierarchical browser + // has one empty folder view. Do not require either layout's row tags. + await expect(content.locator('a[href^="/drive/files/"]')).toHaveCount(0); + await expect(content.getByText("Projects", { exact: true })).toHaveCount(0); } else { - await expect(page.getByTestId("file-list").locator("li")).toHaveCount(3); - await expect(page.getByTestId("folder-list").locator("li")).toHaveCount(2); + await expect(content.getByText("Projects", { exact: true })).toBeVisible(); + await expect(content.getByText("Archive", { exact: true })).toBeVisible(); + await expect(content.getByText("README.txt", { exact: true })).toBeVisible(); } if (item.id === "drive-folder") { - await page.getByRole("button", { name: "Projects", exact: true }).click(); + const folderLink = content.locator('a[href="/drive/folders/folder-projects"]'); + if (await folderLink.count()) { + expectedPath = "/drive/folders/folder-projects"; + await folderLink.click(); + } else { + await page.getByRole("button", { name: "Projects", exact: true }).click(); + } await expect(page.getByTestId("upload-input")).toHaveAttribute("data-folder-id", "folder-projects"); - await expect(page.getByTestId("file-list").locator("li")).toHaveCount(2); - await expect(page.getByTestId("file-list")).not.toContainText("README.txt"); + await expect(content.getByText("design-notes.pdf", { exact: true })).toBeVisible(); + await expect(content.getByText("project-cover.png", { exact: true })).toBeVisible(); + await expect(content.getByText("README.txt", { exact: true })).toHaveCount(0); } } else if (item.id === "file-detail") { await expect(page.getByRole("heading", { name: "design-notes.pdf", exact: true })).toBeVisible(); - await expect(page.getByTestId("file-detail-page")).toContainText("2026-01-10T09:30:00Z"); + await expect(page.getByTestId("file-detail-page").locator('a[href="/api/files/file-design/download"]')).toBeVisible(); } } else { if (item.id === "accounts-populated") { @@ -69,6 +77,7 @@ async function prepareScreen(page: Page, item: ScreenCase) { await expect(content.locator("button[data-shape-option]")).toHaveCount(2); } } + return expectedPath; } async function captureScreen(browser: Browser, server: AppServer, item: ScreenCase, output: string) { @@ -144,8 +153,8 @@ async function captureScreen(browser: Browser, server: AppServer, item: ScreenCa await expect(page.locator("main#app")).toBeVisible(); await expect(page.locator("html")).toHaveAttribute("data-color", "catppuccin-mocha"); await expect(page.locator("html")).toHaveAttribute("data-shape", "rounded"); - await prepareScreen(page, item); - await expect(page).toHaveURL(new URL(item.path, server.url).href); + const expectedPath = await prepareScreen(page, item); + await expect(page).toHaveURL(new URL(expectedPath, server.url).href); await page.waitForLoadState("networkidle"); await page.evaluate(async () => { await document.fonts.ready; diff --git a/apps/ui-catalog/visual/screens/fixtures.ts b/apps/ui-catalog/visual/screens/fixtures.ts index f0afc2a..73bec50 100644 --- a/apps/ui-catalog/visual/screens/fixtures.ts +++ b/apps/ui-catalog/visual/screens/fixtures.ts @@ -9,8 +9,8 @@ export const FILES = [ ] as const satisfies readonly FileItem[]; export const FOLDERS = [ - { id: "folder-projects", name: "Projects", createdAt: "2026-01-08T10:00:00Z" }, - { id: "folder-archive", name: "Archive", createdAt: "2026-01-09T10:00:00Z" }, + { id: "folder-projects", name: "Projects", parentId: null, createdAt: "2026-01-08T10:00:00Z" }, + { id: "folder-archive", name: "Archive", parentId: null, createdAt: "2026-01-09T10:00:00Z" }, ] as const satisfies readonly Folder[]; export const BILLING = { diff --git a/apps/ui-catalog/visual/screens/screens.test.ts b/apps/ui-catalog/visual/screens/screens.test.ts index 19427cd..7e1c43a 100644 --- a/apps/ui-catalog/visual/screens/screens.test.ts +++ b/apps/ui-catalog/visual/screens/screens.test.ts @@ -35,8 +35,11 @@ test("HTTP fixtures satisfy the production Booskiff REST client mappings and fol expect(await client.listFiles()).toEqual(FILES); expect(await client.listFiles("folder-projects")).toEqual(FILES.slice(0, 2)); expect(await client.listFiles("folder-archive")).toEqual([]); + expect(await client.listFiles(undefined, true)).toEqual([FILES[2]]); expect(await client.getFile("file-design")).toEqual(FILES[0]); expect(await client.listFolders()).toEqual(FOLDERS); + expect(await client.listFolders(undefined, true)).toEqual(FOLDERS); + expect(await client.listFolders("folder-projects")).toEqual([]); expect(await client.getFolder("folder-projects")).toEqual(FOLDERS[0]); expect(await client.billingStatus()).toEqual(BILLING); expect(stub.unexpectedRequests).toEqual([]); From 1fa2930700cdd5684ce83002d4642633bb43a6af Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 4 Oct 2026 12:37:52 +0900 Subject: [PATCH 2/3] Align drive upload controls with the approved mockup --- apps/booskiff-web/README.md | 4 + apps/booskiff-web/e2e/real/tests/auth.spec.ts | 1 + .../e2e/tests/drive-resume.spec.ts | 3 +- apps/booskiff-web/e2e/tests/drive.spec.ts | 1 + apps/booskiff-web/e2e/tests/transfers.spec.ts | 11 +- apps/booskiff-web/src/App/Message.purs | 1 + apps/booskiff-web/src/App/Model.purs | 2 + apps/booskiff-web/src/App/View/Common.purs | 2 + apps/booskiff-web/src/App/View/Drive.purs | 111 +++++++++++------- apps/booskiff-web/src/Client/Update.purs | 8 +- apps/booskiff-web/src/style.css | 26 ++-- 11 files changed, 110 insertions(+), 60 deletions(-) diff --git a/apps/booskiff-web/README.md b/apps/booskiff-web/README.md index 216c7bf..a5ac693 100644 --- a/apps/booskiff-web/README.md +++ b/apps/booskiff-web/README.md @@ -36,6 +36,10 @@ Mock ログインのパスワードは `password` です(メールアドレス リスト表示は名前・サイズ・追加日時、アイコン表示はアイコンと名前だけを表示します。 表示形式を切り替えても、現在地・名前の絞り込み・並び順は維持されます。 +見出し右側の「新規」から、一覧の上部に「この場所に追加」を開き、 +アップロードやフォルダー作成を行います。ファイルは上部の案内から一覧までの +領域全体にドロップでき、現在表示しているフォルダーへ追加されます。 + 複数ファイルのアップロードは右下の転送パネルで順番に処理します。 保存先はファイルを追加した時点のフォルダーに固定されます。 送信が 100% になった後も、サーバーが保存成功を返すまでは保存処理中です。 diff --git a/apps/booskiff-web/e2e/real/tests/auth.spec.ts b/apps/booskiff-web/e2e/real/tests/auth.spec.ts index 64edf03..0a9ebf0 100644 --- a/apps/booskiff-web/e2e/real/tests/auth.spec.ts +++ b/apps/booskiff-web/e2e/real/tests/auth.spec.ts @@ -83,6 +83,7 @@ test("real login grants a cookie session, drive access, and logout", async ({ pa // The core validates Hydra's signed access token before persisting this folder. const folder = `hydra-${crypto.randomUUID()}`; + await page.getByTestId("new-menu-button").click(); await page.getByTestId("new-folder-button").click(); await page.getByTestId("folder-name-input").fill(folder); const created = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/folders" && response.request().method() === "POST"); diff --git a/apps/booskiff-web/e2e/tests/drive-resume.spec.ts b/apps/booskiff-web/e2e/tests/drive-resume.spec.ts index d8a09be..de5a4a3 100644 --- a/apps/booskiff-web/e2e/tests/drive-resume.spec.ts +++ b/apps/booskiff-web/e2e/tests/drive-resume.spec.ts @@ -33,12 +33,13 @@ test("authenticated /drive full reload keeps a single, working folder section", await expect(page.getByTestId("drive-page")).toBeVisible(); // Folder section elements must exist exactly once (no duplication). - await expect(page.getByTestId("new-folder-button")).toHaveCount(1); + await expect(page.getByTestId("new-menu-button")).toHaveCount(1); await expect(page.getByTestId("folder-list")).toHaveCount(1); await expect(page.getByTestId("folder-name-input")).toHaveCount(0); // Event handlers must survive resume: folder creation still works. const name = `resume-${runId}`; + await page.getByTestId("new-menu-button").click(); await page.getByTestId("new-folder-button").click(); await expect(page.getByTestId("folder-name-input")).toHaveCount(1); await expect(page.getByTestId("folder-create-submit")).toHaveCount(1); diff --git a/apps/booskiff-web/e2e/tests/drive.spec.ts b/apps/booskiff-web/e2e/tests/drive.spec.ts index c75e9b7..a8499ef 100644 --- a/apps/booskiff-web/e2e/tests/drive.spec.ts +++ b/apps/booskiff-web/e2e/tests/drive.spec.ts @@ -9,6 +9,7 @@ const helloPath = fileURLToPath(new URL("./assets/hello.txt", import.meta.url)); test.describe.configure({ mode: "serial" }); async function quotaText(page: Page) { return page.getByTestId("quota").textContent(); } async function createFolder(page: Page, name: string) { + await page.getByTestId("new-menu-button").click(); await page.getByTestId("new-folder-button").click(); await page.getByTestId("folder-name-input").fill(name); await page.getByTestId("folder-create-submit").click(); diff --git a/apps/booskiff-web/e2e/tests/transfers.spec.ts b/apps/booskiff-web/e2e/tests/transfers.spec.ts index f962e04..ccd75ad 100644 --- a/apps/booskiff-web/e2e/tests/transfers.spec.ts +++ b/apps/booskiff-web/e2e/tests/transfers.spec.ts @@ -9,7 +9,7 @@ test("serial queue freezes folder destination and cannot complete before the ser expect(folderResponse.ok()).toBe(true); const folder = await folderResponse.json(); await page.goto(`/drive/folders/${folder.id}`); - await expect(page.getByTestId("new-folder-button")).toBeEnabled(); + await expect(page.getByTestId("new-menu-button")).toBeEnabled(); let release!: () => void; const held = new Promise((resolve) => { release = resolve; }); const destinations: string[] = []; @@ -65,7 +65,7 @@ test("a rejected upload allows later files and explicit retry; network result st await expect(page.getByTestId("transfer-panel")).toContainText("結果未確認"); }); -test("drop enqueues multiple files into the current folder", async ({ page }) => { +test("drop on the upper addition menu enqueues multiple files into the current folder", async ({ page }) => { await loginViaUi(page); const dataTransfer = await page.evaluateHandle(() => { const data = new DataTransfer(); @@ -73,8 +73,9 @@ test("drop enqueues multiple files into the current folder", async ({ page }) => data.items.add(new File(["two"], "drop-two.txt", { type: "text/plain" })); return data; }); - await expect(page.getByTestId("new-folder-button")).toBeEnabled(); - await page.getByTestId("drive-browser").dispatchEvent("drop", { dataTransfer }); + await expect(page.getByTestId("new-menu-button")).toBeEnabled(); + await page.getByTestId("new-menu-button").click(); + await page.getByTestId("new-menu").dispatchEvent("drop", { dataTransfer }); await expect(page.getByTestId("file-list")).toContainText("drop-one.txt"); await expect(page.getByTestId("file-list")).toContainText("drop-two.txt"); }); @@ -87,7 +88,7 @@ test("a delayed pre-save folder snapshot cannot hide a completed upload", async expect(created.ok()).toBe(true); const folder = await created.json(); await page.goto(`/drive/folders/${folder.id}`); - await expect(page.getByTestId("new-folder-button")).toBeEnabled(); + await expect(page.getByTestId("new-menu-button")).toBeEnabled(); let releaseUpload!: () => void; let releaseSnapshot!: () => void; let snapshotRead!: () => void; diff --git a/apps/booskiff-web/src/App/Message.purs b/apps/booskiff-web/src/App/Message.purs index a6e7a24..06431de 100644 --- a/apps/booskiff-web/src/App/Message.purs +++ b/apps/booskiff-web/src/App/Message.purs @@ -27,6 +27,7 @@ data Message | SetViewMode String | SearchChanged String | SortChanged String + | ToggleNewMenu | OpenCreateFolder | CloseFolderForm | FolderNameChanged String diff --git a/apps/booskiff-web/src/App/Model.purs b/apps/booskiff-web/src/App/Model.purs index 8539dbb..599a732 100644 --- a/apps/booskiff-web/src/App/Model.purs +++ b/apps/booskiff-web/src/App/Model.purs @@ -208,6 +208,7 @@ type Model = , dataEpoch :: Int , filesEpoch :: Int , billingEpoch :: Int + , newMenuOpen :: Boolean , folderFormOpen :: Boolean , selectedFolder :: Maybe String , folderForm :: FolderForm @@ -237,6 +238,7 @@ initialModel mRoute = , dataEpoch: 0 , filesEpoch: 0 , billingEpoch: 0 + , newMenuOpen: false , folderFormOpen: false , selectedFolder: folderForRoute mRoute , folderForm: emptyFolderForm diff --git a/apps/booskiff-web/src/App/View/Common.purs b/apps/booskiff-web/src/App/View/Common.purs index 02722d2..93288b7 100644 --- a/apps/booskiff-web/src/App/View/Common.purs +++ b/apps/booskiff-web/src/App/View/Common.purs @@ -30,6 +30,8 @@ icon kind = HE.svg [ HE.path [ HA.d path, HA.strokeLinecap "round", HA.strokeLinejoin "round" ] [] ] where path = case kind of + "plus" -> "M12 5v14M5 12h14" + "folder-add" -> "M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v10H3ZM12 10v6M9 13h6" "folder" -> "M3 7a2 2 0 0 1 2-2h5l2 2h7a2 2 0 0 1 2 2v10H3Z" "upload" -> "M12 16V3m-5 5 5-5 5 5M4 15v6h16v-6" "download" -> "M12 3v13m-5-5 5 5 5-5M4 16v5h16v-5" diff --git a/apps/booskiff-web/src/App/View/Drive.purs b/apps/booskiff-web/src/App/View/Drive.purs index b1e4259..a8c86fa 100644 --- a/apps/booskiff-web/src/App/View/Drive.purs +++ b/apps/booskiff-web/src/App/View/Drive.purs @@ -22,62 +22,85 @@ view model = HE.div [ HA.key "drive", HA.class' "drive-page", HA.createAttribute [ HE.p [ HA.class' "eyebrow" ] [ HE.text "YOUR WORKSPACE" ] , HE.h1_ [ HE.text (currentFolderName model) ] ] - , HE.div [ HA.class' "drive-primary-actions" ] - [ HE.button [ HA.class' "drive-button secondary", HA.onClick OpenCreateFolder, HA.disabled (not (folderReady model)), HA.createAttribute "data-testid" "new-folder-button" ] - [ icon "folder", HE.text "新規フォルダ" ] - , HE.button [ HA.class' "drive-button primary", HA.onClick ChooseUpload, HA.disabled (not (folderReady model)), HA.createAttribute "data-testid" "upload-submit" ] - [ icon "upload", HE.text "アップロード" ] - , HE.input - [ HA.type' "file" - , HA.multiple true - , HA.hidden true - , HA.onChange StartUpload - , HA.createAttribute "data-testid" "upload-input" - , HA.createAttribute "data-folder-id" (maybe "" identity model.selectedFolder) - , HA.createAttribute "data-folder-name" (currentFolderPath model) - ] + , HE.button + [ HA.class' "drive-button primary" + , HA.onClick ToggleNewMenu + , HA.disabled (not (folderReady model) || model.busy) + , HA.createAttribute "data-testid" "new-menu-button" + , HA.createAttribute "aria-expanded" (if model.newMenuOpen then "true" else "false") + , HA.createAttribute "aria-controls" "drive-add-menu" ] + [ icon "plus", HE.text "新規" ] ] , breadcrumbs model , errorBanner model.errorMessage - , if model.folderFormOpen then folderForm model else emptySlot - , HE.div [ HA.class' "drive-toolbar" ] - [ HE.label [ HA.class' "drive-search" ] - [ icon "search" - , HE.input - [ HA.type' "search" - , HA.placeholder "このフォルダ内を検索" - , HA.value model.search - , HA.onInput SearchChanged - , HA.createAttribute "aria-label" "このフォルダ内を検索" - , HA.createAttribute "data-testid" "drive-search" - ] - ] - , HE.div [ HA.class' "drive-display-tools" ] - [ HE.label [ HA.class' "drive-sort" ] - [ HE.span [ HA.class' "sr-only" ] [ HE.text "並び順" ] - , HE.select [ HA.value model.sort, HA.onInput SortChanged, HA.createAttribute "aria-label" "並び順" ] - [ HE.option [ HA.value "name" ] [ HE.text "名前順" ] - , HE.option [ HA.value "newest" ] [ HE.text "追加日時順" ] - , HE.option [ HA.value "size" ] [ HE.text "サイズ順" ] - ] - ] - , HE.div [ HA.class' "view-switch", HA.createAttribute "role" "group", HA.createAttribute "aria-label" "表示形式" ] - [ viewButton model "list" "リスト表示", viewButton model "grid" "アイコン表示" ] - ] + , HE.input + [ HA.type' "file" + , HA.multiple true + , HA.hidden true + , HA.onChange StartUpload + , HA.createAttribute "data-testid" "upload-input" + , HA.createAttribute "data-folder-id" (maybe "" identity model.selectedFolder) + , HA.createAttribute "data-folder-name" (currentFolderPath model) ] , HE.div - ( [ HA.class' "drive-browser" - , HA.createAttribute "data-testid" "drive-browser" + ( [ HA.class' "drive-dropzone" + , HA.createAttribute "data-testid" "drive-dropzone" , HA.createAttribute "data-folder-id" (maybe "" identity model.selectedFolder) , HA.createAttribute "data-folder-name" (currentFolderPath model) ] <> if folderReady model then [ HA.createAttribute "data-upload-dropzone" "true" ] else [] ) - [ browserContents model + [ if model.newMenuOpen then newMenu model else HE.p [ HA.class' "drive-drop-hint" ] [ icon "upload", HE.text "ファイルをここにドラッグしてアップロード" ] + , if model.folderFormOpen then folderForm model else emptySlot + , toolbar model + , HE.div [ HA.class' "drive-browser", HA.createAttribute "data-testid" "drive-browser" ] [ browserContents model ] , HE.div [ HA.class' "drop-overlay", HA.createAttribute "aria-hidden" "true" ] - [ icon "upload", HE.strong_ [ HE.text (currentFolderName model <> " にアップロード") ] ] + [ HE.div [ HA.class' "drop-overlay-content" ] + [ icon "upload", HE.strong_ [ HE.text (currentFolderName model <> " にアップロード") ] ] + ] + ] + ] + +newMenu :: Model -> Html Message +newMenu model = HE.div [ HA.id "drive-add-menu", HA.class' "drive-add-menu", HA.createAttribute "data-testid" "new-menu" ] + [ HE.div [ HA.class' "drive-add-heading" ] + [ HE.h2_ [ HE.text "この場所に追加" ] + , HE.button [ HA.class' "icon-button", HA.onClick ToggleNewMenu, HA.createAttribute "aria-label" "追加メニューを閉じる" ] [ icon "close" ] + ] + , HE.p [ HA.class' "drive-add-destination" ] [ HE.text ("保存先: " <> currentFolderPath model) ] + , HE.div [ HA.class' "drive-add-actions" ] + [ HE.button [ HA.class' "drive-button", HA.onClick ChooseUpload, HA.disabled (not (folderReady model)), HA.createAttribute "data-testid" "upload-submit" ] + [ icon "upload", HE.text "ファイルをアップロード" ] + , HE.button [ HA.class' "drive-button", HA.onClick OpenCreateFolder, HA.disabled (not (folderReady model) || model.busy), HA.createAttribute "data-testid" "new-folder-button" ] + [ icon "folder-add", HE.text "新しいフォルダ" ] + ] + ] + +toolbar :: Model -> Html Message +toolbar model = HE.div [ HA.class' "drive-toolbar" ] + [ HE.label [ HA.class' "drive-search" ] + [ icon "search" + , HE.input + [ HA.type' "search" + , HA.placeholder "このフォルダ内を検索" + , HA.value model.search + , HA.onInput SearchChanged + , HA.createAttribute "aria-label" "このフォルダ内を検索" + , HA.createAttribute "data-testid" "drive-search" + ] + ] + , HE.div [ HA.class' "drive-display-tools" ] + [ HE.label [ HA.class' "drive-sort" ] + [ HE.span [ HA.class' "sr-only" ] [ HE.text "並び順" ] + , HE.select [ HA.value model.sort, HA.onInput SortChanged, HA.createAttribute "aria-label" "並び順" ] + [ HE.option [ HA.value "name" ] [ HE.text "名前順" ] + , HE.option [ HA.value "newest" ] [ HE.text "追加日時順" ] + , HE.option [ HA.value "size" ] [ HE.text "サイズ順" ] + ] + ] + , HE.div [ HA.class' "view-switch", HA.createAttribute "role" "group", HA.createAttribute "aria-label" "表示形式" ] + [ viewButton model "list" "リスト表示", viewButton model "grid" "アイコン表示" ] ] - , HE.p [ HA.class' "drive-hint" ] [ HE.text "ファイルをここにドラッグしてアップロード" ] ] folderReady :: Model -> Boolean diff --git a/apps/booskiff-web/src/Client/Update.purs b/apps/booskiff-web/src/Client/Update.purs index 7ce75df..0982fb5 100644 --- a/apps/booskiff-web/src/Client/Update.purs +++ b/apps/booskiff-web/src/Client/Update.purs @@ -41,6 +41,7 @@ mkUpdate nav sendMessage model = case _ of , page = pageForMaybeRoute effectiveRoute , selectedFolder = folderForRoute effectiveRoute , folderForm = emptyFolderForm + , newMenuOpen = false , folderFormOpen = false , errorMessage = Nothing , busy = false @@ -95,7 +96,7 @@ mkUpdate nav sendMessage model = case _ of allowed <- liftEffect Upload.confirmLogout pure $ if allowed then Just LogoutConfirmed else Nothing ] - LogoutConfirmed -> Tuple (model { uploads = [], downloads = [], dataEpoch = model.dataEpoch + 1, filesEpoch = model.filesEpoch + 1, billingEpoch = model.billingEpoch + 1, busy = true }) + LogoutConfirmed -> Tuple (model { newMenuOpen = false, uploads = [], downloads = [], dataEpoch = model.dataEpoch + 1, filesEpoch = model.filesEpoch + 1, billingEpoch = model.billingEpoch + 1, busy = true }) [ liftEffect Upload.reset *> logoutAff ] LogoutDone -> Tuple ((initialModel (Just Login)) { isHydrated = true, dataEpoch = model.dataEpoch + 1, filesEpoch = model.filesEpoch + 1, billingEpoch = model.billingEpoch + 1 }) [ replace Login ] LogoutFailed msg -> Tuple (model { errorMessage = Just ("ログアウトに失敗しました: " <> msg), busy = false }) @@ -134,7 +135,8 @@ mkUpdate nav sendMessage model = case _ of SearchChanged search -> noMessages $ model { search = search } SortChanged sort -> noMessages $ model { sort = sort } - OpenCreateFolder -> noMessages $ model { folderFormOpen = true, folderForm = emptyFolderForm, errorMessage = Nothing } + ToggleNewMenu -> if model.busy then noMessages model else noMessages $ model { newMenuOpen = not model.newMenuOpen } + OpenCreateFolder -> if model.busy then noMessages model else noMessages $ model { newMenuOpen = false, folderFormOpen = true, folderForm = emptyFolderForm, errorMessage = Nothing } CloseFolderForm -> noMessages $ model { folderFormOpen = false, folderForm = emptyFolderForm } FolderNameChanged name -> noMessages $ model { folderForm = model.folderForm { name = name } } SubmitCreateFolder -> @@ -150,7 +152,7 @@ mkUpdate nav sendMessage model = case _ of Loaded folders -> maybe "" (\(Folder f) -> f.name) (find (\(Folder f) -> f.id == id) folders) _ -> "" in - noMessages $ model { folderFormOpen = true, folderForm = { name, editing: Just id }, errorMessage = Nothing } + noMessages $ model { newMenuOpen = false, folderFormOpen = true, folderForm = { name, editing: Just id }, errorMessage = Nothing } SubmitRenameFolder -> case model.folderForm.editing of Just id | trim model.folderForm.name /= "" && not model.busy -> Tuple (model { busy = true, errorMessage = Nothing }) diff --git a/apps/booskiff-web/src/style.css b/apps/booskiff-web/src/style.css index 66a1010..efcd67b 100644 --- a/apps/booskiff-web/src/style.css +++ b/apps/booskiff-web/src/style.css @@ -42,9 +42,10 @@ .workspace-content { min-width: 0; padding: 40px clamp(24px, 4vw, 64px) 150px; } .login-layout .workspace-content { max-width: 560px; margin: auto; } .drive-heading { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 16px; } +.drive-heading > div { flex: 1; min-width: 0; } .drive-heading .eyebrow { margin-bottom: 8px; } .drive-heading h1 { font-size: 29px; letter-spacing: -.7px; font-weight: 650; word-break: break-word; } -.drive-primary-actions { display: flex; gap: 10px; flex-shrink: 0; } +.drive-heading > .drive-button { flex-shrink: 0; } .drive-button { display: inline-flex; justify-content: center; align-items: center; gap: 9px; border-radius: 9px; padding: 10px 16px; font-size: 12px; font-weight: 650; min-height: 40px; white-space: nowrap; border: 1px solid transparent; } .drive-button.primary { background: var(--color-accent, #cba6f7); color: var(--color-bg-primary, #1e1e2e); } .drive-button.secondary { border-color: var(--drive-line); background: color-mix(in srgb, currentColor 3%, transparent); } @@ -69,7 +70,17 @@ .icon-button .drive-icon { width: 17px; height: 17px; } .icon-button.selected { color: var(--color-accent, #cba6f7); background: color-mix(in srgb, var(--color-accent, #cba6f7) 13%, transparent); } .icon-button:hover, .text-button:hover { background: color-mix(in srgb, currentColor 7%, transparent); } -.drive-browser { position: relative; min-height: 380px; border: 1px solid var(--drive-line); border-radius: 13px; background: color-mix(in srgb, currentColor 1%, transparent); } +.drive-dropzone { position: relative; min-height: 380px; } +.drive-browser { min-height: 380px; } +.drive-drop-hint { display: flex; align-items: center; gap: 10px; font-size: 12px; opacity: .6; margin-bottom: 24px; } +.drive-drop-hint .drive-icon { width: 17px; height: 17px; } +.drive-add-menu { padding: 0 0 24px; } +.drive-add-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.drive-add-heading h2 { font-size: 16px; font-weight: 550; } +.drive-add-destination { margin: 6px 0 14px; font-size: 12px; opacity: .65; overflow-wrap: anywhere; } +.drive-add-actions { display: flex; flex-wrap: wrap; gap: 8px 12px; } +.drive-add-actions .drive-button { padding: 8px 10px; font-weight: 500; } +.drive-add-actions .drive-button:first-child { margin-left: -10px; } .drive-table-scroll { overflow-x: auto; } .drive-table { width: 100%; border-collapse: collapse; text-align: left; table-layout: fixed; } .drive-table th { height: 43px; font-weight: 500; font-size: 11px; opacity: .55; } @@ -97,8 +108,8 @@ .drive-state > .drive-icon { width: 46px; height: 46px; opacity: .5; margin-bottom: 10px; } .drive-state h2 { font-size: 16px; font-weight: 600; } .drive-state p { font-size: 12px; opacity: .6; max-width: 480px; overflow-wrap: anywhere; } -.drive-hint { font-size: 11px; opacity: .4; text-align: center; margin: 20px 0; } -.drop-overlay { display: none; position: absolute; inset: 0; z-index: 3; background: color-mix(in srgb, var(--drive-bg) 95%, transparent); border: 2px dashed var(--color-accent, #cba6f7); border-radius: 12px; align-items: center; justify-content: center; flex-direction: column; gap: 16px; pointer-events: none; } +.drop-overlay { display: none; position: absolute; inset: 0; z-index: 3; background: color-mix(in srgb, var(--drive-bg) 95%, transparent); border: 2px dashed var(--color-accent, #cba6f7); border-radius: 12px; align-items: center; justify-content: flex-start; flex-direction: column; padding: 24px; pointer-events: none; } +.drop-overlay-content { position: sticky; top: 24px; display: flex; align-items: center; flex-direction: column; gap: 16px; } .drop-overlay .drive-icon { width: 42px; height: 42px; } .is-dragging > .drop-overlay { display: flex; } .drive-error { display: flex; align-items: center; justify-content: space-between; gap: 12px; background: #f38ba810; color: #f38ba8; border: 1px solid #f38ba833; padding: 12px 16px; border-radius: 9px; margin-bottom: 20px; overflow-wrap: anywhere; } @@ -145,7 +156,7 @@ .drive-table th:nth-child(3) { width: 20%; } .drive-table th:last-child { width: 24%; } .drive-table th, .drive-table td { padding-left: 12px; padding-right: 12px; } - .drive-heading { align-items: flex-start; flex-direction: column; gap: 16px; } + .drive-heading { gap: 16px; } } @media (max-width: 760px) { .app-header { height: 64px; padding: 0 18px; gap: 16px; } @@ -160,8 +171,9 @@ .storage-card p { margin: 0; } .workspace-content { padding: 26px 16px 170px; } .drive-heading h1 { font-size: 25px; } - .drive-primary-actions { width: 100%; } - .drive-primary-actions .drive-button { flex: 1; padding: 10px 8px; } + .drive-heading > .drive-button { padding: 10px 12px; min-height: 44px; } + .drive-add-actions { gap: 4px; } + .drive-add-actions .drive-button { min-height: 44px; } .drive-heading .eyebrow { font-size: 9px; } .breadcrumbs { margin-bottom: 22px; } .drive-toolbar { flex-wrap: wrap; gap: 12px; } From b7c05d05e3ad400eb8449f2d54503a3ffdb72888 Mon Sep 17 00:00:00 2001 From: turtton Date: Sun, 4 Oct 2026 12:42:23 +0900 Subject: [PATCH 3/3] Remove redundant drive location labels --- apps/booskiff-web/src/App/View/Drive.purs | 3 +-- apps/booskiff-web/src/style.css | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/booskiff-web/src/App/View/Drive.purs b/apps/booskiff-web/src/App/View/Drive.purs index a8c86fa..fbfeef1 100644 --- a/apps/booskiff-web/src/App/View/Drive.purs +++ b/apps/booskiff-web/src/App/View/Drive.purs @@ -32,7 +32,7 @@ view model = HE.div [ HA.key "drive", HA.class' "drive-page", HA.createAttribute ] [ icon "plus", HE.text "新規" ] ] - , breadcrumbs model + , if isJust model.selectedFolder then breadcrumbs model else emptySlot , errorBanner model.errorMessage , HE.input [ HA.type' "file" @@ -67,7 +67,6 @@ newMenu model = HE.div [ HA.id "drive-add-menu", HA.class' "drive-add-menu", HA. [ HE.h2_ [ HE.text "この場所に追加" ] , HE.button [ HA.class' "icon-button", HA.onClick ToggleNewMenu, HA.createAttribute "aria-label" "追加メニューを閉じる" ] [ icon "close" ] ] - , HE.p [ HA.class' "drive-add-destination" ] [ HE.text ("保存先: " <> currentFolderPath model) ] , HE.div [ HA.class' "drive-add-actions" ] [ HE.button [ HA.class' "drive-button", HA.onClick ChooseUpload, HA.disabled (not (folderReady model)), HA.createAttribute "data-testid" "upload-submit" ] [ icon "upload", HE.text "ファイルをアップロード" ] diff --git a/apps/booskiff-web/src/style.css b/apps/booskiff-web/src/style.css index efcd67b..54d64fa 100644 --- a/apps/booskiff-web/src/style.css +++ b/apps/booskiff-web/src/style.css @@ -77,8 +77,7 @@ .drive-add-menu { padding: 0 0 24px; } .drive-add-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } .drive-add-heading h2 { font-size: 16px; font-weight: 550; } -.drive-add-destination { margin: 6px 0 14px; font-size: 12px; opacity: .65; overflow-wrap: anywhere; } -.drive-add-actions { display: flex; flex-wrap: wrap; gap: 8px 12px; } +.drive-add-actions { display: flex; flex-wrap: wrap; gap: 8px 12px; margin-top: 8px; } .drive-add-actions .drive-button { padding: 8px 10px; font-weight: 500; } .drive-add-actions .drive-button:first-child { margin-left: -10px; } .drive-table-scroll { overflow-x: auto; }