mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
polish(hub): round-2 UX — close the gap to the usability bar
Admin: top-level ⚙ settings entry (owners/admins), signup form states the domain restriction up front, invite list shows creator + join count, org-wide share audit shows creator/date and confirms before revoke, self-row role/remove controls disabled to avoid footguns. User: mobile header no longer overflows (per-file actions move to the ⌘K palette on narrow viewports; tables/pre scroll in their own container), empty-state copy works on mobile, share confirmation is now an explicit "anyone with this link can view" dialog with copy/open/revoke, invite links carry a "you've been invited" banner through login, joining opens the joined project, brand shown as the title, logout labeled, palette placeholder clarified to "file names". Tests: invite use-counter + creator in the owner list. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
This commit is contained in:
co-authored by
Claude Fable 5
parent
baad9e6dc2
commit
e022998c9f
@@ -491,6 +491,15 @@ func (a *BuiltinAuth) startSession(w http.ResponseWriter, userID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// inviteBanner shows an invitation cue when the post-login destination is a
|
||||
// join link, so a visitor who clicked an invite knows why they're here.
|
||||
func inviteBanner(next string) string {
|
||||
if !strings.Contains(next, "join/") && !strings.Contains(next, "join%2F") {
|
||||
return ""
|
||||
}
|
||||
return `<p class="msg" style="margin:0 0 14px">You've been invited to a team. Sign in (or sign up) to accept.</p>`
|
||||
}
|
||||
|
||||
// safeNext keeps post-login redirects on this site.
|
||||
func safeNext(next string) string {
|
||||
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
|
||||
@@ -565,7 +574,7 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if a.Brand != "" {
|
||||
brand = `<p class="alt" style="margin:0 0 14px;color:#aaa">` + html.EscapeString(a.Brand) + `</p>`
|
||||
}
|
||||
authPage(w, "Sign in", brand+fmt.Sprintf(`<form method="post" action="/auth/login?next=%s">%s%s%s<button>Sign in</button></form>
|
||||
authPage(w, "Sign in", brand+inviteBanner(next)+fmt.Sprintf(`<form method="post" action="/auth/login?next=%s">%s%s%s<button>Sign in</button></form>
|
||||
%s<p class="alt"><a href="/auth/reset">Forgot password?</a></p>`,
|
||||
url.QueryEscape(next),
|
||||
field("Email", "email", "email", r.FormValue("email")),
|
||||
@@ -604,11 +613,22 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
errMsg = `<p class="err">` + html.EscapeString(err.Error()) + `</p>`
|
||||
}
|
||||
authPage(w, "Create account", fmt.Sprintf(`<form method="post" action="/auth/signup?next=%s">%s%s%s%s<button>Sign up</button></form>
|
||||
// State the domain restriction up front, where the stranger types their
|
||||
// email — not only after a rejected submit.
|
||||
domainNote := ""
|
||||
if len(a.AllowedDomains) > 0 {
|
||||
domainNote = `<p class="alt" style="margin:2px 0 0">Only ` + html.EscapeString(a.domainList()) + ` email addresses can sign up here.</p>`
|
||||
}
|
||||
brand := ""
|
||||
if a.Brand != "" {
|
||||
brand = `<p class="alt" style="margin:0 0 14px;color:#aaa">` + html.EscapeString(a.Brand) + `</p>`
|
||||
}
|
||||
authPage(w, "Create account", brand+inviteBanner(next)+fmt.Sprintf(`<form method="post" action="/auth/signup?next=%s">%s%s%s%s%s<button>Sign up</button></form>
|
||||
<p class="alt">Have an account? <a href="/auth/login?next=%s">Sign in</a></p>`,
|
||||
url.QueryEscape(next),
|
||||
field("Name", "name", "text", r.FormValue("name")),
|
||||
field("Email", "email", "email", r.FormValue("email")),
|
||||
domainNote,
|
||||
field("Password (min 8 chars)", "password", "password", ""),
|
||||
errMsg, url.QueryEscape(next)))
|
||||
}
|
||||
|
||||
@@ -132,3 +132,28 @@ func TestMemberManagementHTTP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A joined invite bumps its use counter, visible in the owner's invite list.
|
||||
func TestInviteUseCounter(t *testing.T) {
|
||||
h, _, alice, bob, pa := orgHubSrv(t)
|
||||
rec := doAs(t, h, "POST", "/api/orgs/"+pa.Org+"/invites", nil, alice)
|
||||
var inv struct{ Token string }
|
||||
mustJSON(t, rec, &inv)
|
||||
doAs(t, h, "POST", "/api/invites/"+inv.Token, nil, bob)
|
||||
|
||||
rec = doAs(t, h, "GET", "/api/orgs/"+pa.Org+"/invites", nil, alice)
|
||||
var out struct {
|
||||
Invites []struct {
|
||||
Token string `json:"token"`
|
||||
Uses int `json:"uses"`
|
||||
Creator string `json:"creator"`
|
||||
} `json:"invites"`
|
||||
}
|
||||
mustJSON(t, rec, &out)
|
||||
if len(out.Invites) != 1 || out.Invites[0].Uses != 1 {
|
||||
t.Fatalf("invite uses = %+v, want 1 join recorded", out.Invites)
|
||||
}
|
||||
if out.Invites[0].Creator == "" {
|
||||
t.Fatal("invite list should carry the creator")
|
||||
}
|
||||
}
|
||||
|
||||
+17
-1
@@ -43,6 +43,18 @@ type OrgInvite struct {
|
||||
Creator string `json:"creator,omitempty"` // account email
|
||||
Created time.Time `json:"created"`
|
||||
Expires time.Time `json:"expires"`
|
||||
Uses int `json:"uses"` // how many accounts have joined via this link
|
||||
}
|
||||
|
||||
// RecordInviteUse bumps the join counter for an invite (best effort).
|
||||
func (db *OrgDB) RecordInviteUse(token string) {
|
||||
db.mu.Lock()
|
||||
defer db.mu.Unlock()
|
||||
if inv, ok := db.invites[token]; ok {
|
||||
inv.Uses++
|
||||
db.invites[token] = inv
|
||||
db.save()
|
||||
}
|
||||
}
|
||||
|
||||
func (i OrgInvite) expired() bool { return time.Now().After(i.Expires) }
|
||||
@@ -500,7 +512,7 @@ func (s *Server) handleInviteList(w http.ResponseWriter, r *http.Request) {
|
||||
for _, inv := range invs {
|
||||
out = append(out, map[string]any{
|
||||
"token": inv.Token, "url": requestBaseURL(r) + "/#join/" + inv.Token,
|
||||
"creator": inv.Creator, "created": inv.Created, "expires": inv.Expires,
|
||||
"creator": inv.Creator, "created": inv.Created, "expires": inv.Expires, "uses": inv.Uses,
|
||||
})
|
||||
}
|
||||
writeJSON(w, map[string]any{"invites": out})
|
||||
@@ -581,9 +593,13 @@ func (s *Server) handleInviteAccept(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
newMember := org.Members[normEmail(me.Email)] == ""
|
||||
if err := s.Orgs.AddMember(inv.Org, me.Email, RoleMember); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if newMember {
|
||||
s.Orgs.RecordInviteUse(r.PathValue("token"))
|
||||
}
|
||||
writeJSON(w, map[string]any{"ok": true, "org": map[string]string{"id": org.ID, "name": org.Name}})
|
||||
}
|
||||
|
||||
@@ -380,18 +380,29 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||
// Tell the frontend whether self-signup is offered and whether the
|
||||
// signed-in user is a hub admin, so it can hide the "Sign up" link and
|
||||
// show the admin surfaces. Never leak more than these booleans.
|
||||
me := s.requestUser(r)
|
||||
brand := ""
|
||||
if a := s.builtinAuth(); a != nil {
|
||||
auth["allow_signup"] = a.AllowSignup
|
||||
auth["admin"] = s.requestUser(r).Admin
|
||||
auth["admin"] = me.Admin
|
||||
brand = a.Brand
|
||||
}
|
||||
writeJSON(w, map[string]any{
|
||||
if brand == "" {
|
||||
brand = s.Volume
|
||||
}
|
||||
out := map[string]any{
|
||||
"mode": mode,
|
||||
"volume": s.Volume,
|
||||
"brand": brand,
|
||||
"upload": map[string]any{
|
||||
"enabled": s.Upload.Enabled,
|
||||
},
|
||||
"auth": auth,
|
||||
})
|
||||
}
|
||||
if me.Email != "" {
|
||||
out["me"] = map[string]string{"email": me.Email, "name": me.Name}
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
|
||||
func (s *Server) handleProjectList(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -339,7 +339,8 @@ code{background:#f6f8fa;padding:2px 5px;border-radius:4px;font-size:.9em}
|
||||
pre code{padding:0;background:none}
|
||||
img{max-width:100%%}
|
||||
blockquote{margin:0;padding-left:16px;border-left:3px solid #d0d7de;color:#57606a}
|
||||
table{border-collapse:collapse}td,th{border:1px solid #d0d7de;padding:5px 10px}
|
||||
table{border-collapse:collapse;display:block;overflow-x:auto;max-width:100%%}td,th{border:1px solid #d0d7de;padding:5px 10px}
|
||||
pre{max-width:100%%}
|
||||
footer.bdrive{margin-top:64px;padding-top:14px;border-top:1px solid #d0d7de;font-size:12.5px;color:#57606a}
|
||||
footer.bdrive a{color:inherit}
|
||||
@media (prefers-color-scheme: dark){footer.bdrive{border-color:#3a3a44;color:#888}}
|
||||
|
||||
@@ -18,6 +18,7 @@ let projects = [];
|
||||
let currentProject = null; // hub mode: the selected project
|
||||
let apiBase = "api/"; // volume-scoped endpoint prefix
|
||||
let orgs = []; // hub mode: the orgs this account belongs to
|
||||
let joinedOrgId = null; // org just joined via an invite this page-load
|
||||
|
||||
const fileURL = (p) => apiBase + "file?path=" + encodeURIComponent(p);
|
||||
|
||||
@@ -50,7 +51,7 @@ async function boot() {
|
||||
try {
|
||||
serverConfig = await getJSON("api/config");
|
||||
} catch { /* non-fatal */ }
|
||||
document.title = (serverConfig.volume || "beardrive") + " — BearDrive";
|
||||
document.title = serverConfig.brand || serverConfig.volume || "BearDrive";
|
||||
if (serverConfig.auth && serverConfig.auth.enabled) $("signout").hidden = false;
|
||||
if (serverConfig.mode === "hub") {
|
||||
await acceptInviteFromHash();
|
||||
@@ -58,9 +59,13 @@ async function boot() {
|
||||
await loadProjects();
|
||||
updateAdminBar();
|
||||
const { project, path } = parseHash();
|
||||
const proj = projects.find((x) => x.id === project) || projects[0];
|
||||
// After accepting an invite, open a project in the org you just joined
|
||||
// rather than whatever happened to be first.
|
||||
const proj = projects.find((x) => x.id === project)
|
||||
|| (joinedOrgId && projects.find((x) => x.org === joinedOrgId))
|
||||
|| projects[0];
|
||||
if (proj) selectProject(proj, path);
|
||||
else { $("vault-name").textContent = serverConfig.volume || "BearDrive"; showEmptyState(); }
|
||||
else { $("vault-name").textContent = serverConfig.volume || "BearDrive"; updateOrgBar(); showEmptyState(); }
|
||||
setInterval(loadProjects, 30000); // pick up new projects
|
||||
} else {
|
||||
$("vault-name").textContent = serverConfig.volume || "BearDrive";
|
||||
@@ -117,7 +122,7 @@ function selectProject(p, path) {
|
||||
$("crumb").textContent = "";
|
||||
$("meta").textContent = "";
|
||||
$("download").hidden = true;
|
||||
$("content").innerHTML = `<div class="empty">Select a file from the sidebar</div>`;
|
||||
$("content").innerHTML = `<div class="empty">Select a file to read it.<br><span class="empty-hint">On a phone, tap ☰ to browse.</span></div>`;
|
||||
loadProjects(); // refresh active highlight
|
||||
updateOrgBar();
|
||||
initUpload();
|
||||
@@ -139,6 +144,7 @@ async function acceptInviteFromHash() {
|
||||
try {
|
||||
const out = await postJSON("api/invites/" + m[1]); // may redirect to login (401)
|
||||
location.hash = "";
|
||||
joinedOrgId = out.org && out.org.id;
|
||||
toast("Welcome — you joined “" + out.org.name + "”.");
|
||||
} catch (e) {
|
||||
if (String(e.message).includes("signing in")) throw e; // redirecting; stop boot
|
||||
@@ -214,6 +220,16 @@ function currentOrg() {
|
||||
and owners get an Invite button that mints a join link. */
|
||||
function updateOrgBar() {
|
||||
const bar = $("orgbar"), org = currentOrg();
|
||||
// The top-of-sidebar gear is the always-visible admin entry point: any
|
||||
// account that owns an org (or is a hub admin) gets it, whatever project
|
||||
// is open.
|
||||
const owned = orgs.find((o) => o.role === "owner");
|
||||
const gear = $("settings-btn");
|
||||
if (gear) {
|
||||
const target = (org && org.role === "owner") ? org : owned;
|
||||
gear.hidden = !target;
|
||||
gear.onclick = () => showOrgAdmin(target);
|
||||
}
|
||||
if (!org) { bar.hidden = true; return; }
|
||||
bar.hidden = false;
|
||||
const nm = $("org-name");
|
||||
@@ -255,10 +271,12 @@ async function showOrgAdmin(org) {
|
||||
// Members
|
||||
el(panel, "h3", null, "Members");
|
||||
const mlist = el(panel, "div", "admin-list");
|
||||
const myEmail = (serverConfig.me && serverConfig.me.email) || "";
|
||||
for (const m of org.members) {
|
||||
const row = el(mlist, "div", "admin-item");
|
||||
el(row, "span", "ai-main", m.email);
|
||||
if (owner) {
|
||||
const isSelf = myEmail && m.email.toLowerCase() === myEmail.toLowerCase();
|
||||
el(row, "span", "ai-main", m.email + (isSelf ? " (you)" : ""));
|
||||
if (owner && !isSelf) {
|
||||
const sel = document.createElement("select");
|
||||
for (const r of ["owner", "member"]) {
|
||||
const o = document.createElement("option"); o.value = r; o.textContent = r;
|
||||
@@ -333,7 +351,10 @@ async function showOrgAdmin(org) {
|
||||
main.style.cursor = "pointer";
|
||||
main.title = "Copy";
|
||||
main.onclick = () => { navigator.clipboard.writeText(inv.url).then(() => toast("Copied.")); };
|
||||
el(row, "span", "ai-tag", "expires " + new Date(inv.expires).toLocaleDateString());
|
||||
const meta = (inv.creator ? "by " + inv.creator + " · " : "") +
|
||||
(inv.uses ? inv.uses + " joined · " : "unused · ") +
|
||||
"expires " + new Date(inv.expires).toLocaleDateString();
|
||||
el(row, "span", "ai-tag", meta);
|
||||
const rv = el(row, "button", "ai-del", "Revoke");
|
||||
rv.onclick = async () => {
|
||||
try { await api("DELETE", "api/orgs/" + org.id + "/invites/" + inv.token); toast("Revoked."); showOrgAdmin(currentOrg()); }
|
||||
@@ -353,9 +374,13 @@ async function showOrgAdmin(org) {
|
||||
const main = el(row, "span", "ai-main mono", sh.path);
|
||||
main.title = sh.url; main.style.cursor = "pointer";
|
||||
main.onclick = () => window.open(sh.url, "_blank");
|
||||
el(row, "span", "ai-tag", sh.project_name || "");
|
||||
const meta = (sh.project_name || "") +
|
||||
(sh.creator ? " · by " + sh.creator : "") +
|
||||
(sh.created ? " · " + new Date(sh.created).toLocaleDateString() : "");
|
||||
el(row, "span", "ai-tag", meta);
|
||||
const rv = el(row, "button", "ai-del", "Revoke");
|
||||
rv.onclick = async () => {
|
||||
if (!confirm("Revoke the public link to “" + sh.path + "”? Anyone with the URL will lose access.")) return;
|
||||
try { await api("DELETE", "api/shares/" + sh.token); toast("Share revoked."); showOrgAdmin(currentOrg()); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
};
|
||||
@@ -597,6 +622,37 @@ function openWikilink(target) {
|
||||
if (hit) openFile(hit.path);
|
||||
}
|
||||
|
||||
/* A clear, explicitly-public share confirmation: warns that anyone with the
|
||||
link can view, and offers copy / open / revoke. */
|
||||
function showShareDialog(url, copied) {
|
||||
const back = document.createElement("div");
|
||||
back.className = "modal-back";
|
||||
back.innerHTML = `
|
||||
<div class="modal">
|
||||
<h3>🔗 Public link created</h3>
|
||||
<p><b>Anyone with this link can view this file</b> — no account needed. It always shows the latest version until you revoke it.</p>
|
||||
<div class="modal-url"></div>
|
||||
<div class="modal-actions">
|
||||
<button class="pbtn" data-a="copy">${copied ? "Copied ✓" : "Copy link"}</button>
|
||||
<button class="ai-btn" data-a="open">Open</button>
|
||||
<button class="ai-del" data-a="revoke">Revoke</button>
|
||||
<button class="ai-btn" data-a="close">Done</button>
|
||||
</div>
|
||||
</div>`;
|
||||
back.querySelector(".modal-url").textContent = url;
|
||||
const close = () => back.remove();
|
||||
back.onclick = (e) => { if (e.target === back) close(); };
|
||||
const token = url.split("/s/")[1];
|
||||
back.querySelector('[data-a="copy"]').onclick = () => navigator.clipboard.writeText(url).then(() => toast("Copied."));
|
||||
back.querySelector('[data-a="open"]').onclick = () => window.open(url, "_blank");
|
||||
back.querySelector('[data-a="close"]').onclick = close;
|
||||
back.querySelector('[data-a="revoke"]').onclick = async () => {
|
||||
try { await api("DELETE", "api/shares/" + token); toast("Link revoked — it no longer works."); close(); }
|
||||
catch (e) { toast(e.message, true); }
|
||||
};
|
||||
document.body.appendChild(back);
|
||||
}
|
||||
|
||||
function join(dir, rel) {
|
||||
const parts = (dir ? dir.split("/") : []).concat(rel.split("/"));
|
||||
const out = [];
|
||||
@@ -623,18 +679,13 @@ function updateShareButton() {
|
||||
});
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
const share = await r.json();
|
||||
let copied = "";
|
||||
let copied = false;
|
||||
if (navigator.clipboard) {
|
||||
try { await navigator.clipboard.writeText(share.url); copied = " (copied)"; } catch { /* http origin */ }
|
||||
try { await navigator.clipboard.writeText(share.url); copied = true; } catch { /* http origin */ }
|
||||
}
|
||||
$("meta").innerHTML = "";
|
||||
const a = document.createElement("a");
|
||||
a.href = share.url;
|
||||
a.target = "_blank";
|
||||
a.textContent = share.url;
|
||||
$("meta").append("public link: ", a, copied);
|
||||
showShareDialog(share.url, copied);
|
||||
} catch (err) {
|
||||
$("meta").textContent = "Share failed: " + err.message;
|
||||
toast("Share failed: " + err.message, true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
<span id="vault-name">…</span>
|
||||
<div class="vault-actions">
|
||||
<button id="adminbar" class="adminbar" hidden></button>
|
||||
<a id="signout" href="/auth/logout" hidden title="Sign out">⏻</a>
|
||||
<button id="settings-btn" class="icon-btn2" hidden title="Manage organization">⚙</button>
|
||||
<a id="signout" href="/auth/logout" hidden title="Sign out" aria-label="Sign out">⏻</a>
|
||||
</div>
|
||||
</header>
|
||||
<nav id="projects" aria-label="Projects" hidden></nav>
|
||||
@@ -37,12 +38,12 @@
|
||||
<a id="download" class="btn" hidden download>Download</a>
|
||||
</header>
|
||||
<article id="content" class="markdown">
|
||||
<div class="empty">Select a file from the sidebar</div>
|
||||
<div class="empty">Select a file to read it.</div>
|
||||
</article>
|
||||
</main>
|
||||
<div id="palette-overlay" hidden>
|
||||
<div id="palette" role="dialog" aria-label="Search and quick actions">
|
||||
<input id="palette-input" type="text" placeholder="Search files, projects, actions…"
|
||||
<input id="palette-input" type="text" placeholder="Search file names, projects, actions…"
|
||||
autocomplete="off" spellcheck="false">
|
||||
<ul id="palette-results"></ul>
|
||||
<footer id="palette-hint">↑↓ navigate · ⏎ select · esc close</footer>
|
||||
|
||||
@@ -152,6 +152,7 @@ body {
|
||||
.hact { margin-left: auto; }
|
||||
.hact a { color: var(--accent); text-decoration: none; margin-left: 8px; }
|
||||
.empty { color: var(--text-faint); text-align: center; margin-top: 20vh; }
|
||||
.empty-hint { font-size: 12px; color: var(--text-faint); }
|
||||
|
||||
/* ---- command palette (⌘K) ---- */
|
||||
#palette-overlay[hidden] { display: none; }
|
||||
@@ -224,6 +225,8 @@ body {
|
||||
.vault-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.adminbar { border: none; cursor: pointer; font: inherit; font-size: 11.5px; padding: 2px 8px; border-radius: 5px; background: #4a3a12; color: #f5c451; }
|
||||
.adminbar:hover { background: #5c4816; }
|
||||
.icon-btn2 { border: none; background: transparent; color: var(--text-faint); font-size: 15px; cursor: pointer; padding: 0 2px; line-height: 1; }
|
||||
.icon-btn2:hover { color: var(--text); }
|
||||
|
||||
/* ---- topbar controls ---- */
|
||||
.icon-btn { display: none; border: none; background: transparent; color: var(--text-dim); font-size: 18px; cursor: pointer; padding: 2px 6px; border-radius: 5px; }
|
||||
@@ -267,6 +270,14 @@ body {
|
||||
.ai-del:hover { background: #4a2420; color: #ff7b72; }
|
||||
.admin-empty { padding: 14px 12px; color: var(--text-faint); font-size: 13px; }
|
||||
|
||||
/* ---- modal ---- */
|
||||
.modal-back { position: fixed; inset: 0; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; z-index: 150; padding: 20px; }
|
||||
.modal { background: var(--bg-side); border: 1px solid var(--border); border-radius: 12px; padding: 24px; width: min(460px, 100%); box-shadow: 0 20px 60px rgba(0,0,0,.5); }
|
||||
.modal h3 { margin: 0 0 10px; font-size: 17px; }
|
||||
.modal p { margin: 0 0 16px; font-size: 13.5px; color: var(--text-dim); }
|
||||
.modal-url { font: 12px var(--mono, ui-monospace, Menlo, monospace); background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; color: var(--text-dim); word-break: break-all; margin-bottom: 16px; }
|
||||
.modal-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
|
||||
/* ---- toast ---- */
|
||||
#toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px); background: var(--bg-active); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px 18px; font-size: 13.5px; box-shadow: 0 8px 30px rgba(0,0,0,.5); opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; z-index: 200; }
|
||||
#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
@@ -287,7 +298,13 @@ body {
|
||||
#search-btn kbd { display: none; }
|
||||
#search-btn { font-size: 0; gap: 0; padding: 4px 9px; }
|
||||
#search-btn::before { content: "🔍"; font-size: 14px; }
|
||||
/* The per-file action buttons overflow a phone header; on mobile they
|
||||
live in the ⌘K palette instead (which offers all of them), so the bar
|
||||
stays to Menu · crumb · Search. */
|
||||
#share-btn, #history-btn, #upload-btn, #download { display: none !important; }
|
||||
#meta { display: none; }
|
||||
.markdown, .admin, .onboard, .history { max-width: 100%; }
|
||||
.markdown table, pre.plain { display: block; overflow-x: auto; max-width: 100%; }
|
||||
.ob-row { flex-direction: column; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user