mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(webapp): [phase 0] React+TS frontend scaffold, build pipeline, e2e harness
- Vite+React+TypeScript workspace at internal/webapp/frontend; vite build emits committed assets into internal/webapp/static (the go:embed target), so plain 'go build' still needs no Node - style.css and the SVG sprite ported verbatim; boot layer (api/config, 401->login redirect) ported so the auth flow works end to end - frontend(): content-hashed assets/* now served immutable; index.html stays no-cache (TestFrontendSPAFallback covers both) - committed e2e harness (BDRIVE_E2E_SERVE=1, deterministic seeded hub on :8993) wired as Playwright webServer; 4 shell specs green - check-dist.sh guards against stale committed build output Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
This commit is contained in:
co-authored by
Claude Fable 5
parent
9adb4172ba
commit
e701555257
+23
-14
@@ -100,19 +100,25 @@ refresh; invalidate after uploads/renames/admin actions — mirror today's
|
||||
## Phases
|
||||
|
||||
### Phase 0 — scaffold & pipeline
|
||||
- [ ] Vite+React+TS workspace at `internal/webapp/frontend/`; build lands in
|
||||
`internal/webapp/static/` and is committed; old files removed only in
|
||||
Phase 5 (until then keep building the new app; parity gate flips the
|
||||
switch — see note below).
|
||||
- [ ] NOTE: to keep `main`-mergeable states shippable, do the whole
|
||||
migration on ONE branch; each phase is a commit (or a few), and the
|
||||
committed `static/` on the branch is the React build from Phase 0 on.
|
||||
- [ ] `style.css` ported verbatim as global stylesheet; SVG sprite ported.
|
||||
- [ ] Dev proxy works against a local hub.
|
||||
- [ ] Cache-header change in `frontend()` + assets served hashed.
|
||||
- [ ] `check-dist.sh` works.
|
||||
- [ ] e2e harness (§Verification) committed and starts a seeded hub.
|
||||
- [ ] `go build ./...`, `go vet ./...`, `go test ./...` green.
|
||||
- [x] Vite+React+TS workspace at `internal/webapp/frontend/`; build lands in
|
||||
`internal/webapp/static/` and is committed. The branch's `static/` is
|
||||
the React build from Phase 0 on (the old files live on `main` /
|
||||
`git show main:internal/webapp/static/app.js` for porting reference);
|
||||
the parity gate in Phase 5 is what makes the branch mergeable.
|
||||
- [x] `style.css` ported verbatim as global stylesheet; SVG sprite ported
|
||||
(kept inline in `index.html`, as before).
|
||||
- [x] Dev proxy works against a local hub (`BDRIVE_DEV_PROXY` overrides the
|
||||
`localhost:8080` default).
|
||||
- [x] Cache-header change in `frontend()` + assets served hashed
|
||||
(covered by `TestFrontendSPAFallback`).
|
||||
- [x] `check-dist.sh` works.
|
||||
- [x] e2e harness committed (`e2e_serve_test.go`, gated by
|
||||
`BDRIVE_E2E_SERVE=1`, port 8993, fresh state each run) and wired as
|
||||
Playwright's webServer; 4 shell specs green. Also ported in Phase 0
|
||||
(the e2e login flow needed it): the `getJSON`/`postJSON` layer with
|
||||
401→login redirect, `/api/config` boot, and the title/vault-name
|
||||
wiring (`src/api/http.ts`, `src/hooks/useConfig.ts`).
|
||||
- [x] `go build ./...`, `go vet ./...`, `go test ./...` green.
|
||||
|
||||
### Phase 1 — shell: boot, session, projects, routing
|
||||
- [ ] Boot from `/api/config`; volume vs hub mode both render.
|
||||
@@ -129,6 +135,9 @@ refresh; invalidate after uploads/renames/admin actions — mirror today's
|
||||
- [ ] Breadcrumbs; per-route scroll restoration.
|
||||
- [ ] Download + raw file view; share button state; share dialog.
|
||||
- [ ] Drag-drop upload (presign and relay paths) with refresh after commit.
|
||||
- [ ] Command palette (⌘K: file names, projects, actions — port the
|
||||
palette overlay from the old shell) and the topbar overflow menu
|
||||
(`more-btn`/`more-menu`) for narrow viewports.
|
||||
|
||||
### Phase 3 — project home, insights, history
|
||||
- [ ] Project home at `/<pid>`: connect guide (3 tabs: "Claude Code &
|
||||
@@ -206,7 +215,7 @@ file path; reload on `/insights`.
|
||||
|
||||
## Status
|
||||
|
||||
- [ ] Phase 0
|
||||
- [x] Phase 0
|
||||
- [ ] Phase 1
|
||||
- [ ] Phase 2
|
||||
- [ ] Phase 3
|
||||
|
||||
@@ -2,6 +2,7 @@ package webapp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -78,9 +79,12 @@ func TestFrontendSPAFallback(t *testing.T) {
|
||||
if rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
|
||||
t.Fatalf("%s: want 200 html, got %d %s", url, rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `id="sidebar"`) {
|
||||
if !strings.Contains(rec.Body.String(), `id="root"`) {
|
||||
t.Fatalf("%s: expected the app shell, got %.60q", url, rec.Body.String())
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); cc != "no-cache" {
|
||||
t.Fatalf("%s: the shell must revalidate, got Cache-Control %q", url, cc)
|
||||
}
|
||||
}
|
||||
// Client routes all resolve to the shell, not a 404 or file content.
|
||||
shell("/")
|
||||
@@ -88,12 +92,18 @@ func TestFrontendSPAFallback(t *testing.T) {
|
||||
shell("/p-deadbeef/notes/plan.md") // hub-style route
|
||||
shell("/join/abc123") // invite route
|
||||
|
||||
// Real assets are served as themselves.
|
||||
if rec := get(t, h, "/app.js"); rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "javascript") {
|
||||
t.Fatalf("/app.js: %d %s", rec.Code, rec.Header().Get("Content-Type"))
|
||||
// Real assets are served as themselves, cacheable forever: Vite emits
|
||||
// content-hashed filenames, so find one instead of hardcoding a hash.
|
||||
assets, err := fs.Glob(staticFiles, "static/assets/*.js")
|
||||
if err != nil || len(assets) == 0 {
|
||||
t.Fatalf("no built js asset embedded (run npm run build in frontend/): %v", err)
|
||||
}
|
||||
if rec := get(t, h, "/style.css"); rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "css") {
|
||||
t.Fatalf("/style.css: %d %s", rec.Code, rec.Header().Get("Content-Type"))
|
||||
rec := get(t, h, strings.TrimPrefix(assets[0], "static"))
|
||||
if rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "javascript") {
|
||||
t.Fatalf("%s: %d %s", assets[0], rec.Code, rec.Header().Get("Content-Type"))
|
||||
}
|
||||
if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
|
||||
t.Fatalf("hashed assets must be immutable, got Cache-Control %q", cc)
|
||||
}
|
||||
|
||||
// A mistyped API path is a genuine 404, not the shell.
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package webapp
|
||||
|
||||
// E2E hub harness for the frontend's Playwright suite (frontend/e2e). Not
|
||||
// part of the normal test suite: it only runs with BDRIVE_E2E_SERVE=1, where
|
||||
// it serves a small deterministic hub on :8993 until killed. Playwright
|
||||
// starts it via its webServer config and tears it down after the run.
|
||||
//
|
||||
// Unlike the manual demo harness, state is wiped on every start so the
|
||||
// tests always see the same world: one org ("default", owned by the admin
|
||||
// account), one project ("wiki") with a handful of seeded files, two
|
||||
// accounts, one share-ready markdown tree with wikilinks, and enough read
|
||||
// heat to light up the insights views.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/runbear-io/beardrive/internal/journal"
|
||||
"github.com/runbear-io/beardrive/internal/remote"
|
||||
)
|
||||
|
||||
const (
|
||||
e2eAddr = "0.0.0.0:8993"
|
||||
e2eAdmin = "e2e@example.com"
|
||||
e2eMember = "member@example.com"
|
||||
e2ePassword = "e2e-pass-1"
|
||||
)
|
||||
|
||||
func TestE2EServe(t *testing.T) {
|
||||
if os.Getenv("BDRIVE_E2E_SERVE") == "" {
|
||||
t.Skip("frontend e2e harness; set BDRIVE_E2E_SERVE=1 to run")
|
||||
}
|
||||
state := filepath.Join(os.TempDir(), "bdrive-e2e-hub")
|
||||
if err := os.RemoveAll(state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(state, "storage"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
be, err := remote.Open(t.Context(), "file://"+filepath.Join(state, "storage"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, err := OpenProjectDB(filepath.Join(state, "projects.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, _, err := db.GetOrCreate("wiki", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv := &Server{Root: be, Projects: db, Device: webDevice, Refresh: 0, Upload: UploadConfig{Enabled: true}}
|
||||
|
||||
seedE2E(t, state, filepath.Join(state, "storage", p.ID), p.ID)
|
||||
|
||||
srv.Reads, err = OpenReadLedger(filepath.Join(state, "reads.json"), 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.Devices, _ = OpenDeviceRegistry(filepath.Join(state, "devices.json"))
|
||||
srv.Devices.Observe(DeviceInfo{ID: "seed", Name: "seed-agent", OS: "linux/amd64"})
|
||||
|
||||
auth, err := OpenBuiltinAuth(filepath.Join(state, "auth.json"), false, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := auth.signup(e2eAdmin, "E2E Admin", e2ePassword); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := auth.signup(e2eMember, "E2E Member", e2ePassword); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth.Admins = map[string]bool{e2eAdmin: true}
|
||||
srv.Auth = auth
|
||||
|
||||
orgs, err := OpenOrgDB(filepath.Join(state, "orgs.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
org, err := orgs.Create("default", e2eAdmin)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := orgs.AddMember(org.ID, e2eMember, RoleMember); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.SetOrg(p.ID, org.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.Orgs = orgs
|
||||
auth.InviteValid = orgs.ValidInvite
|
||||
|
||||
shares, err := OpenShareDB(filepath.Join(state, "shares.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv.Shares = shares
|
||||
|
||||
t.Logf("e2e hub on http://%s (state: %s) — %s / %s", e2eAddr, state, e2eAdmin, e2ePassword)
|
||||
go http.ListenAndServe(e2eAddr, srv.Handler())
|
||||
time.Sleep(2 * time.Hour) // Playwright kills the process when the run ends
|
||||
}
|
||||
|
||||
// seedE2E journals a small fixed file tree (with history on one path and one
|
||||
// binary) and read heat for it.
|
||||
func seedE2E(t *testing.T, state, prefix, projectID string) {
|
||||
t.Helper()
|
||||
os.MkdirAll(filepath.Join(prefix, "journal"), 0o755)
|
||||
os.MkdirAll(filepath.Join(prefix, "blobs"), 0o755)
|
||||
now := time.Now().UTC()
|
||||
var ops []journal.Op
|
||||
var lam, seq int64
|
||||
put := func(path, content string, age time.Duration) {
|
||||
sum := sha256.Sum256([]byte(content))
|
||||
blob := hex.EncodeToString(sum[:])
|
||||
if err := os.WriteFile(filepath.Join(prefix, "blobs", blob), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lam++
|
||||
seq++
|
||||
ops = append(ops, journal.Op{
|
||||
Seq: seq, Lamport: lam, Time: now.Add(-age),
|
||||
Device: "seed", DeviceName: "seed-agent", Author: "alice@x.io",
|
||||
User: "alice@x.io", UserName: "Alice",
|
||||
Kind: journal.KindPut, Path: path, Blob: blob,
|
||||
Size: int64(len(content)), Mode: 0o644,
|
||||
})
|
||||
}
|
||||
put("index.md", "# Wiki\n\nStart at the [[guide]] or browse [notes](notes/readme.md).\n", 72*time.Hour)
|
||||
put("guide.md", "# Guide\n\nFirst version of the guide.\n", 48*time.Hour)
|
||||
put("guide.md", "# Guide\n\nSecond version of the guide, with more detail.\n", 2*time.Hour)
|
||||
put("notes/readme.md", "# Notes\n\nNested folder content.\n", 24*time.Hour)
|
||||
put("notes/deep/topic.md", "# Topic\n\nDeeply nested file.\n", 24*time.Hour)
|
||||
// Tiny valid PNG (1x1), enough to exercise the binary/download path.
|
||||
png := "\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" +
|
||||
"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
put("assets/logo.png", png, 24*time.Hour)
|
||||
if err := journal.Append(filepath.Join(prefix, "journal", "seed.jsonl"), ops); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
day := now.Format("2006-01-02")
|
||||
var stats []ReadStat
|
||||
for _, rd := range []struct {
|
||||
path string
|
||||
kind string
|
||||
actor string
|
||||
n int64
|
||||
}{
|
||||
{"index.md", ReadKindHuman, "alice@x.io", 12},
|
||||
{"index.md", ReadKindAgent, "seed", 30},
|
||||
{"guide.md", ReadKindHuman, "bob@x.io", 5},
|
||||
{"guide.md", ReadKindAgent, "seed", 9},
|
||||
{"notes/readme.md", ReadKindAgent, "seed", 2},
|
||||
} {
|
||||
stats = append(stats, ReadStat{Project: projectID, Path: rd.path, Day: day,
|
||||
Kind: rd.kind, Actor: rd.actor, Count: rd.n, Last: now})
|
||||
}
|
||||
if err := newFileReadRepo(filepath.Join(state, "reads.json")).PutBatch(stats); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
Executable
+14
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
# Verify the committed build output at ../static matches the frontend
|
||||
# source. Run before tagging a release (goreleaser requires a clean tree,
|
||||
# so a stale dist would otherwise slip through silently).
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
npm ci
|
||||
npm run build
|
||||
if [ -n "$(git status --porcelain -- ../static)" ]; then
|
||||
echo "internal/webapp/static is stale: rebuild and commit it" >&2
|
||||
git status --short -- ../static >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "internal/webapp/static is fresh"
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Page } from "@playwright/test";
|
||||
|
||||
export const ADMIN = "e2e@example.com";
|
||||
export const MEMBER = "member@example.com";
|
||||
export const PASSWORD = "e2e-pass-1";
|
||||
|
||||
// Signs in through the server-rendered /auth pages and waits for the SPA
|
||||
// shell to render.
|
||||
export async function login(page: Page, email: string = ADMIN) {
|
||||
await page.goto("/");
|
||||
await page.waitForURL(/auth\/login/);
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.fill('input[name="password"]', PASSWORD);
|
||||
await page.click("form button");
|
||||
await page.waitForSelector("#sidebar");
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
// The suite runs against the committed Go e2e harness (e2e_serve_test.go):
|
||||
// a deterministic seeded hub on :8993, freshly wiped on every start. It
|
||||
// exercises the BUILT frontend served by the Go binary — run `npm run
|
||||
// build` before `npm run e2e`, or test against stale assets.
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
timeout: 30_000,
|
||||
retries: 0,
|
||||
workers: 1, // specs share one hub with mutable state (uploads, shares)
|
||||
use: {
|
||||
baseURL: "http://localhost:8993",
|
||||
},
|
||||
webServer: {
|
||||
command:
|
||||
"cd ../../../.. && BDRIVE_E2E_SERVE=1 go test -count=1 -timeout 3h -run TestE2EServe ./internal/webapp",
|
||||
url: "http://localhost:8993/",
|
||||
reuseExistingServer: true,
|
||||
timeout: 60_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { login } from "./helpers";
|
||||
|
||||
test("unauthenticated visit redirects to the login page", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.waitForURL(/auth\/login/);
|
||||
await expect(page.locator('input[name="email"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test("login lands in the app shell", async ({ page }) => {
|
||||
await login(page);
|
||||
await expect(page).toHaveTitle("BearDrive");
|
||||
await expect(page.locator("#sidebar")).toBeVisible();
|
||||
await expect(page.locator("#topbar")).toBeVisible();
|
||||
});
|
||||
|
||||
test("hashed assets are served immutable, shell revalidates", async ({ page, request }) => {
|
||||
await login(page);
|
||||
const src = await page.locator('script[type="module"]').getAttribute("src");
|
||||
expect(src).toMatch(/^\/assets\/.+\.js$/);
|
||||
const asset = await request.get(src!);
|
||||
expect(asset.headers()["cache-control"]).toContain("immutable");
|
||||
const shell = await request.get("/");
|
||||
expect(shell.headers()["cache-control"]).toBe("no-cache");
|
||||
});
|
||||
|
||||
test("mistyped API path is a real 404, not the shell", async ({ page }) => {
|
||||
await login(page); // unauthenticated /api/* is a 401 before routing
|
||||
const res = await page.request.get("/api/nope");
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐻</text></svg>">
|
||||
</head>
|
||||
<body>
|
||||
<svg width="0" height="0" class="sprite" aria-hidden="true" focusable="false">
|
||||
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></symbol>
|
||||
<symbol id="i-chevd" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
|
||||
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></symbol>
|
||||
<symbol id="i-doc" viewBox="0 0 24 24"><path d="M14 3v5h5"/><path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/></symbol>
|
||||
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.2-3.2"/></symbol>
|
||||
<symbol id="i-share" viewBox="0 0 24 24"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4"/></symbol>
|
||||
<symbol id="i-hist" viewBox="0 0 24 24"><path d="M3 3v6h6"/><path d="M3.5 9a9 9 0 1 0 2-4"/><path d="M12 8v4l3 2"/></symbol>
|
||||
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
|
||||
<symbol id="i-gear" viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 13a7.6 7.6 0 0 0 0-2l1.7-1.3-1.7-3-2 .8a7.6 7.6 0 0 0-1.7-1l-.3-2.1H10l-.3 2.1a7.6 7.6 0 0 0-1.7 1l-2-.8-1.7 3L6 11a7.6 7.6 0 0 0 0 2l-1.7 1.3 1.7 3 2-.8a7.6 7.6 0 0 0 1.7 1l.3 2.1h3.4l.3-2.1a7.6 7.6 0 0 0 1.7-1l2 .8 1.7-3z"/></symbol>
|
||||
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 20v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="3.2"/><path d="M22 20v-2a4 4 0 0 0-3-3.8"/><path d="M16 3.5a4 4 0 0 1 0 7"/></symbol>
|
||||
<symbol id="i-link" viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1"/></symbol>
|
||||
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 3 5 6v5c0 4.5 3 7.5 7 9 4-1.5 7-4.5 7-9V6z"/></symbol>
|
||||
<symbol id="i-check" viewBox="0 0 24 24"><path d="m5 13 4 4L19 7"/></symbol>
|
||||
<symbol id="i-x" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6 6 18"/></symbol>
|
||||
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m2 0v12a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7"/></symbol>
|
||||
<symbol id="i-lock" viewBox="0 0 24 24"><rect x="4.5" y="10" width="15" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></symbol>
|
||||
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 8v4l3 2"/></symbol>
|
||||
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></symbol>
|
||||
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h8"/></symbol>
|
||||
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 9v4M12 17h.01"/><path d="M10.3 4 3 17a2 2 0 0 0 1.7 3h14.6a2 2 0 0 0 1.7-3L13.7 4a2 2 0 0 0-3.4 0z"/></symbol>
|
||||
<symbol id="i-power" viewBox="0 0 24 24"><path d="M12 4v8M7.5 7a7 7 0 1 0 9 0"/></symbol>
|
||||
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14"/></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 20V9m0 0 4 4m-4-4-4 4M5 5h14"/></symbol>
|
||||
<symbol id="i-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none"/></symbol>
|
||||
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M4 20l1.2-4.2L16.6 4.4a2 2 0 0 1 2.9 2.9L8.2 18.8z"/></symbol>
|
||||
<symbol id="i-enter" viewBox="0 0 24 24"><path d="M9 10 4 15l5 5"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||
<symbol id="i-dots" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/></symbol>
|
||||
</svg>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1972
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "beardrive-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"e2e": "playwright test -c e2e"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.61.1",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from "react";
|
||||
import { useConfig } from "./hooks/useConfig";
|
||||
|
||||
// Phase 0 shell: boots /api/config (redirecting to /auth/login when there
|
||||
// is no session) and renders the static layout so the ported stylesheet can
|
||||
// be verified. Routing and data views arrive in Phase 1.
|
||||
export default function App() {
|
||||
const { data: config } = useConfig();
|
||||
|
||||
useEffect(() => {
|
||||
if (config) document.title = config.brand || config.volume || "BearDrive";
|
||||
}, [config]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="sb-backdrop" />
|
||||
<aside id="sidebar">
|
||||
<header id="vault">
|
||||
<span id="vault-badge" aria-hidden="true">
|
||||
🐻
|
||||
</span>
|
||||
<span id="vault-name">{config ? config.brand || config.volume || "BearDrive" : "…"}</span>
|
||||
</header>
|
||||
<nav id="tree" aria-label="Files" />
|
||||
</aside>
|
||||
<main id="main">
|
||||
<header id="topbar">
|
||||
<span id="crumb" />
|
||||
<span id="meta" />
|
||||
</header>
|
||||
<article id="content" className="markdown">
|
||||
<div className="empty">{config ? "Select a file to read it." : "Loading…"}</div>
|
||||
</article>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Fetch helpers shared by every API hook. All URLs are root-absolute: a
|
||||
// deep path like /<project>/<dir>/<file> must never break relative
|
||||
// resolution.
|
||||
|
||||
function toLogin(): never {
|
||||
// Auth required: sign in, then come back to the current route.
|
||||
location.href =
|
||||
"/auth/login?next=" + encodeURIComponent(location.pathname + location.search);
|
||||
throw new Error("signing in…");
|
||||
}
|
||||
|
||||
export async function getJSON<T>(url: string): Promise<T> {
|
||||
const r = await fetch(url);
|
||||
if (r.status === 401) toLogin();
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.json();
|
||||
}
|
||||
|
||||
export async function postJSON<T>(url: string, body?: unknown): Promise<T> {
|
||||
const r = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body || {}),
|
||||
});
|
||||
if (r.status === 401) toLogin();
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.json();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Hand-written TypeScript shapes for the Go API responses. Derived from the
|
||||
// handler structs in internal/webapp — keep in sync when handlers change.
|
||||
// The API is deliberately storage-blind: nothing here ever names a bucket,
|
||||
// remote URL, or credential, and heat responses carry no actor identities.
|
||||
|
||||
// GET /api/config (handleConfig, server.go)
|
||||
export interface ServerConfig {
|
||||
mode: "volume" | "hub";
|
||||
volume: string;
|
||||
brand: string;
|
||||
upload: { enabled: boolean };
|
||||
auth: {
|
||||
enabled: boolean;
|
||||
cli_login?: string;
|
||||
allow_signup?: boolean;
|
||||
admin?: boolean;
|
||||
};
|
||||
reads: { enabled: boolean };
|
||||
me?: { email: string; name: string };
|
||||
}
|
||||
|
||||
// GET /api/projects (handleProjectList → Project, projects.go)
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
org?: string;
|
||||
created?: string;
|
||||
}
|
||||
|
||||
export interface ProjectList {
|
||||
projects: Project[];
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getJSON } from "../api/http";
|
||||
import type { ServerConfig } from "../api/types";
|
||||
|
||||
// The first request the app makes; everything else keys off its answer.
|
||||
// If auth is on and there is no session, redirect to the login page here
|
||||
// rather than letting every authed call 401 (noisy console, and the
|
||||
// redirect happens anyway). /api/config reports `me` only when signed in.
|
||||
export function useConfig() {
|
||||
return useQuery({
|
||||
queryKey: ["config"],
|
||||
queryFn: async () => {
|
||||
const cfg = await getJSON<ServerConfig>("/api/config");
|
||||
if (cfg.auth.enabled && !cfg.me) {
|
||||
location.href =
|
||||
"/auth/login?next=" +
|
||||
encodeURIComponent(location.pathname + location.search);
|
||||
await new Promise(() => {}); // never resolve; we're navigating away
|
||||
}
|
||||
return cfg;
|
||||
},
|
||||
staleTime: Infinity,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import App from "./App";
|
||||
import "./style.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: 1, refetchOnWindowFocus: false },
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// Build output feeds the Go binary: ../static is the go:embed target in
|
||||
// server.go, and the compiled assets are committed so plain `go build`
|
||||
// needs no Node. Run ./check-dist.sh to verify the committed output is
|
||||
// fresh.
|
||||
// Dev target: a locally running hub (`bdrive web` or the e2e harness).
|
||||
const target = process.env.BDRIVE_DEV_PROXY || "http://localhost:8080";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: { outDir: "../static", emptyOutDir: true },
|
||||
server: {
|
||||
proxy: {
|
||||
// Everything the Go server owns; the frontend itself only ever uses
|
||||
// root-absolute URLs, so prefix proxying is enough.
|
||||
"/api": target,
|
||||
"/auth": target,
|
||||
"^/s/": target,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -383,10 +383,16 @@ func (s *Server) frontend(static fs.FS) http.HandlerFunc {
|
||||
files := http.FileServerFS(static)
|
||||
index, _ := fs.ReadFile(static, "index.html")
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
// Embedded assets carry no modtime, so without this browsers cache
|
||||
// them heuristically and users see a stale frontend after upgrades.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
upath := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
|
||||
// Vite emits content-hashed filenames under assets/, safe to cache
|
||||
// forever. Everything else (index.html above all) must revalidate:
|
||||
// embedded files carry no modtime, so without no-cache browsers
|
||||
// cache heuristically and users see a stale frontend after upgrades.
|
||||
if strings.HasPrefix(upath, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
// Reserved prefixes that fell through to the catch-all are genuine
|
||||
// 404s — don't mask a mistyped API/auth/share URL with the app shell.
|
||||
if strings.HasPrefix(upath, "api/") || strings.HasPrefix(upath, "auth/") || strings.HasPrefix(upath, "s/") {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4,8 +4,9 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐻</text></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-CZjUv2DS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-oVhdizP9.css">
|
||||
</head>
|
||||
<body>
|
||||
<svg width="0" height="0" class="sprite" aria-hidden="true" focusable="false">
|
||||
@@ -38,53 +39,6 @@
|
||||
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||
<symbol id="i-dots" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/></symbol>
|
||||
</svg>
|
||||
<div id="sb-backdrop"></div>
|
||||
<aside id="sidebar">
|
||||
<header id="vault">
|
||||
<span id="vault-badge" aria-hidden="true">🐻</span>
|
||||
<span id="vault-name">…</span>
|
||||
<div class="vault-actions">
|
||||
<button id="adminbar" class="adminbar" hidden></button>
|
||||
<button id="settings-btn" class="icon-btn2" hidden title="Manage organization" aria-label="Manage organization"><svg class="ico"><use href="#i-users"/></svg></button>
|
||||
<a id="signout" href="/auth/logout" hidden title="Sign out" aria-label="Sign out"><svg class="ico"><use href="#i-power"/></svg></a>
|
||||
</div>
|
||||
</header>
|
||||
<nav id="projects" aria-label="Projects" hidden></nav>
|
||||
<nav id="tree" aria-label="Files"></nav>
|
||||
<footer id="orgbar" hidden>
|
||||
<span id="org-name" title="Manage organization" role="button" tabindex="0"></span>
|
||||
<button id="invite-btn" hidden title="Manage this organization">Manage</button>
|
||||
</footer>
|
||||
</aside>
|
||||
<main id="main">
|
||||
<header id="topbar">
|
||||
<button id="menu-btn" class="icon-btn" title="Menu" aria-label="Menu"><svg class="ico"><use href="#i-menu"/></svg></button>
|
||||
<span id="crumb"></span>
|
||||
<span id="meta"></span>
|
||||
<button id="search-btn" class="btn ghost" title="Search (⌘K)"><svg class="ico"><use href="#i-search"/></svg> <span class="lbl">Search</span> <kbd>⌘K</kbd></button>
|
||||
<button id="share-btn" class="btn" hidden><svg class="ico"><use href="#i-share"/></svg> <span class="lbl">Share</span></button>
|
||||
<button id="history-btn" class="btn" hidden><svg class="ico"><use href="#i-hist"/></svg> <span class="lbl">History</span></button>
|
||||
<button id="upload-btn" class="btn" hidden><svg class="ico"><use href="#i-upload"/></svg> <span class="lbl">Upload</span></button>
|
||||
<input id="upload-input" type="file" hidden>
|
||||
<a id="download" class="btn" hidden download><svg class="ico"><use href="#i-download"/></svg> <span class="lbl">Download</span></a>
|
||||
<button id="more-btn" class="btn icon-only" hidden title="More actions" aria-label="More actions"><svg class="ico"><use href="#i-dots"/></svg></button>
|
||||
<div id="more-menu" hidden role="menu"></div>
|
||||
</header>
|
||||
<article id="content" class="markdown">
|
||||
<div class="empty">Select a file to read it.</div>
|
||||
</article>
|
||||
</main>
|
||||
<div id="palette-overlay" hidden>
|
||||
<div id="palette" role="dialog" aria-label="Search and quick actions">
|
||||
<div id="palette-inputwrap">
|
||||
<svg class="ico"><use href="#i-search"/></svg>
|
||||
<input id="palette-input" type="text" placeholder="Search file names, projects, actions…"
|
||||
autocomplete="off" spellcheck="false">
|
||||
</div>
|
||||
<ul id="palette-results"></ul>
|
||||
<footer id="palette-hint">↑↓ navigate · ⏎ select · esc close</footer>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user