From 4112c753fcfc6bcc2da0883d77fba597aec135ae Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 25 Sep 2026 21:39:32 -0700 Subject: [PATCH 1/2] demo and folder share update --- CLAUDE.md | 2 +- backend/gamma/routers/blocks.py | 10 +- backend/gamma/routers/shares.py | 42 +---- backend/tests/test_shares.py | 16 +- cloud/deploy/demo/.env.example | 2 +- docs/dev/api.md | 4 +- docs/dev/home_library.md | 18 ++ docs/user_guide.md | 2 +- frontend/src/README.md | 2 +- frontend/src/app/App.jsx | 198 +++++++++++++-------- frontend/src/library/library.css | 6 - frontend/src/library/libraryAccess.js | 34 ++++ frontend/src/shared/i18n/locales/zh.json | 4 +- frontend/src/shared/lib/utils.js | 7 +- frontend/src/shared/styles/app.css | 3 +- frontend/src/sharing/SharePopover.jsx | 6 +- frontend/src/sharing/SharedFolder.jsx | 50 ------ frontend/tests/e2e/scenarios/share.mjs | 27 ++- frontend/tests/e2e/scenarios/transfers.mjs | 6 +- frontend/tests/libraryAccess.test.mjs | 39 ++++ 20 files changed, 280 insertions(+), 198 deletions(-) create mode 100644 frontend/src/library/libraryAccess.js delete mode 100644 frontend/src/sharing/SharedFolder.jsx create mode 100644 frontend/tests/libraryAccess.test.mjs diff --git a/CLAUDE.md b/CLAUDE.md index 1d96fa27..3f9f972b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,7 @@ Frontend has no linter. UI changes are verified by relevant flows in the browser - Interface language ([docs/dev/i18n.md](docs/dev/i18n.md)): every user-visible string in the frontend goes through `t("English sentence")` from `src/shared/i18n/i18n.js` (`tn` for counts, `T` to mark a string in a static table); the English text is the key, `locales/zh.json` holds the Chinese. A new string needs its catalog line in the same change: `npm run i18n -- --sync` adds the empty key, `node --test tests/i18n.test.mjs` fails until it is filled; `npm run i18n:audit` lists text that never reached `t()`. `t()` works in module-level constants (the catalog loads before `App`; a language change reloads the page). Never compare against translated text. The browser suite pins `en-US` on every context. - Theme: Settings → Appearance — System plus the seven pinned themes in `THEMES` (`app/prefs.js`; `gamma-theme` in localStorage, an inline script in `index.html` applies a pinned theme before first paint) plus display-only "Flip page colors" (`gamma-pdf-dark`). - Sharing a page or a folder: `src/sharing/SharePopover.jsx` (the header link button, a popover under it like the account menu; for a folder the same popover with a folder `target` — only the words change — under the topbar's link button while a folder is open, or from the folder menu's Share…) — link + Copy + Stop sharing, access as three audience tiles (anyone / signed in / invited only) plus a View / Edit toggle, invited people with their own access, the citation section; built from the settings kit like the workspace Manage dialog. There is no "reset link" — stop and share again. Data functions stay in App.jsx (`loadShareSettings`, `updateShareSettings`, …). -- View modes are derived from the URL: `/` home, `/?page=` page (with PDF if it has `source_url`), `/?share=` the share view (`shareMode`: no library/chat/prefs; `readOnly` is state — false once the link resolves with edit rights, and `utils.withShare` puts the token on every API call; a folder share shows its listing, `sharing/SharedFolder.jsx`, and opens a page with `page=` beside the token, each a history entry), `/?block=` jump-to-block; every non-share URL also carries `ws=`. +- View modes are derived from the URL: `/` home, `/?page=` page (with PDF if it has `source_url`), `/?share=` the share view (`shareMode`: no library/chat/prefs; `readOnly` is state — false once the link resolves with edit rights, and `utils.withShare` puts the token on every API call; a folder share shows the home library confined to that folder — `library/libraryAccess.js` is the one object every home affordance asks, `root`/`browse`/`organize`/`pin`/`history`, also what makes a workspace viewer's library read-only — and opens a page with `page=` beside the token, each a history entry), `/?block=` jump-to-block; every non-share URL also carries `ws=`. - Reference links: a highlight block with `properties.link_url` / `link_page_id` is a clickable link region on the PDF; `link_highlight_id` additionally targets an exact highlight in that paper. Document links resolve against the library by DOI/arXiv id before offering fetch-vs-browser. - Home library (folder labels, merged listing, the shared `PageCard`, recents strip + snapshots, context menu): [docs/dev/home_library.md](docs/dev/home_library.md). - Menus (`src/shared/ui/Menus.jsx`): `ContextMenu` + row primitives (`MenuItem`/`MenuLabel`/`SubMenuItem`). A flyout renders INSIDE the parent menu's DOM (portalling would break the outside-pointerdown test) and opens on hover guarded by `src/shared/ui/menuAim.js` (the "safe triangle"; UI-agnostic, reuse for any hierarchical surface). diff --git a/backend/gamma/routers/blocks.py b/backend/gamma/routers/blocks.py index e273e992..82149f21 100644 --- a/backend/gamma/routers/blocks.py +++ b/backend/gamma/routers/blocks.py @@ -165,19 +165,21 @@ async def ub_get_or_create_by_doc(doc_id: str, payload: UBByDocCreate, request: @router.get("/blocks/{block_id}/children") async def ub_get_children(block_id: str, request: Request): scope = share_scope(request) - if scope is not None and block_id == "root": - # A share link may not enumerate the owner's library root (a folder - # share lists its pages through GET /share/{token}). + if scope is not None and block_id == "root" and not scope.folder: + # A page share may not enumerate the owner's library; a folder share + # lists the pages it reaches — the share view's home library. raise HTTPException(status_code=403, detail="not accessible via this share link") with connect_pages_db(resolve_ws(request)) as conn: if block_id != "root": if not conn.execute("SELECT 1 FROM unified_blocks WHERE id = ?", (block_id,)).fetchone(): raise HTTPException(status_code=404, detail="block not found") - assert_block_in_scope(conn, block_id, scope) + assert_block_in_scope(conn, block_id, scope) rows = conn.execute( f"SELECT {BLOCK_COLUMNS} FROM unified_blocks WHERE parent_id = ? ORDER BY position ASC", (block_id,), ).fetchall() + if scope is not None and block_id == "root": + rows = [r for r in rows if scope.allows_page(conn, r[0])] previews = _page_previews(conn) if block_id == "root" else None children = [block_to_dict(r) for r in rows] if previews is not None: diff --git a/backend/gamma/routers/shares.py b/backend/gamma/routers/shares.py index d45eae41..ebfe2519 100644 --- a/backend/gamma/routers/shares.py +++ b/backend/gamma/routers/shares.py @@ -37,11 +37,11 @@ from fastapi import APIRouter, HTTPException, Request from pydantic import BaseModel -from ..auth import (SHARE_AUDIENCES, SHARE_ROLES, ShareScope, note_share_miss, require_ws, - serialize_share_users, share_access, share_lookup) +from ..auth import (SHARE_AUDIENCES, SHARE_ROLES, note_share_miss, require_ws, serialize_share_users, + share_access, share_lookup) from ..blocks_store import page_attachment, root_pages from ..db import connect_pages_db, connect_users_db, page_now -from ..foldertags import clean_path, parse_tags, path_within +from ..foldertags import clean_path, path_within router = APIRouter(prefix="/api", tags=["shares"]) @@ -121,31 +121,6 @@ def _page_doc_id(ws: str, page_id: str) -> str: return attachment["id"] if attachment else "" -def _folder_pages(ws: str, folder: str) -> list[dict]: - """The share view's listing of a folder share: every page the scope - reaches, newest edit first — ``{id, title, doc_id, folders, labels, - created_at, updated_at}``.""" - scope = ShareScope(folder=folder) - pages = [] - with connect_pages_db(ws) as conn: - for page_id, content, props_raw, created_at, updated_at in conn.execute( - "SELECT id, content, properties, created_at, updated_at FROM unified_blocks " - "WHERE parent_id = 'root' ORDER BY updated_at DESC"): - try: - props = json.loads(props_raw or "{}") - except ValueError: - props = {} - if not any(path_within(tag, scope.folder) for tag in parse_tags(props.get("folder"))): - continue - attachment = page_attachment(props) - pages.append({"id": page_id, "title": content or "Untitled", - "doc_id": attachment["id"] if attachment else "", - "folders": parse_tags(props.get("folder")), - "labels": parse_tags(props.get("category")), - "created_at": created_at, "updated_at": updated_at}) - return pages - - def _validated(editor: str, current: dict, payload: ShareSettings) -> dict: audience = payload.audience if payload.audience is not None else current["audience"] role = payload.role if payload.role is not None else current["role"] @@ -318,9 +293,10 @@ async def get_share(token: str, request: Request): grant access, 403 when this signed-in account isn't allowed. Otherwise what the link shares plus what this viewer may do (``can_edit``): a page share carries ``page_id`` and ``doc_id`` (the page's PDF attachment id, - "" without one); a folder share carries ``folder`` and ``pages``, the - listing the share view shows (``_folder_pages``). ``username`` is who - shared it; ``workspace_id`` the workspace. ``viewer`` / ``viewer_is_guest`` + "" without one); a folder share carries ``folder`` — the share view then + lists it through ``GET /blocks/root/children`` like the home library. + ``username`` is who shared it; ``workspace_id`` the workspace. + ``viewer`` / ``viewer_is_guest`` tell the share view whether to offer "Open in my library" (a member) or "Add to my library" (an account that can import).""" share = share_lookup(token) @@ -336,8 +312,6 @@ async def get_share(token: str, request: Request): "username": share["created_by"], "workspace_id": share["workspace_id"], "audience": share["audience"], "role": share["role"], "can_edit": level == "edit", "viewer": request.state.user or "", "viewer_is_guest": bool(request.state.is_guest)} - if share["folder"]: - out["pages"] = _folder_pages(share["workspace_id"], share["folder"]) - else: + if share["page_id"]: out["doc_id"] = _page_doc_id(share["workspace_id"], share["page_id"]) return out diff --git a/backend/tests/test_shares.py b/backend/tests/test_shares.py index 656a21a5..671659e3 100644 --- a/backend/tests/test_shares.py +++ b/backend/tests/test_shares.py @@ -522,16 +522,18 @@ def test_folder_share_reaches_the_pages_filed_in_it(bob, anon): assert resolved.status_code == 200, resolved.text data = resolved.json() assert data["folder"] == "lab/readout" and data["page_id"] == "" and "doc_id" not in data - assert {p["id"] for p in data["pages"]} == {inside["id"], deeper["id"]} - listed = next(p for p in data["pages"] if p["id"] == deeper["id"]) - assert listed["title"] == "In a subfolder" and listed["folders"] == ["lab/readout/sub", "elsewhere"] q = {"share": token} + # the library listing through the link is the folder's pages, with previews + listing = anon.get("/api/blocks/root/children", params=q) + assert listing.status_code == 200, listing.text + children = listing.json()["children"] + assert {c["id"] for c in children} == {inside["id"], deeper["id"]} + assert next(c for c in children if c["id"] == inside["id"])["preview"] == "a note in the folder" assert anon.get(f"/api/blocks/{inside['id']}", params=q).status_code == 200 assert anon.get(f"/api/blocks/{note['id']}", params=q).status_code == 200 assert anon.get(f"/api/blocks/{deeper['id']}/subtree", params=q).status_code == 200 assert anon.get(f"/api/blocks/{outside['id']}", params=q).status_code == 403 - assert anon.get("/api/blocks/root/children", params=q).status_code == 403 assert anon.get(f"/api/pages/{inside['id']}/ops", params=q).status_code == 200 assert anon.get(f"/api/pages/{outside['id']}/ops", params=q).status_code == 403 assert anon.get(f"/api/chats/{inside['id']}", params=q).status_code == 200 @@ -545,7 +547,8 @@ def test_folder_share_reaches_the_pages_filed_in_it(bob, anon): # membership is live: a page filed later joins, one moved out leaves later = make_page(bob, "Filed later", {"folder": "lab/readout"}) assert anon.get(f"/api/blocks/{later['id']}", params=q).status_code == 200 - assert {p["id"] for p in anon.get(f"/api/share/{token}").json()["pages"]} == {inside["id"], deeper["id"], later["id"]} + assert {c["id"] for c in anon.get("/api/blocks/root/children", params=q).json()["children"]} == { + inside["id"], deeper["id"], later["id"]} bob.put(f"/api/blocks/{inside['id']}", json={"properties": {"folder": "lab/other"}}) assert anon.get(f"/api/blocks/{inside['id']}", params=q).status_code == 403 @@ -621,7 +624,8 @@ def test_folder_share_follows_renames_and_dies_with_the_folder(bob, anon): assert bob.get("/api/share-settings/folder", params={"name": "new/x"}).json()["token"] == taken assert anon.get(f"/api/share/{old_x}").status_code == 404 assert anon.get(f"/api/share/{page_token}").json()["page_id"] == page["id"] - assert {p["id"] for p in anon.get(f"/api/share/{taken}").json()["pages"]} == {page["id"], keep["id"]} + assert {c["id"] for c in anon.get("/api/blocks/root/children", params={"share": taken}).json()["children"]} == { + page["id"], keep["id"]} # deleting the folder drops its shares r = bob.post("/api/folders/rename", json={"src": "new", "dst": ""}) diff --git a/cloud/deploy/demo/.env.example b/cloud/deploy/demo/.env.example index f44f2d35..f0b14368 100644 --- a/cloud/deploy/demo/.env.example +++ b/cloud/deploy/demo/.env.example @@ -2,4 +2,4 @@ # tag; the container never sees it (its settings are demo.env). The # update-demo-server skill rewrites this line to the build it deploys: a # `sha-<7 hex>` tag that docker.yml pushed (a branch build, never :latest). -GAMMA_TAG=sha-30586ef +GAMMA_TAG=sha-39bb6e6 diff --git a/docs/dev/api.md b/docs/dev/api.md index 76f249f6..a7603c27 100644 --- a/docs/dev/api.md +++ b/docs/dev/api.md @@ -165,7 +165,7 @@ never returned by the generic endpoint. | Method | Path | Purpose | |---|---|---| | GET/POST | `/blocks/by-doc/{doc_id}` | lookup / create the page BY ATTACHMENT — the page whose PDF is `doc_id` (POST creates it: `{default_title, source_url?, original_filename?, folder?}`, `folder` files a NEW page only); the PDF-ingest + extension-dedup path, and what "Open as document" on a PDF file chip calls (the file is already stored under that hash). Text-only pages come from `POST /pages` | -| GET | `/blocks/{id}/children`, `/{id}/subtree`, `/{id}/backlinks` | tree reads; the root listing (`/blocks/root/children`) additionally gives every page a `preview` — the first ~240 chars of its first non-highlight child blocks joined with ` · ` (one window query, `""` when empty) | +| GET | `/blocks/{id}/children`, `/{id}/subtree`, `/{id}/backlinks` | tree reads; the root listing (`/blocks/root/children`) — through a folder share token, only the pages the share reaches (the share view's home library); 403 through a page share — additionally gives every page a `preview` — the first ~240 chars of its first non-highlight child blocks joined with ` · ` (one window query, `""` when empty) | | POST/PUT/DELETE | `/blocks`, `/blocks/{id}` | CRUD — inside a page these are thin wrappers over the op path (`gamma/ops.py`): logged, fanned out to the page's room; `PUT` takes `content` and/or a properties PATCH (a null value deletes the key). A new page (`parent_id: "root"`) is a plain insert (`blocks_store.create_page`); deleting a page is `ops.delete_page`: subtree + its op log gone, a `deleted_pages` tombstone left | | PUT | `/blocks/{id}/children` | replace the whole subtree (delete + reinsert; triggers orphan-upload cleanup) — bulk paths only (imports, tests); the page's room gets a `reload`. The editor itself sends ops | | POST | `/blocks/{id}/reorder` | move within the page (an op) or, with `parent_id` on another page, across pages (the source room sees a `delete`, the target reloads) | @@ -227,7 +227,7 @@ guarded fetch path. | GET/PUT/DELETE | `/share-settings/folder?name=` | the folder share's settings (`{token: null}` when unshared; any member) / changes / stop — exactly like a page's; the share follows folder renames through `/folders/rename` | | POST | `/share/{page_id}` | create the page's share link (defaults `anyone`/`view`; optional body `{audience, role, users}` applies to a NEW link) or return the existing one unchanged — root blocks only (400 otherwise); workspace editors and owners | | GET/PUT/DELETE | `/share-settings/{page_id}` | read settings (`{token: null}` when unshared; any member) / change `audience`, `role`, `users` (`["carol"]` or `[{name, role}]`; validated: unknown usernames or roles → 400; the token stays; `edit`+`anyone` is allowed — see "Link visitors" above) / stop sharing (the token dies) — editors and owners | -| GET | `/share/{token}` | resolve a link for this viewer → `{page_id, folder, username (who shared it), workspace_id, audience, role, can_edit, viewer, viewer_is_guest}` plus, for a page share, `doc_id` (the page's PDF attachment id via `page_attachment`, `""` without one) or, for a folder share, `pages` (the share view's listing, `[{id, title, doc_id, folders, labels, created_at, updated_at}]`, newest edit first); `viewer`/`viewer_is_guest` let the share view offer "Open in my library" or "Add to my library"; 404 unknown, 401 sign in first, 403 signed in but not allowed | +| GET | `/share/{token}` | resolve a link for this viewer → `{page_id, folder, username (who shared it), workspace_id, audience, role, can_edit, viewer, viewer_is_guest}` plus, for a page share, `doc_id` (the page's PDF attachment id via `page_attachment`, `""` without one); a folder share's listing is `GET /blocks/root/children` through the token; `viewer`/`viewer_is_guest` let the share view offer "Open in my library" or "Add to my library"; 404 unknown, 401 sign in first, 403 signed in but not allowed | ### Search (`search.py`, `gamma/block_index.py`, `gamma/pdf_index.py`) | Method | Path | Purpose | diff --git a/docs/dev/home_library.md b/docs/dev/home_library.md index 92ffacf4..de093f7f 100644 --- a/docs/dev/home_library.md +++ b/docs/dev/home_library.md @@ -5,6 +5,24 @@ Code: [FileBrowser.jsx](../../frontend/src/library/FileBrowser.jsx), [libraryUtils.js](../../frontend/src/library/libraryUtils.js), [Menus.jsx](../../frontend/src/shared/ui/Menus.jsx), glue in App.jsx. +## What a viewer may do + +[libraryAccess.js](../../frontend/src/library/libraryAccess.js) derives ONE +object from how the library was reached — `lib` in App.jsx — and every +affordance of the home library asks it, never a role or a share token: `root` +(the folder the view is confined to: a folder share's folder, "" otherwise; +`openFolder` clamps into it, the back row and the breadcrumb stop at it), +`browse` (there is a library to list — a page share has none), `organize` +(New page / New folder, drags and drops, rename, move, duplicate, delete, +labels, sharing a folder, file drops onto the library), `pin` (the pin +buttons and the pinned strip) and `history` (the recents strip). A workspace +viewer browses everything and organizes nothing; a folder share's visitor +browses the shared folder only — the share view IS the home library at that +folder, listed through `GET /blocks/root/children` with the token +([api.md](api.md) "Shares"), its pages opening in the same view (`page=` +beside the token, each a history entry) and the topbar's home button +returning to the folder. + Quick open ([QuickOpen.jsx](../../frontend/src/library/QuickOpen.jsx)) is the keyboard way into the library from anywhere: Ctrl+P (App.jsx's global key listener; not in a share view) opens a palette over the pages. With no diff --git a/docs/user_guide.md b/docs/user_guide.md index 526057f2..3953640d 100644 --- a/docs/user_guide.md +++ b/docs/user_guide.md @@ -130,7 +130,7 @@ The **link button** in the top bar shares the open page, Notion-style: - Viewers see the PDF, highlights and notes, no login needed; editors edit alongside you, with live cursors. A visitor editing through an anyone-with-the-link share is asked for a display name. - **Stop sharing** ends the link; share again for a new one. Copied links carry the workspace, so a teammate opening one lands in the right library. -**Sharing a folder** works the same way: right-click a folder and choose **Share…**, or open the folder and press the top bar's link button. The link opens every page filed in the folder — including pages you file there later — as a small library; visitors click a page to read it and return with the home button. The same audience and View / Edit choices apply; an edit link lets people edit those pages' notes but never move pages in or out of the folder. +**Sharing a folder** works the same way: right-click a folder and choose **Share…**, or open the folder and press the top bar's link button. The link opens every page filed in the folder — including pages you file there later — as a read-only library view of that folder; visitors open a page like you would and return with the home button. The same audience and View / Edit choices apply; an edit link lets people edit those pages' notes but never move pages in or out of the folder. ## Workspaces diff --git a/frontend/src/README.md b/frontend/src/README.md index a2a79e11..1aca1e94 100644 --- a/frontend/src/README.md +++ b/frontend/src/README.md @@ -17,7 +17,7 @@ through barrel files. `main.jsx` remains the Vite entry point. | `pdf/` | `PdfViewer.jsx`, document loading, citations, translation, and scroll alignment | | `search/` | Workspace search (`SearchPanel.jsx`) | | `settings/` | `SettingsDialog.jsx`, individual settings panes, shared pane controls (`SettingsKit.jsx`), the profile sync reading (`syncState.js`), navigation, integration setup, and `settings.css` | -| `sharing/` | The Share popover (`SharePopover.jsx`, a page or a folder as its target): link, access, invited people, stop sharing; the share view's folder listing (`SharedFolder.jsx`) | +| `sharing/` | The Share popover (`SharePopover.jsx`, a page or a folder as its target): link, access, invited people, stop sharing | | `support/` | Report a problem: the dialog (`ReportProblem.jsx`) and the pure report builder it and the tests share (`problemReport.js`) | | `transfers/` | Import/export dialogs (`ImportExport.jsx`), the import review (`ImportReviewDialog.jsx`, `ImportTree.jsx`, `importApi.js`, `importReview.js`), format rules, and upload/file chips (`FileChip.jsx`) | | `shared/model/` | Block tree helpers (`blockModel.js`), block operations (`blockOps.js`), and highlight colors | diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index e9efcb99..50e26b3f 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -11,7 +11,7 @@ import { ExportDialog, ImportDialog } from "../transfers/ImportExport"; import ImportReviewDialog from "../transfers/ImportReviewDialog"; import { parseGammaLink } from "../shared/model/gammaLinks.js"; import { pageHostUser, publicPath } from "../shared/lib/slug.js"; -import { API, apiJson, setShareView, withShare, withWorkspace, setCurrentWorkspace, getCurrentWorkspace, setLinkName, makeId, fmtBytes, getDocIdForUrl, isPdfFile, isMarkdownFile, metaSourceInfo, resolvePdfUrl, pdfProxyUrl, probePdfUrl, setExpectedUser, getExpectedUser, usePersistedState, usePersistedFlag, copyText, copyRich, readNdjson } from "../shared/lib/utils"; +import { API, apiJson, getShareToken, setShareView, withShare, withWorkspace, setCurrentWorkspace, getCurrentWorkspace, setLinkName, makeId, fmtBytes, getDocIdForUrl, isPdfFile, isMarkdownFile, metaSourceInfo, resolvePdfUrl, pdfProxyUrl, probePdfUrl, setExpectedUser, getExpectedUser, usePersistedState, usePersistedFlag, copyText, copyRich, readNdjson } from "../shared/lib/utils"; import { BlockDropIndicator, ChatMarkdown, @@ -102,7 +102,7 @@ import GuideOverlay from "../guide/GuideOverlay"; import { guideEvents } from "../guide/events"; import { Empty, QuotaMeter, Section } from "../settings/SettingsKit"; import { CopyBox, SharePopover } from "../sharing/SharePopover"; -import { SharedFolder } from "../sharing/SharedFolder"; +import { libraryAccess } from "../library/libraryAccess"; import { MirrorPopover } from "../collaboration/MirrorPopover"; import { addFolderTag, @@ -179,9 +179,12 @@ try { const old = localStorage.getItem("gamma-home-kinds"); if (old && old !== " // be active at once (a label view opened inside a folder). function homeUrlFor(folder, label) { const q = []; + const share = getShareToken(); // a folder share's library: the token names the workspace + if (share) q.push(`share=${encodeURIComponent(share)}`); if (folder) q.push(`folder=${encodeURIComponent(folder)}`); if (label) q.push(label === NO_LABEL ? "unlabelled=1" : `category=${encodeURIComponent(label)}`); - return withWorkspace(q.length ? `/?${q.join("&")}` : "/"); + const url = q.length ? `/?${q.join("&")}` : "/"; + return share ? url : withWorkspace(url); } // Folder uploads tag each PDF with its directory path as a folder label: @@ -406,8 +409,8 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { const shareMode = Boolean(initialShare) || Boolean(publicPage); const [readOnly, setReadOnly] = useState(shareMode); const [shareInfo, setShareInfo] = useState(null); // resolved share: {owner, role, canEdit, audience, viewer} - // A folder share's listing ({name, pages}): the share view shows it until - // a card opens one of its pages, and the topbar's home button returns to it. + // A folder share ({name}): the share view is then the home library confined + // to that folder until a page opens; the topbar's home button returns to it. const [sharedFolder, setSharedFolder] = useState(null); // "login" | "forbidden" | "missing" while the share can't open const [shareGate, setShareGate] = useState(publicPage?.missing ? "missing" : null); @@ -422,6 +425,12 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { const [workspace, setWorkspace] = useState(null); // {id, name, role, personal, members} const [workspaces, setWorkspaces] = useState([]); const [wsReady, setWsReady] = useState(shareMode); + // What this viewer may do with the library itself (library/libraryAccess.js): + // every affordance of the home listing asks this, never a role or a share. + const lib = useMemo( + () => libraryAccess({ shareMode, shareFolder: sharedFolder?.name || "", role: workspace?.role || "" }), + [shareMode, sharedFolder, workspace], + ); const [workspaceUnavailable, setWorkspaceUnavailable] = useState(false); const wsId = workspace?.id || ""; @@ -1082,6 +1091,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { const handleFolderClick = (path, e) => handleContainerClick("folder", path, e); const handleLabelClick = (name, e) => handleContainerClick("label", name, e); function openFolder(path) { + path = lib.clamp(path); clearSelection(); setFolderFilter(path); setCategoryFilter(""); @@ -1388,6 +1398,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { // a page-card drag moves the dragged (or whole selected) pages. The back-row // overrides onPages to remove from the open folder instead. function dropOnFolder(e, target, onPages = (ids) => addPagesToFolder(ids, target)) { + if (!lib.organize) return; e.preventDefault(); setFolderDragOver(null); const folders = droppedFolderPaths(e); @@ -1402,6 +1413,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { // Drop dispatch for label rows/tiles: pages get the label, folder drags are // ignored (a folder can't be "labelled" — its papers each carry their own). function dropOnLabel(e, name, onPages = (ids) => (name === NO_LABEL ? clearPagesLabels(ids) : addPagesToLabel(ids, name))) { + if (!lib.organize) return; e.preventDefault(); setFolderDragOver(null); if (droppedFolderPaths(e)) { setStatus(t("Folders can’t carry labels — drop pages instead.")); return; } @@ -3516,8 +3528,12 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { function fetchHomeBlocks() { return apiJson(`${API}/blocks/root/children`) - .then((data) => setHomeBlocks(Array.isArray(data.children) ? data.children : [])) - .catch(() => setHomeBlocks([])); + .then((data) => { + const children = Array.isArray(data.children) ? data.children : []; + setHomeBlocks(children); + return children; + }) + .catch(() => { setHomeBlocks([]); return []; }); } useEffect(() => { @@ -4783,15 +4799,17 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { } if (data.folder) { - // A folder share: its listing, or — with `page=` in the URL — one of - // its pages (goSharedPage keeps the two in the history). - const pages = data.pages || []; - setSharedFolder({ name: data.folder, pages }); + // A folder share: the home library confined to that folder (the + // listing the token may read), or — with `page=` in the URL — one + // of its pages; goSharedPage keeps the two in the history. + setSharedFolder({ name: data.folder }); setReadOnly(!data.can_edit); - if (initialBlockId && pages.some((p) => p.id === initialBlockId)) { + const within = (path) => path === data.folder || path.startsWith(data.folder + "/"); + setFolderFilter(initialFolder && within(initialFolder) ? initialFolder : data.folder); + const pages = await fetchHomeBlocks(); + if (initialBlockId && pages.some((b) => b.id === initialBlockId)) { await openSharedPage(token, initialBlockId, data); } else { - setPageTitle(data.folder); setStatus(t("Loaded shared folder.")); } return; @@ -4849,42 +4867,40 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { setStatus(share.can_edit ? t("Shared by {username} — your edits save to their page.", { username: share.username }) : t("Loaded shared page.")); } - // A folder share's navigation between its listing ("") and a page, each a - // history entry (`page=` beside the token); popstate replays it without - // pushing. The ref keeps the once-registered listener on the latest closure. - function goSharedPage(pageId, { push = true } = {}) { + // A folder share's navigation between its library ("" — at `folder`, else + // where it was) and a page, each a history entry beside the token (`page=` + // / `folder=`); popstate replays an entry without pushing. The ref keeps + // the once-registered listener on the latest closure. + function goSharedPage(pageId, { push = true, folder } = {}) { if (!sharedFolder) return; - if (push) { - const url = `${window.location.pathname}?share=${encodeURIComponent(initialShare)}${pageId ? `&page=${encodeURIComponent(pageId)}` : ""}`; - window.history.pushState(null, "", url); - } if (pageId) { + if (push) window.history.pushState(null, "", `${window.location.pathname}?share=${encodeURIComponent(initialShare)}&page=${encodeURIComponent(pageId)}`); setLoading(true); openSharedPage(initialShare, pageId, { can_edit: !!shareInfo?.canEdit, username: shareInfo?.owner || "" }) .catch((err) => setStatus(t("Share open failed: {message}", { message: err.message }))) .finally(() => setLoading(false)); return; } - leaveCurrentPage(); - setFocusedBlockId(""); - setFocusedBlock(null); - setBlocks([]); - setDocId(""); - setInputUrl(""); - setPdfUrl(""); - setPageTitle(sharedFolder.name); + const target = lib.clamp(folder ?? folderFilter); + if (push) window.history.pushState(null, "", homeUrlFor(target, "")); + goHome(true, true); // the library, refreshed — the same path as the home button + openFolder(target); } const goSharedPageRef = useRef(goSharedPage); goSharedPageRef.current = goSharedPage; useEffect(() => { if (!shareMode) return undefined; - const onPop = () => goSharedPageRef.current(new URLSearchParams(window.location.search).get("page") || "", { push: false }); + const onPop = () => { + const params = new URLSearchParams(window.location.search); + goSharedPageRef.current(params.get("page") || "", { push: false, folder: params.get("folder") || "" }); + }; window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, [shareMode]); async function openBlock(blockId, opts) { - if (!blockId || shareMode) return; + if (!blockId) return; + if (shareMode) { goSharedPage(blockId); return; } // Back records LINK jumps only — callers opt in via {pushNav: true}. // Plain navigation (library, search, tabs, home) never pushes. if (opts?.pushNav && blockId !== focusedBlockId) pushNav(); @@ -6261,7 +6277,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { // What the open page carries — THE switch for layout and page-level // affordances (docs/dev/block_centric.md). pdfUrl is only the viewer's input. const pageAttach = useMemo(() => pageAttachment(focusedBlock), [focusedBlock]); - const homeMode = !focusedBlockId && !shareMode; + const homeMode = !focusedBlockId && lib.browse; bindingsRef.current = keybindings; appCmdRef.current = { shareMode, homeMode, readOnly, hasPage: !!focusedBlockId && !homeMode, hasPdf: !!pdfUrl && !homeMode, @@ -6645,18 +6661,19 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { const homeVisibleItems = useMemo(() => homeItems.slice(0, homeShowCount), [homeItems, homeShowCount]); // "New folder" leads the listing wherever folders are listed — not inside a // label view or with the listing filtered to files or labels. - const newFolderAllowed = !categoryFilter && homeKinds !== "files" && homeKinds !== "labels"; + const newFolderAllowed = lib.organize && !categoryFilter && homeKinds !== "files" && homeKinds !== "labels"; // "New page" is the first item of the listing itself (like "New folder") — // Notion-style: creating a page needs no file. Not inside a label view // (pages are created plain, then labelled) nor when only folders show. - const newPageAllowed = !categoryFilter && homeKinds !== "folders" && homeKinds !== "labels"; + const newPageAllowed = lib.organize && !categoryFilter && homeKinds !== "folders" && homeKinds !== "labels"; // What an empty listing says — the view it is empty for, not the library. const homeEmptyText = categoryFilter === NO_LABEL ? t("Every page here carries a label.") : categoryFilter ? t("Nothing is labelled “{categoryFilter}” here — drop a page on a label to add it.", { categoryFilter }) : homeKinds === "labels" ? (folderFilter ? t("No labels on the pages in this folder yet.") : t("No labels yet — add one from a page’s label field.")) - : folderFilter ? t("This folder is empty — start a page here or drag pages onto it from the library.") : t("No pages yet — start with “New page”, or open a PDF from the + button above."); + : folderFilter ? (lib.organize ? t("This folder is empty — start a page here or drag pages onto it from the library.") : t("This folder is empty.")) + : t("No pages yet — start with “New page”, or open a PDF from the + button above."); // Timestamp shown on a library card follows the active sort: sorted by view // time → viewed (falling back to modified, same as the sort), by added → // created; modified otherwise (incl. Title A–Z). @@ -7648,7 +7665,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { {/* Recently-viewed shortcut strip. Labels are browsed like folders (the kind toggle's Labels mode) and shown as chips on each row, so this is the only carousel left. */} - {homeMode && recentViewedPages.length > 0 ? ( + {homeMode && lib.history && recentViewedPages.length > 0 ? ( {recentViewedPages.map((b) => ( } preview={b._preview} @@ -7668,7 +7685,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { ))} ) : null} - {homeMode && !categoryFilter && !folderFilter && pinnedItems.length > 0 ? ( + {homeMode && lib.pin && !categoryFilter && !folderFilter && pinnedItems.length > 0 ? (
{t("Pinned")}
@@ -7717,7 +7734,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { ) : null} {homeMode && (folderFilter || categoryFilter) ? (
- {folderFilter && !categoryFilter ? ( + {folderFilter && !categoryFilter && folderFilter !== lib.root ? (
{ @@ -7730,11 +7747,11 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { const parent = folderFilter.includes("/") ? folderFilter.slice(0, folderFilter.lastIndexOf("/")) : ""; dropOnFolder(e, parent, (ids) => removePagesFromFolder(ids, folderFilter)); }} - title={t("Back — or drop a page or folder here to move it out of this folder")} + title={lib.organize ? t("Back — or drop a page or folder here to move it out of this folder") : t("Back")} > {folderFilter.includes("/") ? folderFilter.slice(0, folderFilter.lastIndexOf("/")) : t("All files")} - {t("drop here to move out of this folder")} + {lib.organize ? {t("drop here to move out of this folder")} : null}
) : null} {/* The label view gets the same back row: it drops the @@ -7746,7 +7763,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { onClick={closeLabel} // Inside "No label" there is no label to take off — the // back row is plain navigation there. - {...(categoryFilter === NO_LABEL ? { title: T("Back") } : { + {...(categoryFilter === NO_LABEL || !lib.organize ? { title: T("Back") } : { onDragOver: (e) => { e.preventDefault(); setFolderDragOver("__label_up__"); }, onDragLeave: () => setFolderDragOver(null), onDrop: (e) => dropOnLabel(e, categoryFilter, (ids) => removePagesFromLabel(ids, categoryFilter)), @@ -7755,17 +7772,19 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { > {folderFilter || t("All files")} - {categoryFilter === NO_LABEL ? null : {t("drop here to remove this label")}} + {categoryFilter === NO_LABEL || !lib.organize ? null : {t("drop here to remove this label")}}
) : null}
{categoryFilter ? : } - {/* Breadcrumb: every path segment navigates to its level */} + {/* Breadcrumb: every path segment navigates to its level — + from the library's root on (a folder share starts at its folder) */} {(folderFilter ? folderFilter.split("/") : []).map((seg, i, segs) => { const prefix = segs.slice(0, i + 1).join("/"); + if (!lib.contains(prefix)) return null; return ( - {i > 0 ? / : null} + {i > 0 && lib.contains(segs.slice(0, i).join("/")) ? / : null} ); @@ -7778,7 +7797,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { ) : ( )} @@ -7889,7 +7908,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { key={item.key} {...folderCardProps(f)} className={`${dim} ${folderDragOver === f ? "dragOver" : ""} ${selectedFolders.has(f) ? "selected" : ""}`} - tip={t("Click to select · double-click to open · drop a page or folder to move it in")} + tip={lib.organize ? t("Click to select · double-click to open · drop a page or folder to move it in") : t("Click to select · double-click to open")} time={cardTime(item)} renameNode={folderRenaming?.name === f ? ( renameFolder(f, e.currentTarget.value)} /> ) : null} - draggable={folderRenaming?.name !== f} + draggable={lib.organize && folderRenaming?.name !== f} onDoubleClick={() => { if (folderRenaming?.name !== f) openFolder(f); }} /> ); } @@ -7936,17 +7955,19 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { onBlur={(e) => commitPageRename(id, e.currentTarget.value)} /> ) : null} - draggable={!isEditing} + draggable={lib.organize && !isEditing} onDragStart={(e) => { e.dataTransfer.setData("text/plain", id); e.dataTransfer.effectAllowed = "move"; }} onClick={(e) => handlePageClick(b, e)} onDoubleClick={() => { if (!isEditing) openPage(id); }} onContextMenu={openPageMenu(id, b.content)} > - + {lib.pin ? ( + + ) : null} ); })} @@ -8004,7 +8025,8 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { onDragLeave={() => setFolderDragOver(null)} onDrop={(e) => dropOnLabel(e, l)} title={l === NO_LABEL - ? t("Pages without any label · double-click to open · drop a page to clear its labels") : t("Click to select · double-click to open · right-click to rename or delete · drop a page to label it")} + ? t("Pages without any label · double-click to open · drop a page to clear its labels") + : lib.organize ? t("Click to select · double-click to open · right-click to rename or delete · drop a page to label it") : t("Click to select · double-click to open")} > {labelTitle(l)} @@ -8015,7 +8037,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) {
{ e.dataTransfer.setData("text/plain", FOLDER_DRAG + f); e.dataTransfer.effectAllowed = "move"; }} onClick={(e) => handleFolderClick(f, e)} onDoubleClick={() => { if (folderRenaming?.name !== f) openFolder(f); }} @@ -8023,7 +8045,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { onDragOver={(e) => { e.preventDefault(); setFolderDragOver(f); }} onDragLeave={() => setFolderDragOver(null)} onDrop={(e) => dropOnFolder(e, f)} - title={t("Click to select · double-click to open · right-click to rename or delete · drop a page or folder to move it in")} + title={lib.organize ? t("Click to select · double-click to open · right-click to rename or delete · drop a page or folder to move it in") : t("Click to select · double-click to open")} > {folderRenaming?.name === f ? ( @@ -8053,7 +8075,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) {
{ e.dataTransfer.setData("text/plain", id); e.dataTransfer.effectAllowed = "move"; }} onClick={(e) => handlePageClick(b, e)} onDoubleClick={() => { if (!isEditing) openPage(id); }} @@ -8079,11 +8101,13 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { openTagMenu("label", l)} /> {pageKindLabel(b._attachment)} - + {lib.pin ? ( + + ) : null}
); })} @@ -8094,8 +8118,6 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { ) : null} - ) : shareMode && sharedFolder && !focusedBlockId ? ( - goSharedPage(id)} /> ) : ( visibleBlocks.length === 0 ? ( notesTail ||
{t("No blocks yet.")}
@@ -8758,7 +8780,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { {openPopover === "share" && shareTarget?.kind === "page" ? sharePopover : null} - ) : homeMode && folderFilter && !categoryFilter ? ( + ) : homeMode && lib.organize && folderFilter && !categoryFilter ? ( // The same button for the open folder: one link for every page filed in it. - {pageTitle} + {pageTitle || sharedFolder?.name || ""} {shareInfo ? ( @@ -9891,13 +9913,18 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { {!many ? ( { setHomeMenu(null); clearSelection(); openBlock(homeMenu.id, { restoreScroll: true }); }}>{t("Open")} ) : null} - {!many ? ( + {!many && lib.organize ? ( { setHomeMenu(null); clearSelection(); setHomeEditingId(homeMenu.id); }}>{t("Rename")} ) : null} - { setHomeMenu(null); setPagesPinned(ids, !allPinned); }}> - {allPinned ? t("Unpin") : many ? t("Pin {n} pages", { n: ids.length }) : t("Pin")} - + {lib.pin ? ( + { setHomeMenu(null); setPagesPinned(ids, !allPinned); }}> + {allPinned ? t("Unpin") : many ? t("Pin {n} pages", { n: ids.length }) : t("Pin")} + + ) : null} + {lib.organize ? ( { setHomeMenu(null); duplicatePages(ids); }}>{many ? t("Duplicate {n} pages", { n: ids.length }) : t("Duplicate")} + ) : null} + {lib.organize ? ( { setHomeMenu(null); removePagesFromFolder(ids, ""); }}>{t("All folders")} ) : null} + ) : null} + {lib.organize ? ( { setHomeMenu(null); deletePages(ids); }}>{many ? t("Delete {n} pages", { n: ids.length }) : t("Delete")} + ) : null} ); })() : homeMenu.kind === "label" ? ( <> { const name = homeMenu.name; setHomeMenu(null); if (!homeMode) goHome(); openLabel(name, homeMode ? folderFilter : ""); }}>{t("Open")} - { setHomeMenu(null); setLabelRenaming({ name: homeMenu.name, draft: homeMenu.name }); }}>{t("Rename")} - { setHomeMenu(null); deleteLabelByName(homeMenu.name); }}>{t("Delete")} + {lib.organize ? ( + <> + { setHomeMenu(null); setLabelRenaming({ name: homeMenu.name, draft: homeMenu.name }); }}>{t("Rename")} + { setHomeMenu(null); deleteLabelByName(homeMenu.name); }}>{t("Delete")} + + ) : null} ) : ( <> { const name = homeMenu.name; setHomeMenu(null); if (!homeMode) goHome(); openFolder(name); }}>{t("Open")} - { setHomeMenu(null); setFolderRenaming({ name: homeMenu.name, draft: homeMenu.name }); }}>{t("Rename")} - { const name = homeMenu.name; setHomeMenu(null); openFolderShare(name); }}>{t("Share…")} - {(() => { + {lib.organize ? ( + <> + { setHomeMenu(null); setFolderRenaming({ name: homeMenu.name, draft: homeMenu.name }); }}>{t("Rename")} + { const name = homeMenu.name; setHomeMenu(null); openFolderShare(name); }}>{t("Share…")} + + ) : null} + {lib.pin ? (() => { // Like pages: acting on a selected folder acts on the whole selection const paths = selectedFolders.size > 1 && selectedFolders.has(homeMenu.name) ? [...selectedFolders] : [homeMenu.name]; const allPinned = paths.every((p) => pinnedFolders.some((q) => q.path === p)); @@ -9951,13 +9989,15 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { {allPinned ? t("Unpin") : paths.length > 1 ? t("Pin {n} folders", { n: paths.length }) : t("Pin")} ); - })()} + })() : null} { const name = homeMenu.name; setHomeMenu(null); setExportFolder(name); setExportOpen(true); }} >{t("Export…")} + {lib.organize ? ( { setHomeMenu(null); deleteFolderByName(homeMenu.name); }}>{t("Delete")} + ) : null} )} diff --git a/frontend/src/library/library.css b/frontend/src/library/library.css index a384c491..f52f1313 100644 --- a/frontend/src/library/library.css +++ b/frontend/src/library/library.css @@ -529,9 +529,3 @@ .quickOpenTag { flex-shrink: 0; font-style: normal; font-size: calc(10px * var(--ui-font-scale, 1)); color: var(--text-dim); } .quickOpen .chatMentionHint { padding: 6px 6px 0; } .quickOpenKey { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } - -/* The share view of a folder share (sharing/SharedFolder.jsx): the folder's - name row over the home library's card grid. */ -.sharedFolder { padding: 4px 8px 16px; } -.sharedFolder .folderHint { font-weight: 400; } -.sharedFolder .pageCard { cursor: pointer; } diff --git a/frontend/src/library/libraryAccess.js b/frontend/src/library/libraryAccess.js new file mode 100644 index 00000000..d77ba59f --- /dev/null +++ b/frontend/src/library/libraryAccess.js @@ -0,0 +1,34 @@ +// What the person may do with the LIBRARY they are looking at — the home +// listing, its folders and labels — as opposed to a page's notes (that is +// App's `readOnly`). One object derived from how the library was reached, +// consulted by every affordance of the home library: the "New page" / "New +// folder" rows, drags and drops, the context menus, pins, the recents and +// pinned strips, and how far up the folder browser may go. Nothing in the +// listing checks a role or a share token itself. +// +// root the folder the view is confined to ("" = the whole library): +// a folder share's folder — the browser never climbs above it +// browse whether there is a library to list at all (a page share has none) +// organize create, rename, move, duplicate, delete, label, file — every +// write to the library's structure, incl. sharing a folder +// pin pins and the pinned strip (page pins are a block write) +// history the recents strip and the account's view history +// contains whether a folder path is inside `root` +// clamp that path when it is, else `root` +// +// A workspace viewer browses everything but organizes nothing; a share +// visitor browses the shared folder only. +export function libraryAccess({ shareMode = false, shareFolder = "", role = "" } = {}) { + const root = shareMode ? shareFolder : ""; + const organize = !shareMode && role !== "viewer"; + const contains = (path) => !root || path === root || path.startsWith(root + "/"); + return { + root, + browse: !shareMode || !!shareFolder, + organize, + pin: organize, + history: !shareMode, + contains, + clamp: (path) => (contains(path || "") ? path || "" : root), + }; +} diff --git a/frontend/src/shared/i18n/locales/zh.json b/frontend/src/shared/i18n/locales/zh.json index da4500df..7a8c3315 100644 --- a/frontend/src/shared/i18n/locales/zh.json +++ b/frontend/src/shared/i18n/locales/zh.json @@ -377,6 +377,7 @@ "Click to add a block below\nAlt+click to add above": "点击在下方添加块\nAlt+点击在上方添加", "Click to edit": "点击编辑", "Click to rename": "点击重命名", + "Click to select · double-click to open": "单击选择 · 双击打开", "Click to select · double-click to open · drop a page or folder to move it in": "单击选择 · 双击打开 · 拖入页面或文件夹以移入", "Click to select · double-click to open · drop a page to label it": "单击选择 · 双击打开 · 拖入页面以添加此标签", "Click to select · double-click to open · right-click to rename or delete · drop a page or folder to move it in": "单击选择 · 双击打开 · 右键重命名或删除 · 拖入页面或文件夹以移入", @@ -1259,7 +1260,6 @@ "No new items were added.": "没有新增条目。", "No other accounts": "没有其他账户", "No other pages in your library yet.": "你的文库中还没有其他页面。", - "No pages are filed in this folder yet.": "此文件夹中还没有页面。", "No pages carry {which}.": "没有页面带有{which}。", "No pages match “{docPickerQuery}”.": "没有页面匹配“{docPickerQuery}”。", "No pages yet — create one first.": "还没有页面——请先创建一个。", @@ -2037,6 +2037,7 @@ "This browser cannot record the screen.": "此浏览器无法录制屏幕。", "This cannot be undone.": "此操作无法撤销。", "This folder is empty — start a page here or drag pages onto it from the library.": "此文件夹为空——在此新建页面,或从文库拖入页面。", + "This folder is empty.": "此文件夹为空。", "This handwriting note is full. Start a new note before duplicating.": "此手写笔记已满。请先新建笔记再复制。", "This highlight has a note": "此高亮带有笔记", "This is your folder — open it in your library instead of the shared view": "这是你的文件夹——请在你的文库中打开,而不是分享视图", @@ -2600,7 +2601,6 @@ "{chosenCount} selected items · Please keep this dialog open.": "{chosenCount} 个所选条目 · 请保持此对话框打开。", "{chosen} of {total} items selected · {shown} shown": "已选 {chosen}/{total} 个条目 · 显示 {shown} 个", "{connected} · ChatGPT subscription": "{connected} · ChatGPT 订阅", - "{content}\nClick to open": "{content}\n单击打开", "{content}\nClick to select · double-click to open": "{content}\n单击选择 · 双击打开", "{count} {word}{_s}": "{count} {word}", "{ctxText} — the last reply's prompt and answer in {model}'s context window": "{ctxText} — 上一条回复的提示与回答占 {model} 上下文窗口的比例", diff --git a/frontend/src/shared/lib/utils.js b/frontend/src/shared/lib/utils.js index d49b6d3c..e5246e82 100644 --- a/frontend/src/shared/lib/utils.js +++ b/frontend/src/shared/lib/utils.js @@ -331,6 +331,11 @@ function setShareView(token) { SHARE_TOKEN = token || ""; SHARE_VIEW = true; } +// The share view's token (""), for in-app URLs that must stay in the share — +// a folder share's library navigation (App's homeUrlFor). +function getShareToken() { + return SHARE_TOKEN; +} function withShare(url) { if (!SHARE_TOKEN || typeof url !== "string" || !url.startsWith(`${API}/`)) return url; if (/[?&]share=/.test(url)) return url; @@ -398,4 +403,4 @@ async function readNdjson(res, onBatch) { } } -export { API, makeId, fmtBytes, sha256, getDocIdForUrl, isPdfFile, isMarkdownFile, isUnverifiedPaperMeta, metaSourceInfo, apiJson, setShareView, withShare, withWorkspace, assetUrl, setCurrentWorkspace, getCurrentWorkspace, setLinkName, getLinkName, resolvePdfUrl, pdfProxyUrl, probePdfUrl, setExpectedUser, getExpectedUser, usePersistedState, usePersistedFlag, copyText, copyRich, readNdjson }; +export { API, makeId, fmtBytes, sha256, getDocIdForUrl, isPdfFile, isMarkdownFile, isUnverifiedPaperMeta, metaSourceInfo, apiJson, setShareView, getShareToken, withShare, withWorkspace, assetUrl, setCurrentWorkspace, getCurrentWorkspace, setLinkName, getLinkName, resolvePdfUrl, pdfProxyUrl, probePdfUrl, setExpectedUser, getExpectedUser, usePersistedState, usePersistedFlag, copyText, copyRich, readNdjson }; diff --git a/frontend/src/shared/styles/app.css b/frontend/src/shared/styles/app.css index 7f3ff977..efa94335 100644 --- a/frontend/src/shared/styles/app.css +++ b/frontend/src/shared/styles/app.css @@ -5497,4 +5497,5 @@ html.appFocusFullscreen, html.appFocusFullscreen body { overflow: hidden; oversc border-bottom-width: 2px; border-radius: 4px; } -.sharePopover .sharePopoverTarget { display: inline-flex; align-items: center; gap: 4px; margin-left: 8px; max-width: 45%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.sharePopover .popoverTitle { display: inline-flex; align-items: center; gap: 8px; min-width: 0; } +.sharePopover .sharePopoverTarget { display: inline-flex; align-items: center; gap: 4px; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; } diff --git a/frontend/src/sharing/SharePopover.jsx b/frontend/src/sharing/SharePopover.jsx index de4d8ce6..ce04594b 100644 --- a/frontend/src/sharing/SharePopover.jsx +++ b/frontend/src/sharing/SharePopover.jsx @@ -304,8 +304,10 @@ export function SharePopover({ return (
- {title} - {kind === "folder" ? {target.name} : null} + + {title} + {kind === "folder" ? {target.name} : null} +
diff --git a/frontend/src/sharing/SharedFolder.jsx b/frontend/src/sharing/SharedFolder.jsx deleted file mode 100644 index 26a8c7a0..00000000 --- a/frontend/src/sharing/SharedFolder.jsx +++ /dev/null @@ -1,50 +0,0 @@ -// The share view of a folder share (/?share= naming a folder): the -// pages the link reaches, as the home library's cards, newest edit first — -// what GET /api/share/{token} listed (`pages`). A card opens its page in -// the same share view (App puts `page=` beside the token in the URL and -// loads the page through the token, like a page share); the topbar's home -// button comes back here. Presentational: App owns the data and navigation. -import React from "react"; -import { PageCard } from "../library/FileBrowser"; -import { formatRelativeTime, pageKindLabel } from "../library/libraryUtils"; -import { FileGlyph, FolderOpenIcon } from "../shared/ui/Icons"; -import { t } from "../shared/i18n/i18n.js"; - -export function SharedFolder({ folder, pages, labelMode, onOpen }) { - return ( -
-
-
- - {folder} - {t("{n} page{_s}", { n: pages.length, _s: pages.length === 1 ? "" : "s" })} -
-
- {pages.length === 0 ? ( -
{t("No pages are filed in this folder yet.")}
- ) : ( -
- {pages.map((p) => { - const attachment = p.doc_id ? { id: p.doc_id } : null; - return ( - } - title={p.title} - tip={t("{content}\nClick to open", { content: p.title })} - kind={pageKindLabel(attachment)} - time={formatRelativeTime(p.updated_at)} - folders={p.folders} labels={p.labels} labelMode={labelMode} - role="link" - tabIndex={0} - onClick={() => onOpen(p.id)} - onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(p.id); } }} - /> - ); - })} -
- )} -
- ); -} - diff --git a/frontend/tests/e2e/scenarios/share.mjs b/frontend/tests/e2e/scenarios/share.mjs index 26989a1e..35f7ddd5 100644 --- a/frontend/tests/e2e/scenarios/share.mjs +++ b/frontend/tests/e2e/scenarios/share.mjs @@ -49,9 +49,11 @@ export async function shareScenarios({ server, browser, alice, bob, step, until, await step("folder share: the folder view's link button shares every page filed there; visitors browse the listing", async () => { const paperA = await alice.api("/api/blocks", { method: "POST", body: { parent_id: "root", content: "Folder share paper A" } }); - await alice.api(`/api/blocks/${paperA.id}`, { method: "PUT", body: { properties: { folder: "sharedlab/sub" } } }); + await alice.api(`/api/blocks/${paperA.id}`, { method: "PUT", body: { properties: { folder: "sharedlab" } } }); const paperB = await alice.api("/api/blocks", { method: "POST", body: { parent_id: "root", content: "Folder share paper B" } }); await alice.api(`/api/blocks/${paperB.id}`, { method: "PUT", body: { properties: { folder: "sharedlab" } } }); + const deeper = await alice.api("/api/blocks", { method: "POST", body: { parent_id: "root", content: "Folder share paper in a subfolder" } }); + await alice.api(`/api/blocks/${deeper.id}`, { method: "PUT", body: { properties: { folder: "sharedlab/sub" } } }); await alice.api("/api/blocks", { method: "POST", body: { parent_id: paperA.id, content: "a note inside the shared folder" } }); const outside = await alice.api("/api/blocks", { method: "POST", body: { parent_id: "root", content: "Not in the shared folder" } }); @@ -73,17 +75,30 @@ export async function shareScenarios({ server, browser, alice, bob, step, until, await ctx.close(); assertEq((await alice.api("/api/share-settings/folder?name=sharedlab")).token, folderToken, "the folder's share"); - // an anonymous visitor: the listing, a page, and back — each a history entry + // an anonymous visitor: the folder view itself — the library's own rows, + // confined to the folder and stripped of everything that would change it const vctx = await browser.newContext({ viewport: { width: 1280, height: 860 } }); const v = await openPage(vctx, `${server.base}/?share=${folderToken}`); - await v.waitForSelector(".sharedFolder .pageCard", { timeout: 15000 }); - assertEq(await v.locator(".sharedFolder .pageCard").count(), 2, "both pages listed"); + await v.locator(".fileRow", { hasText: "Folder share paper A" }).waitFor({ timeout: 15000 }); + assertEq(await v.locator(".fileList .fileRow").count(), 2, "the folder's own pages"); + assertEq(await v.locator(".fileList .folderRow").count(), 1, "its subfolder"); + assertEq(await v.locator(".folderNewBtn").count(), 0, "no New page / New folder for a visitor"); + assertEq(await v.locator(".fileRowPin").count(), 0, "no pins for a visitor"); + assertEq(await v.locator(".folderBackRow").count(), 0, "nothing above the shared folder"); + assert((await v.textContent(".folderCurrent")).includes("sharedlab"), "the folder crumb"); assert((await v.textContent(".readOnlyTitle")).includes("sharedlab"), "the folder name in the topbar"); - await v.locator(".sharedFolder .pageCard", { hasText: "Folder share paper A" }).click(); + // into the subfolder and back up — never above the root + await v.locator(".fileList .folderRow", { hasText: "sub" }).dblclick(); + await v.locator(".fileRow", { hasText: "in a subfolder" }).waitFor(); + await v.locator(".folderBackRow").click(); + await v.locator(".fileRow", { hasText: "Folder share paper B" }).waitFor(); + assertEq(await v.locator(".folderBackRow").count(), 0, "back at the root, no way further up"); + // a page opens in the same share view; the home button returns; history replays both + await v.locator(".fileRow", { hasText: "Folder share paper A" }).dblclick(); await v.locator(".blockRow", { hasText: "a note inside the shared folder" }).waitFor({ timeout: 15000 }); assert(v.url().includes(`page=${paperA.id}`), "the open page rides in the URL"); await v.click("button[aria-label='Back to the shared folder']"); - await v.waitForSelector(".sharedFolder .pageCard"); + await v.locator(".fileRow", { hasText: "Folder share paper B" }).waitFor(); await v.goBack(); await v.locator(".blockRow", { hasText: "a note inside the shared folder" }).waitFor({ timeout: 15000 }); assertNoProblems(v); diff --git a/frontend/tests/e2e/scenarios/transfers.mjs b/frontend/tests/e2e/scenarios/transfers.mjs index 2be641e9..bb63b2a5 100644 --- a/frontend/tests/e2e/scenarios/transfers.mjs +++ b/frontend/tests/e2e/scenarios/transfers.mjs @@ -116,7 +116,11 @@ with zipfile.ZipFile(sys.argv[1], 'w') as z: await (await chooser).setFiles({ name: "reviewed.md", mimeType: "text/markdown", buffer: Buffer.from("---\ntitle: Reviewed Markdown\nfolder: Imported notes\n---\nA selected note.") }); const review = page.getByRole("dialog", { name: "Review Markdown import", exact: true }); await review.getByRole("progressbar").waitFor(); - assert((await review.innerText()).includes("Uploading for review")); + // The upload phase ends as soon as the bytes are sent; the gate above + // holds only the answer, so a slow runner may already be scanning. + const busyText = await review.innerText(); + assert(["Uploading for review", "Checking files and library destinations"].some((label) => busyText.includes(label)), + "the review shows its busy phase while the upload is answered"); release(); await review.getByRole("checkbox", { name: "Import Reviewed Markdown", exact: true }).waitFor(); await choice(review, "Deselect all").click(); diff --git a/frontend/tests/libraryAccess.test.mjs b/frontend/tests/libraryAccess.test.mjs new file mode 100644 index 00000000..ac8b439d --- /dev/null +++ b/frontend/tests/libraryAccess.test.mjs @@ -0,0 +1,39 @@ +// library/libraryAccess.js: the one object the home library asks before +// offering anything — what a member, a viewer and a share visitor may do. +import test from "node:test"; +import assert from "node:assert/strict"; +import { libraryAccess } from "../src/library/libraryAccess.js"; + +test("a member organizes the whole library", () => { + const lib = libraryAccess({ role: "owner" }); + assert.equal(lib.root, ""); + assert.ok(lib.browse && lib.organize && lib.pin && lib.history); + assert.ok(lib.contains("") && lib.contains("a/b")); + assert.equal(lib.clamp("a/b"), "a/b"); + assert.equal(lib.clamp(""), ""); +}); + +test("a workspace viewer browses everything but changes nothing", () => { + const lib = libraryAccess({ role: "viewer" }); + assert.ok(lib.browse && lib.history); + assert.ok(!lib.organize && !lib.pin); + assert.equal(lib.root, ""); +}); + +test("a folder share visitor is confined to the folder and its subfolders", () => { + const lib = libraryAccess({ shareMode: true, shareFolder: "lab/readout" }); + assert.equal(lib.root, "lab/readout"); + assert.ok(lib.browse); + assert.ok(!lib.organize && !lib.pin && !lib.history); + assert.ok(lib.contains("lab/readout") && lib.contains("lab/readout/sub")); + assert.ok(!lib.contains("lab") && !lib.contains("lab/readouts") && !lib.contains("")); + assert.equal(lib.clamp("lab/readout/sub"), "lab/readout/sub"); + assert.equal(lib.clamp("lab"), "lab/readout"); + assert.equal(lib.clamp(""), "lab/readout"); + assert.equal(lib.clamp(undefined), "lab/readout"); +}); + +test("a page share has no library to browse", () => { + const lib = libraryAccess({ shareMode: true }); + assert.ok(!lib.browse && !lib.organize); +}); From 43041e199ff2188b4ef5079bc30ee2fa9639e930 Mon Sep 17 00:00:00 2001 From: Tim Date: Fri, 25 Sep 2026 22:09:50 -0700 Subject: [PATCH 2/2] ai usage show, folder share improve --- backend/gamma/ai_settings.py | 25 ++++++----- backend/gamma/routers/pdf.py | 9 +++- backend/gamma/seed.py | 2 +- backend/tests/test_ai_allowance.py | 12 ++++-- backend/tests/test_shares.py | 19 +++++++++ docs/dev/ai.md | 11 ++--- docs/dev/api.md | 4 +- docs/dev/guests.md | 16 +++++-- docs/dev/home_library.md | 5 ++- docs/dev/settings.md | 5 ++- frontend/src/app/App.jsx | 51 ++++++++++++++++++++--- frontend/src/auth/LoginPage.jsx | 4 +- frontend/src/auth/guestExpiry.js | 4 +- frontend/src/settings/SettingsAi.jsx | 8 ++-- frontend/src/settings/SettingsKit.jsx | 22 ++++++++++ frontend/src/shared/i18n/locales/zh.json | 19 ++++++--- frontend/src/shared/styles/app.css | 3 ++ frontend/tests/e2e/scenarios/auth.mjs | 14 +++++-- frontend/tests/e2e/scenarios/settings.mjs | 8 ++++ frontend/tests/e2e/scenarios/share.mjs | 5 +++ frontend/tests/guestExpiry.test.mjs | 8 ++-- 21 files changed, 197 insertions(+), 57 deletions(-) diff --git a/backend/gamma/ai_settings.py b/backend/gamma/ai_settings.py index 940f293f..d32757e3 100644 --- a/backend/gamma/ai_settings.py +++ b/backend/gamma/ai_settings.py @@ -315,21 +315,21 @@ def server_entries_for(user: str) -> list: def allowance_status(user: str, limit: int) -> dict: - """What the pickers and the Usage pane show of the shared allowance: - {"limit", "used" (tokens through shared entries in the last 24 h), - "exhausted"}.""" + """What the account card, the pickers and the Usage pane show of the + shared allowance: {"limit" (0 = unlimited), "used" (tokens through + shared entries in the last 24 h), "exhausted"}.""" used = ai_usage.shared_used(user) - return {"limit": limit, "used": used, "exhausted": used >= limit} + return {"limit": limit, "used": used, "exhausted": bool(limit) and used >= limit} def shared_allowance(user: str) -> dict | None: - """``allowance_status`` when a metered shared entry applies to ``user`` - (one it can use: a key, or a connected sign-in, under a non-zero - limit), else None — the object ai_runtime() reports, without building + """``allowance_status`` when a shared entry applies to ``user`` (one it + can use: a key, or a connected sign-in; limit 0 when the admin set + none), else None — the object ai_runtime() reports, without building the runtime.""" entries, limit = shared_access(user) usable = any(ai_protocols.PROTOCOLS.get(e.get("protocol")) and _has_credential(e) for e in entries) - return allowance_status(user, limit) if usable and limit else None + return allowance_status(user, limit) if usable else None def provider_label(entry: dict) -> str: @@ -488,10 +488,13 @@ def ai_runtime(user: str) -> dict: "shared": is_server_id(pid)}) allowance = None shared_ids = [pid for pid in providers if is_server_id(pid)] - if limit and shared_ids: + if shared_ids: + # Reported whenever a shared entry applies (limit 0 = unlimited); + # the transport only meters under a limit. allowance = allowance_status(user, limit) - for pid in shared_ids: - providers[pid]["allowance"] = {"user": user, "limit": limit} + if limit: + for pid in shared_ids: + providers[pid]["allowance"] = {"user": user, "limit": limit} return { "user": user, # whose config this is — the usage recorder's key "providers": providers, diff --git a/backend/gamma/routers/pdf.py b/backend/gamma/routers/pdf.py index ff6f3f70..4a18d923 100644 --- a/backend/gamma/routers/pdf.py +++ b/backend/gamma/routers/pdf.py @@ -296,9 +296,14 @@ def proxy_pdf(source_url: str, request: Request): local_path = uploads / f"{pdf_doc_id}.pdf" want_save = request.query_params.get("save") == "1" - # If a local copy exists, redirect to the uploads route (supports Range requests) + # If a local copy exists, redirect to the uploads route (supports Range + # requests). The browser follows a redirect with no help from the app, so + # the query that named the workspace — a share token, or ?ws= — rides + # along, or the copy would be looked for in the session's own library. if local_path.exists(): - return RedirectResponse(f"/api/uploads/{pdf_doc_id}.pdf", status_code=302) + carried = {k: v for k, v in request.query_params.items() if k in ("share", "ws")} + target = f"/api/uploads/{pdf_doc_id}.pdf" + (f"?{urllib.parse.urlencode(carried)}" if carried else "") + return RedirectResponse(target, status_code=302) # Download from source. Streamed through to the client as upstream bytes # arrive — buffering the whole file first meant the browser saw zero bytes diff --git a/backend/gamma/seed.py b/backend/gamma/seed.py index 23e1507a..bc2d1105 100644 --- a/backend/gamma/seed.py +++ b/backend/gamma/seed.py @@ -56,7 +56,7 @@ def _welcome_blocks(): (secrets.token_urlsafe(9), figures_id, generate_key_between("a0", None), f"![]({_SCREENSHOTS}/01-annotated-pdf.png)", '{}'), (secrets.token_urlsafe(9), figures_id, generate_key_between("a0V", None), f"![]({_SCREENSHOTS}/02-home.png)", '{}'), (guest_id, wid, generate_key_between("a1", None), "## Guest account", '{}'), - (secrets.token_urlsafe(9), guest_id, "a0", f"You are signed in as a **guest**. This workspace is yours alone, and it is deleted with everything in it {_guest_lifetime()} after you started. To keep your work, ask the admin for an account.", '{}'), + (secrets.token_urlsafe(9), guest_id, "a0", f"You are signed in as a **guest**. This workspace is yours alone. It stays for {_guest_lifetime()} after you started, or until you log out, and is then deleted with everything in it. To keep your work, ask the admin for an account.", '{}'), (md_id, wid, generate_key_between("a1V", None), "## Markdown formatting", '{}'), (secrets.token_urlsafe(9), md_id, "a0", "Blocks support **bold**, *italic*, `code`, [links](https://example.com), and inline $\\KaTeX$ math like $E = mc^2$.", '{}'), ] diff --git a/backend/tests/test_ai_allowance.py b/backend/tests/test_ai_allowance.py index 8323ccd3..337b7719 100644 --- a/backend/tests/test_ai_allowance.py +++ b/backend/tests/test_ai_allowance.py @@ -131,9 +131,12 @@ def test_admin_round_trip_and_validation(admin, member, shared): def test_runtime_reports_the_allowance(admin, member, shared): from gamma.ai_settings import ai_runtime from gamma.ai_usage import shared_used - # No limit: nothing metered, nothing reported. - assert ai_runtime("allow_member")["allowance"] is None + # No limit: the usage is reported, nothing is metered. + spend("allow_member", shared, 40) + assert ai_runtime("allow_member")["allowance"] == {"limit": 0, "used": 40, "exhausted": False} assert "allowance" not in ai_runtime("allow_member")["providers"][shared] + from gamma import ai_usage + ai_usage.clear("allow_member", keep_metered=False) set_allowance(admin, accounts=LIMIT) spend("allow_member", shared, 300) @@ -157,7 +160,8 @@ def test_runtime_reports_the_allowance(admin, member, shared): def test_usage_carries_the_allowance(admin, member, shared): body = member.get("/api/ai/usage").json() - assert body["allowance"] is None and "windows" in body and "models" in body + assert body["allowance"] == {"limit": 0, "used": 0, "exhausted": False} + assert "windows" in body and "models" in body set_allowance(admin, accounts=LIMIT) spend("allow_member", shared, 250) body = member.get("/api/ai/usage").json() @@ -258,7 +262,7 @@ def test_guests_have_their_own_limit(admin, member, shared, upstream): # Accounts unlimited, guests metered; the switch decides access at all. r = admin.put("/api/admin/ai-providers", json={"guests": True, "allowance": {"guests": 300}}) assert r.status_code == 200 - assert member.get("/api/ai/models").json()["allowance"] is None + assert member.get("/api/ai/models").json()["allowance"]["limit"] == 0 # unlimited, still reported assert guest.get("/api/ai/models").json()["allowance"] == {"limit": 300, "used": 0, "exhausted": False} spend(name, shared, 100) r = guest.post("/api/ai/chat", json={"prompt": "hi", "stream": True}) diff --git a/backend/tests/test_shares.py b/backend/tests/test_shares.py index 671659e3..285e65a4 100644 --- a/backend/tests/test_shares.py +++ b/backend/tests/test_shares.py @@ -172,6 +172,25 @@ def test_pdf_proxy_only_serves_the_pages_own_source(bob, anon): assert anon.get("/api/pdf", params={"source_url": "https://example.com/paper.pdf", "share": t_notes}).status_code == 403 + # a copy the proxy cached earlier answers with a redirect to the upload — + # which must keep the token, or the browser's follow-up lands in the + # visitor's own (missing) library + import hashlib + from conftest import workspace_of + from gamma.db import ws_uploads_dir + from gamma.storage import DIGEST_CHARS + cached = hashlib.sha256(b"https://example.com/paper.pdf").hexdigest()[:DIGEST_CHARS] + uploads = ws_uploads_dir(workspace_of("bob_share")) + uploads.mkdir(parents=True, exist_ok=True) + (uploads / f"{cached}.pdf").write_bytes(b"%PDF-1.4 cached copy") + bob.put(f"/api/blocks/{paper['id']}", json={"properties": {"doc_id": cached}}) + r = anon.get("/api/pdf", params={"source_url": "https://example.com/paper.pdf", "share": t_paper}, + follow_redirects=False) + assert r.status_code == 302, r.text + assert r.headers["location"] == f"/api/uploads/{cached}.pdf?share={t_paper}" + assert anon.get(r.headers["location"]).status_code == 200 + assert anon.get(f"/api/uploads/{cached}.pdf").status_code == 401 + # --- permissions: who may open, what they may do ----------------------------- diff --git a/docs/dev/ai.md b/docs/dev/ai.md index 9a068a63..94ce36f5 100644 --- a/docs/dev/ai.md +++ b/docs/dev/ai.md @@ -172,10 +172,11 @@ spends through the shared entries: `allowance: {accounts, guests}` in the same config (tokens, input + output, per account per rolling 24 hours; 0 = unlimited, the default; `GET/PUT /api/admin/ai-providers`, either key alone). `ai_usage.shared_used` sums the account's `server:` rows in the window; -`ai_runtime` reports `allowance: {limit, used, exhausted}` (null when no -shared entry is in the runtime or its limit is 0 — guests take the guests' -limit, everyone else the accounts') and puts `allowance: {user, limit}` on -each shared provider conf. The one choke point is `ai_client.open_ai` +`ai_runtime` reports `allowance: {limit, used, exhausted}` whenever a +shared entry is in the runtime (limit 0 = unlimited, never exhausted; null +when none is — guests take the guests' limit, everyone else the accounts') +and, under a limit, puts `allowance: {user, limit}` on each shared provider +conf. The one choke point is `ai_client.open_ai` (`call_ai` goes through it): `check_allowance` re-reads the count on every call and raises `AllowanceExhausted`, an `HTTPException` 429 whose detail names the used and limit tokens and points at Settings → AI. Chat (both @@ -782,7 +783,7 @@ show what a week cost. Code: `gamma/ai_usage.py`, `ai_client.normalize_usage`, {calls, input, output, cache_read, cache_write}, kinds: {kind → the same} and models: [{provider_id, provider_name, model, …}] over the last 30 days, first_at, keep_days, allowance}` (`allowance`: the shared entries' - 24-hour allowance, above, or null); `DELETE /api/ai/usage` forgets the + 24-hour allowance, above, or null when no shared entry applies); `DELETE /api/ai/usage` forgets the account's rows except those the allowance still counts. Settings → AI › Connections → **Token usage** renders three tiles (today / 7 days / 30 days), the all-time line with Reset, and a by-model table (plus a by-kind block when more than one kind ran). diff --git a/docs/dev/api.md b/docs/dev/api.md index a7603c27..76127e73 100644 --- a/docs/dev/api.md +++ b/docs/dev/api.md @@ -216,7 +216,7 @@ guarded fetch path. | Method | Path | Purpose | |---|---|---| | POST | `/resolve-pdf` | URL/arXiv/DOI → fetchable PDF (citation_pdf_url sniffing, Unpaywall OA fallback) | -| GET | `/pdf` | proxy/download a PDF (`save=1` caches it server-side) | +| GET | `/pdf` | proxy/download a PDF (`save=1` caches it server-side); a copy already cached answers 302 to `/uploads/.pdf`, carrying the request's `share` / `ws` query so the browser's follow-up stays in the same library | | POST | `/uploads`, `/upload-image` | store a PDF / an image (content-hash names, dedup'd; quota-gated) | | POST | `/upload-file` | store a file for a block to reference as `[name](/api/uploads/.)` — the file chip. Any extension except executables (`storage.BLOCKED_EXTENSIONS`: exe, msi, bat, dll, ps1, …; 400 "not accepted (executable)"); the extension comes from the uploaded name, lowercased, `.bin` when there is none; images route like `/upload-image`, a `.pdf` must be a real PDF and lands under the same `.pdf` the PDF ingest mints (so it can be opened as a document page later); same hashing + limits → `{url, name, size, already_existed}` | | POST | `/upload-ink` | store a handwriting group's `gamma-ink` JSON (the request body; validated against `gamma/ink.py`'s schema and limits, canonical bytes so identical strokes dedup) as `.ink` → `{url, size, strokes, bbox, pdf_position, already_existed}`; editors and edit shares. [handwriting.md](handwriting.md) | @@ -279,7 +279,7 @@ the request's workspace — the extension names none, so its personal one. | Method | Path | Purpose | |---|---|---| | POST | `/ai/chat` | chat; NDJSON stream of `{context}` (first line: per-page coverage — native/text, pages shown of total; `doc_id` `""` for a page without a PDF; the open paper's entry adds `selection: {passages: [{page, section, found, crop}]}` when passages were selected) then `{delta}`/`{action}`/`{progress}`/`{usage}`/`{error}`; `usage` is the provider's token report `{input, output, cache_read, cache_write}`, one line per provider turn (the client sums an agent reply's rounds; non-stream replies carry one summed `usage` field); `progress` previews an edit_block/create_block call still being written (target id + markdown so far). Context is `pages` (up to 7 page ids, de-duplicated; they also become the tool scope's `context_pages`) or `page_id` (one; its PDF attachment derived server-side; `doc_id` is accepted as a compatibility input and resolves to its page), plus model id, effort, images, files, the agent scope, the selected PDF passages `selections` (`[{text, page, box}]`, box `[x0, y0, x1, y1]` page fractions; the older `"---"`-joined `selection` string is still read), and the notes pointers `focus_block_id` (cursor block), `context_blocks` (attached block ids), `note_selections` (selected note text as exact source ranges `[{block_id, from, to, text}]`, what `edit_block` mode `"selection"` rewrites). See [ai.md](ai.md) | -| GET | `/ai/models` | model registry (each model carries `native_pdf`: whether its provider accepts the PDF file itself, and `shared`: it comes from a server entry, `server::`) + default prompts (feeds the model switchers and prompt editor) + `allowance` (`{limit, used, exhausted}` for the shared entries, or null — [guests.md](guests.md)) | +| GET | `/ai/models` | model registry (each model carries `native_pdf`: whether its provider accepts the PDF file itself, and `shared`: it comes from a server entry, `server::`) + default prompts (feeds the model switchers and prompt editor) + `allowance` (`{limit, used, exhausted}` for the shared entries, `limit` 0 = unlimited; null when none applies — [guests.md](guests.md)) | | GET | `/ai/settings` | masked provider list (key hints only, each with its display `label`), then the server's shared entries the account may use as read-only rows (`shared: true`, key hint for admins only), plus the `protocols` and named `services` (e.g. DeepSeek) the add form offers | | POST/PUT/DELETE | `/ai/providers[/{id}]` | manage the account's own provider entries (a shared `server:` id is a 404 here) | | POST | `/ai/providers/{id}/test` | live probe of one credential (model: the entry's `test_model`, else the request's `model` — the client sends its metadata model — else the first model); failures carry an `auth` flag for expired/rejected credentials. Admins may name a shared entry (`server:`) | diff --git a/docs/dev/guests.md b/docs/dev/guests.md index c0d6e017..19f857a9 100644 --- a/docs/dev/guests.md +++ b/docs/dev/guests.md @@ -59,7 +59,8 @@ name. use too. An admin may delete a guest account like any other. A guest's `POST /api/logout` deletes the account right away: nothing can -sign into it again. A shorter lifetime applies to existing guests at once +sign into it again. The account menu's Log out says so first for a guest +(a confirmation, "Log out and delete"). A shorter lifetime applies to existing guests at once (expiry is computed from `created_at` on every check, not stored). The old midnight rollover is gone: `sessions.guest_date` stays in the @@ -98,7 +99,8 @@ meters the shared entries only, per account, over a rolling 24 hours: (`PUT {"allowance": {"accounts": N}}` — either key alone; anything but a whole number in range is a 400); - `ai_runtime(user)` reports `"allowance": {"limit", "used", "exhausted"}` - (`null` while no shared entry applies or the limit is 0), `used` being + whenever a shared entry applies (`limit` 0 = unlimited, never exhausted; + `null` while none applies), `used` being `ai_usage.shared_used(user)`: the account's rows on `server:` provider ids in the last 24 h; it also puts `{"user", "limit"}` on every shared provider conf, and the shared models stay listed once it is used up; @@ -138,8 +140,14 @@ allowance work the same on every server. stays manually startable everywhere. Like every offer it waits for Suggest tours ([onboarding.md](onboarding.md)). - The account card names when the workspace goes ("Temporary workspace · - gone in 5 hours", from `guest_expires_at`, `auth/guestExpiry.js`); that - line is every guest's, demo server or not. + deleted in 5 hours", from `guest_expires_at`, `auth/guestExpiry.js`), + with the hint that it stays until then or until log-out; that line is + every guest's, demo server or not. +- The account card of any account a shared entry applies to shows what it + spent through the shared entries (`AllowanceMeter` in + `settings/SettingsKit.jsx`: "Server AI: 1.2k of 50k tokens in the last + 24 h" over the storage meter's bar, or the plain count with no limit), + refreshed with `/api/ai/models` each time the menu opens. demo.gammapdf.com is its own compose project on the VPS, in a folder next to the account server's (`GAMMA_DEMO=1` in its `demo.env`), reached through diff --git a/docs/dev/home_library.md b/docs/dev/home_library.md index de093f7f..d956481c 100644 --- a/docs/dev/home_library.md +++ b/docs/dev/home_library.md @@ -20,8 +20,9 @@ viewer browses everything and organizes nothing; a folder share's visitor browses the shared folder only — the share view IS the home library at that folder, listed through `GET /blocks/root/children` with the token ([api.md](api.md) "Shares"), its pages opening in the same view (`page=` -beside the token, each a history entry) and the topbar's home button -returning to the folder. +beside the token, each a history entry) and the topbar's home button — or +the folder path that leads the page's title, every crumb a folder — returning +to the folder. Quick open ([QuickOpen.jsx](../../frontend/src/library/QuickOpen.jsx)) is the keyboard way into the library from anywhere: Ctrl+P (App.jsx's global key diff --git a/docs/dev/settings.md b/docs/dev/settings.md index 92f22dbc..a98f41f5 100644 --- a/docs/dev/settings.md +++ b/docs/dev/settings.md @@ -253,8 +253,9 @@ AI: dictation) and the account's token usage ([ai.md](ai.md) "Token usage"), which opens with a **Shared allowance** row ("12k of 50k tokens in the last 24 h", a red "used up" tag once - spent) while a shared entry with an allowance applies - ([guests.md](guests.md)). The check, models and usage sections appear only + spent; "12k tokens in the last 24 h · no limit" without one) while a + shared entry applies ([guests.md](guests.md)). The account menu's card + shows the same numbers under the storage meter. The check, models and usage sections appear only once a provider exists; a guest sees the usage too, without Reset. - **Chat**: **Chat** (the default reasoning effort and the snapshot-clearing switch), then **Tools**: the master switch and, per chat diff --git a/frontend/src/app/App.jsx b/frontend/src/app/App.jsx index 50e26b3f..751ac497 100644 --- a/frontend/src/app/App.jsx +++ b/frontend/src/app/App.jsx @@ -100,7 +100,7 @@ import ReportProblem from "../support/ReportProblem"; import { useGuide } from "../guide/useGuide"; import GuideOverlay from "../guide/GuideOverlay"; import { guideEvents } from "../guide/events"; -import { Empty, QuotaMeter, Section } from "../settings/SettingsKit"; +import { AllowanceMeter, Empty, QuotaMeter, Section } from "../settings/SettingsKit"; import { CopyBox, SharePopover } from "../sharing/SharePopover"; import { libraryAccess } from "../library/libraryAccess"; import { MirrorPopover } from "../collaboration/MirrorPopover"; @@ -803,6 +803,19 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { }); } + // A guest account has no password, so logging out deletes it and its + // workspace on the spot (docs/dev/guests.md): say so before it happens. + function confirmGuestLogout() { + setOpenPopover(null); + setConfirmBox({ + title: T("Log out and delete this workspace?"), + message: t("A guest can't sign back in: logging out deletes this workspace and everything in it now."), + confirmLabel: t("Log out and delete"), + danger: true, + onConfirm: doLogout, + }); + } + async function doLogout() { // Flush pending edits while the session is still valid. Setting authUser // false after the cookie is removed performs a local-only workspace @@ -8814,7 +8827,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { className={`iconBtn ${openPopover === "user" ? "activeIcon" : ""}`} onClick={() => { const opening = openPopover !== "user"; - if (opening) refreshQuota(); // fresh storage meter on open + if (opening) { refreshQuota(); refreshAiModels(); } // fresh storage and AI meters on open setOpenPopover(opening ? "user" : null); }} data-guide="header.account" @@ -8848,13 +8861,19 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { ) : null}
{authUser.is_guest ? ( -
{t("Nothing here is kept once the workspace goes. Ask the admin for an account to keep your work.")}
+
{t("Your work stays until then, or until you log out; then the workspace is deleted with everything in it. Ask the admin for an account to keep your work.")}
) : null} {quotaInfo?.quota_mb ? (
) : null} + {aiInfo?.allowance ? ( +
+ +
+ ) : null}
{/* The workspace switcher: every library this account belongs to; switching reloads the tab on that workspace's URL. */} @@ -8911,7 +8930,7 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { {t("Report a problem…")}
- @@ -9029,7 +9048,29 @@ function LibraryApp({ publicPage = null, initialServerConfig = null }) { > - {pageTitle || sharedFolder?.name || ""} + {sharedFolder ? ( + // A folder share: the folder path from the shared folder down, + // each crumb returning to that folder's listing, then the page. + + {folderFilter.split("/").map((seg, i, segs) => { + const prefix = segs.slice(0, i + 1).join("/"); + if (!lib.contains(prefix)) return null; + const here = !focusedBlockId && prefix === folderFilter; + return ( + + {i > 0 && lib.contains(segs.slice(0, i).join("/")) ? / : null} + {here ? {seg} : ( + + )} + + ); + })} + {focusedBlockId ? <>/{pageTitle} : null} + + ) : ( + {pageTitle} + )} {shareInfo ? ( diff --git a/frontend/src/auth/LoginPage.jsx b/frontend/src/auth/LoginPage.jsx index 756b59c3..293796ef 100644 --- a/frontend/src/auth/LoginPage.jsx +++ b/frontend/src/auth/LoginPage.jsx @@ -142,8 +142,8 @@ export function LoginPage({

{hours - ? tn("Your own workspace for {n} hour. Nothing is kept.", "Your own workspace for {n} hours. Nothing is kept.", hours) - : t("Your own workspace for a while. Nothing is kept.")} + ? tn("Your own workspace for {n} hour, then it is deleted.", "Your own workspace for {n} hours, then it is deleted.", hours) + : t("Your own workspace for a while, then it is deleted.")}

{error && !signInOpen ?
{error}
: null} {cloudError && !signInOpen ?
{cloudError}
: null} diff --git a/frontend/src/auth/guestExpiry.js b/frontend/src/auth/guestExpiry.js index b3ca5275..4b73726a 100644 --- a/frontend/src/auth/guestExpiry.js +++ b/frontend/src/auth/guestExpiry.js @@ -7,6 +7,6 @@ export function guestExpiryLabel(expiresAt, now = Date.now()) { const at = expiresAt ? Date.parse(expiresAt) : NaN; if (!Number.isFinite(at)) return t("Temporary workspace"); const minutes = Math.max(1, Math.ceil((at - now) / 60000)); - if (minutes < 60) return tn("Temporary workspace · gone in {n} minute", "Temporary workspace · gone in {n} minutes", minutes); - return tn("Temporary workspace · gone in {n} hour", "Temporary workspace · gone in {n} hours", Math.round(minutes / 60)); + if (minutes < 60) return tn("Temporary workspace · deleted in {n} minute", "Temporary workspace · deleted in {n} minutes", minutes); + return tn("Temporary workspace · deleted in {n} hour", "Temporary workspace · deleted in {n} hours", Math.round(minutes / 60)); } diff --git a/frontend/src/settings/SettingsAi.jsx b/frontend/src/settings/SettingsAi.jsx index 0a1f46cd..8eef1b9f 100644 --- a/frontend/src/settings/SettingsAi.jsx +++ b/frontend/src/settings/SettingsAi.jsx @@ -583,9 +583,11 @@ function AllowanceRow({ allowance }) { const { used = 0, limit = 0, exhausted = false } = allowance; return ( {exhausted ? {t("used up")} : null} diff --git a/frontend/src/settings/SettingsKit.jsx b/frontend/src/settings/SettingsKit.jsx index 6eb9c11d..309d92f9 100644 --- a/frontend/src/settings/SettingsKit.jsx +++ b/frontend/src/settings/SettingsKit.jsx @@ -9,6 +9,7 @@ import { AlertCircleIcon, CheckIcon, CloudCheckIcon, EyeIcon, EyeOffIcon, Monito import { bindable, chordFromEvent, chordParts } from "../shared/lib/hotkeys.js"; import { BROWSER_TAG, profileSyncState } from "./syncState.js"; import { t } from "../shared/i18n/i18n.js"; +import { fmtTokens } from "../chat/tokenUsage"; export const SettingsDraftContext = React.createContext(null); @@ -613,6 +614,27 @@ export function PercentMeter({ percent, barOnly, caption = "" }) { ); } +// What the account has spent through the server's shared AI connections in +// the last 24 hours (the `allowance` of GET /api/ai/models, docs/dev/guests.md): +// the storage meter's bar under the admin's limit, the plain count without +// one. Null when no shared connection applies. +export function AllowanceMeter({ allowance }) { + if (!allowance) return null; + const used = Number(allowance.used) || 0; + const limit = Number(allowance.limit) || 0; + if (!limit) { + return ( + + {t("Server AI: {used} tokens in the last 24 h", { used: fmtTokens(used) })} + + ); + } + return ; +} + // The keys of a chord as caps: "Ctrl" "Shift" "K", or ⇧⌘K on a Mac. export function KeyCaps({ chord }) { return {chordParts(chord).map((part, i) => {part})}; diff --git a/frontend/src/shared/i18n/locales/zh.json b/frontend/src/shared/i18n/locales/zh.json index 7a8c3315..75458fdf 100644 --- a/frontend/src/shared/i18n/locales/zh.json +++ b/frontend/src/shared/i18n/locales/zh.json @@ -59,6 +59,7 @@ "A complete copy{folder}: pages, highlights, notes, metadata, AI chats and files. Ready to import into another Gamma library.": "完整副本{folder}:页面、高亮、笔记、元数据、AI 聊天和文件。可直接导入另一个 Gamma 文库。", "A connection worth coming back to.": "一个值得回头再看的联系。", "A graph with native PDF highlights": "带原生 PDF 高亮的图谱", + "A guest can't sign back in: logging out deletes this workspace and everything in it now.": "访客无法重新登录:退出登录会立即删除此工作区及其中的全部内容。", "A link lets people open every page filed in this folder, including pages you file here later — read-only or editable, for anyone or only for accounts you name.": "一个链接即可让他人打开归入此文件夹的所有页面,包括你之后归入的页面——只读或可编辑,对所有人开放或仅限你指定的账户。", "A link lets people open this page — read-only or editable, for anyone or only for accounts you name.": "链接让他人打开此页面——只读或可编辑,对任何人或仅对你指定的账户开放。", "A link that opens every page filed in this folder, now and later": "一个链接即可打开归入此文件夹的所有页面,包括之后归入的", @@ -256,6 +257,7 @@ "Back to where you were{steps} — Alt+← · right-click to clear": "回到之前的位置{steps}——Alt+← · 右键清除", "Back to workspaces": "返回工作区", "Back to your library": "回到你的文库", + "Back to {folder}": "返回 {folder}", "Back up all {n} workspace{_s}": "备份全部 {n} 个工作区", "Back up now": "立即备份", "Back — or drop a page here to take this label off it": "返回——或将页面拖到此处以去掉此标签", @@ -1091,6 +1093,8 @@ "Log copied.": "日志已复制。", "Log in": "登录", "Log out": "退出登录", + "Log out and delete": "退出并删除", + "Log out and delete this workspace?": "退出登录并删除此工作区?", "Login failed": "登录失败", "Logs": "日志", "Logseq": "Logseq", @@ -1309,7 +1313,6 @@ "Notes, chats and indexes — small and quick; uploaded PDFs are not copied": "笔记、聊天和索引——小而快;不复制上传的 PDF", "Nothing added — the upload was refused.": "未添加任何内容——上传被拒绝。", "Nothing cloned yet.": "尚未克隆任何内容。", - "Nothing here is kept once the workspace goes. Ask the admin for an account to keep your work.": "工作区到期后,这里的内容都不会保留。请向管理员申请账户以保留你的工作。", "Nothing is labelled “{categoryFilter}” here — drop a page on a label to add it.": "这里没有带“{categoryFilter}”标签的内容——把页面拖到标签上即可添加。", "Nothing logged since the server started.": "服务器启动以来没有记录。", "Nothing logged yet this session.": "本次会话尚无记录。", @@ -1785,6 +1788,9 @@ "Send the full PDF file with your messages so the model sees figures & tables (uses more tokens). Click to enable.": "随消息发送完整的 PDF 文件,让模型看到图和表(消耗更多 token)。点击启用。", "Sepia": "褐色", "Server": "服务器", + "Server AI used up: {used} of {limit} tokens in the last 24 h": "服务器 AI 已用完:过去 24 小时 {used} / {limit} token", + "Server AI: {used} of {limit} tokens in the last 24 h": "服务器 AI:过去 24 小时 {used} / {limit} token", + "Server AI: {used} tokens in the last 24 h": "服务器 AI:过去 24 小时 {used} token", "Server backups": "服务器备份", "Server client": "服务器客户端", "Server log": "服务器日志", @@ -1972,8 +1978,8 @@ "Task name": "任务名称", "Telling the model the spoken language improves accuracy; auto-detect handles mixed or unlisted languages.": "告诉模型所说的语言可提高准确度;自动检测可处理混合或未列出的语言。", "Temporary workspace": "临时工作区", - "Temporary workspace · gone in {n} hour": "临时工作区 · {n} 小时后删除", - "Temporary workspace · gone in {n} minute": "临时工作区 · {n} 分钟后删除", + "Temporary workspace · deleted in {n} hour": "临时工作区 · {n} 小时后删除", + "Temporary workspace · deleted in {n} minute": "临时工作区 · {n} 分钟后删除", "Terminal platform": "终端平台", "Test": "测试", "Test model": "测试模型", @@ -2080,6 +2086,7 @@ "Token usage reset": "Token 用量已重置", "Tokens a day for each guest; 0 = unlimited": "每个访客每天的 Token 数;0 = 不限", "Tokens a day on the shared keys; 0 = unlimited": "每天可通过共享密钥使用的 Token 数;0 = 不限", + "Tokens your AI requests spent through this server's shared connections in the last 24 hours. Your own keys are not counted.": "过去 24 小时内,你的 AI 请求通过此服务器的共享连接消耗的 token。你自己的密钥不计入。", "Tokens · this conversation": "Token · 此对话", "Tool limits": "工具限制", "Tool rounds": "工具轮数", @@ -2266,8 +2273,8 @@ "Your name on this page": "你在此页面上的名字", "Your note": "你的笔记", "Your original paper, with annotations": "你的原始论文,带批注", - "Your own workspace for a while. Nothing is kept.": "你将获得一个临时工作区,到期后不做保留。", - "Your own workspace for {n} hour. Nothing is kept.": "你将获得一个保留 {n} 小时的工作区,到期后不做保留。", + "Your own workspace for a while, then it is deleted.": "你专属的工作区会保留一段时间,之后会被删除。", + "Your own workspace for {n} hour, then it is deleted.": "你专属的工作区保留 {n} 小时,之后会被删除。", "Your own writing as a document: top-level headings and paragraphs, deeper blocks as nested lists, mentions and synced blocks as wikilinks.": "你自己的文字作为文档:顶层为标题和段落,更深的块为嵌套列表,提及和同步块为 wikilinks。", "Your own writing, nested under the highlight it belongs to.": "你自己的文字,嵌套在所属的高亮下。", "Your own writing, typeset under the highlight it belongs to — headings, lists, code, math and pasted images included.": "你自己的文字,排版在所属的高亮下——包括标题、列表、代码、公式和粘贴的图片。", @@ -2275,6 +2282,7 @@ "Your personal workspace{default}": "你的个人工作区{default}", "Your selection is now chat context": "你的选区现在是聊天上下文", "Your storage": "你的存储", + "Your work stays until then, or until you log out; then the workspace is deleted with everything in it. Ask the admin for an account to keep your work.": "你的内容会保留到届时,或保留到你退出登录为止;之后工作区及其中的全部内容都会被删除。如需长期保存,请向管理员申请账户。", "ZIP contents": "ZIP 内容", "Zip the exported folder and pick it here": "将导出的文件夹压缩为 zip 并在此选择", "Zoom": "缩放", @@ -2655,6 +2663,7 @@ "{used} of {limit} tokens in the last 24 h": "最近 24 小时已用 {used} / {limit} Token", "{used} of {max} pages published": "已发布 {used}/{max} 个页面", "{used} of {quota} used ({pct}%)": "已用 {used},共 {quota}({pct}%)", + "{used} tokens in the last 24 h · no limit": "过去 24 小时 {used} token · 不限额", "{used} used — no quota": "已用 {used}——无配额", "{used}% used · {left}% left{reset}": "已用 {used}% · 剩余 {left}%{reset}", "{username} can now {verb} {name}.": "{username} 现在可以{verb} {name}。", diff --git a/frontend/src/shared/styles/app.css b/frontend/src/shared/styles/app.css index efa94335..81439644 100644 --- a/frontend/src/shared/styles/app.css +++ b/frontend/src/shared/styles/app.css @@ -2386,6 +2386,9 @@ button.pdfOutlineChevron:hover { background: rgba(128, 128, 128, 0.28); color: v /* --- modern topbar bits --- */ .addPopover { min-width: 300px; } +.shareCrumbs { display: flex; align-items: center; gap: 2px; white-space: nowrap; } +.shareCrumbs .crumbBtn { font: inherit; font-weight: 500; color: var(--text-secondary); } +.shareCrumbs .crumbSep { padding: 0 3px; } .readOnlyTitle { flex: 1; min-width: 0; diff --git a/frontend/tests/e2e/scenarios/auth.mjs b/frontend/tests/e2e/scenarios/auth.mjs index a6f6f4f4..e01a45ab 100644 --- a/frontend/tests/e2e/scenarios/auth.mjs +++ b/frontend/tests/e2e/scenarios/auth.mjs @@ -46,11 +46,19 @@ export async function authScenarios({ server, browser, alice, step, until, asser const role = page.locator(".userCardRole"); await role.waitFor(); const line = await role.textContent(); - assert(/^Temporary workspace · gone in \d+ (hours?|minutes?)$/.test(line), `the card names the expiry (${line})`); + assert(/^Temporary workspace · deleted in \d+ (hours?|minutes?)$/.test(line), `the card names the expiry (${line})`); // A second guest login is another account, with its own workspace. const other = await (await fetch(`${server.base}/api/login-guest`, { method: "POST" })).json(); assert(/^guest-/.test(other.username) && other.username !== session.user, "every guest login mints a fresh account"); - assertNoProblems(page); + // Logging out deletes a guest: the menu says so first. + await page.getByRole("button", { name: "Log out", exact: true }).click(); + const confirm = page.locator(".confirmModal", { hasText: "Log out and delete this workspace?" }); + await confirm.waitFor(); + await confirm.getByRole("button", { name: "Log out and delete", exact: true }).click(); + await page.waitForSelector(".loginGuestBtn"); + const after = await page.evaluate(() => fetch("/api/session").then((r) => r.json())); + assertEq(after.user, null, "signed out"); + assertNoProblems(page, [/401/]); } finally { await ctx.close(); } }); @@ -65,7 +73,7 @@ export async function authScenarios({ server, browser, alice, step, until, asser const page = await openPage(ctx, `${server.base}/`); const demo = page.getByRole("button", { name: "Try the demo", exact: true }); await demo.waitFor(); - await page.getByText(`Your own workspace for ${hours} hours. Nothing is kept.`, { exact: true }).waitFor(); + await page.getByText(`Your own workspace for ${hours} hours, then it is deleted.`, { exact: true }).waitFor(); assertEq(await page.locator(".loginInput").count(), 0, "the password form is folded"); assertEq(await page.locator(".loginGuestBtn").count(), 0, "one guest button, the demo one"); const disclosure = page.getByRole("button", { name: "Admin sign-in", exact: true }); diff --git a/frontend/tests/e2e/scenarios/settings.mjs b/frontend/tests/e2e/scenarios/settings.mjs index c927d761..84df7364 100644 --- a/frontend/tests/e2e/scenarios/settings.mjs +++ b/frontend/tests/e2e/scenarios/settings.mjs @@ -985,6 +985,14 @@ export async function settingsScenarios(env) { assert(models.models.some((m) => m.model === "lab-model" && m.shared), "member sees the shared model"); const mine = (await member.api("/api/ai/settings")).providers.find((p) => p.shared); assertEq(mine.key_hint, ""); + // The account card shows what the shared connections allow. + await page.getByRole("button", { name: "Close settings", exact: true }).click(); + await page.getByRole("button", { name: "Account & settings", exact: true }).click(); + const card = page.locator(".userPopover"); + await card.waitFor(); + await until(() => page.getByTestId("account-ai-usage").innerText({ timeout: 500 }).then((text) => text.includes("Server AI: 0 of 50k tokens in the last 24 h"), () => false), + { what: "the account card's shared AI line" }) + .catch(async (e) => { throw new Error(`${e.message}; the card says: ${await card.innerText()}`); }); assertNoProblems(page); } finally { await ctx.close(); diff --git a/frontend/tests/e2e/scenarios/share.mjs b/frontend/tests/e2e/scenarios/share.mjs index 35f7ddd5..085ed76f 100644 --- a/frontend/tests/e2e/scenarios/share.mjs +++ b/frontend/tests/e2e/scenarios/share.mjs @@ -97,6 +97,11 @@ export async function shareScenarios({ server, browser, alice, bob, step, until, await v.locator(".fileRow", { hasText: "Folder share paper A" }).dblclick(); await v.locator(".blockRow", { hasText: "a note inside the shared folder" }).waitFor({ timeout: 15000 }); assert(v.url().includes(`page=${paperA.id}`), "the open page rides in the URL"); + assert((await v.textContent(".shareCrumbs")).includes("sharedlab"), "the folder path leads the title"); + await v.locator(".shareCrumbs .crumbBtn", { hasText: "sharedlab" }).click(); + await v.locator(".fileRow", { hasText: "Folder share paper B" }).waitFor(); + await v.goBack(); + await v.locator(".blockRow", { hasText: "a note inside the shared folder" }).waitFor({ timeout: 15000 }); await v.click("button[aria-label='Back to the shared folder']"); await v.locator(".fileRow", { hasText: "Folder share paper B" }).waitFor(); await v.goBack(); diff --git a/frontend/tests/guestExpiry.test.mjs b/frontend/tests/guestExpiry.test.mjs index f762b88b..71b78dc4 100644 --- a/frontend/tests/guestExpiry.test.mjs +++ b/frontend/tests/guestExpiry.test.mjs @@ -5,10 +5,10 @@ import { guestExpiryLabel } from "../src/auth/guestExpiry.js"; test("a guest's card says when the workspace goes", () => { const now = Date.parse("2026-09-25T10:00:00Z"); - assert.equal(guestExpiryLabel("2026-09-25T15:00:00Z", now), "Temporary workspace · gone in 5 hours"); - assert.equal(guestExpiryLabel("2026-09-25T11:10:00Z", now), "Temporary workspace · gone in 1 hour"); - assert.equal(guestExpiryLabel("2026-09-25T10:20:00Z", now), "Temporary workspace · gone in 20 minutes"); - assert.equal(guestExpiryLabel("2026-09-25T09:00:00Z", now), "Temporary workspace · gone in 1 minute", "past due: about to go"); + assert.equal(guestExpiryLabel("2026-09-25T15:00:00Z", now), "Temporary workspace · deleted in 5 hours"); + assert.equal(guestExpiryLabel("2026-09-25T11:10:00Z", now), "Temporary workspace · deleted in 1 hour"); + assert.equal(guestExpiryLabel("2026-09-25T10:20:00Z", now), "Temporary workspace · deleted in 20 minutes"); + assert.equal(guestExpiryLabel("2026-09-25T09:00:00Z", now), "Temporary workspace · deleted in 1 minute", "past due: about to go"); assert.equal(guestExpiryLabel("", now), "Temporary workspace"); assert.equal(guestExpiryLabel("not a date", now), "Temporary workspace"); });