mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(web): selectable folders with listing + per-folder change feed
Folders in the file tree are now selectable: the row opens the folder (chevron still folds) and the main pane lists its contents, with clickable breadcrumbs, folder URLs/deep links, palette entries, and uploads landing inside the selected folder. In hub mode the listing includes a Recent changes feed for the subtree; the history API now classifies puts as add|edit (replayed over all ops before filtering) so entries are badged added/edited/deleted, and op notes render under entries with URLs linkified. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
This commit is contained in:
co-authored by
Claude Fable 5
parent
ba595b9d45
commit
06bedabfd6
@@ -20,7 +20,7 @@ import (
|
||||
// HistoryEntry is one change as the history API reports it.
|
||||
type HistoryEntry struct {
|
||||
Time string `json:"time"`
|
||||
Kind string `json:"kind"` // put | delete
|
||||
Kind string `json:"kind"` // add | edit | delete
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Blob string `json:"blob,omitempty"` // sha256; fetch via the blob endpoint
|
||||
@@ -59,6 +59,24 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
journal.Sort(all)
|
||||
// A put is an "add" when the path didn't exist just before it (first
|
||||
// version, or first after a delete), an "edit" otherwise. Existence is
|
||||
// replayed over ALL ops in journal order, before any path/prefix filter,
|
||||
// so a filtered view classifies the same as the full feed.
|
||||
kinds := make([]string, len(all))
|
||||
exists := make(map[string]bool, len(all))
|
||||
for i, op := range all {
|
||||
switch {
|
||||
case op.Kind == journal.KindDelete:
|
||||
kinds[i] = "delete"
|
||||
exists[op.Path] = false
|
||||
case exists[op.Path]:
|
||||
kinds[i] = "edit"
|
||||
default:
|
||||
kinds[i] = "add"
|
||||
exists[op.Path] = true
|
||||
}
|
||||
}
|
||||
entries := make([]HistoryEntry, 0, n)
|
||||
for i := len(all) - 1; i >= 0 && len(entries) < n; i-- { // newest first
|
||||
op := all[i]
|
||||
@@ -73,7 +91,7 @@ func (s *Server) handleHistory(v *volume, w http.ResponseWriter, r *http.Request
|
||||
dev = DeviceInfo{ID: op.Device, Name: op.DeviceName}
|
||||
}
|
||||
entries = append(entries, HistoryEntry{
|
||||
Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: op.Kind,
|
||||
Time: op.Time.UTC().Format("2006-01-02T15:04:05Z"), Kind: kinds[i],
|
||||
Path: op.Path, Size: op.Size, Blob: op.Blob,
|
||||
User: op.User, UserName: op.UserName, Author: op.Author,
|
||||
Device: dev, Note: op.Note,
|
||||
|
||||
@@ -72,6 +72,10 @@ func TestHistoryAPI(t *testing.T) {
|
||||
if newest.Size != int64(len("v2 longer")) || oldest.Size != int64(len("v1")) {
|
||||
t.Fatalf("order wrong: %+v", out.Entries)
|
||||
}
|
||||
// puts are classified: first version = add, later versions = edit
|
||||
if oldest.Kind != "add" || newest.Kind != "edit" {
|
||||
t.Fatalf("kinds = %q, %q; want add, edit", oldest.Kind, newest.Kind)
|
||||
}
|
||||
if newest.User != "alice@x.io" || newest.UserName != "Alice" {
|
||||
t.Fatalf("user = %+v", newest)
|
||||
}
|
||||
@@ -94,6 +98,10 @@ func TestHistoryAPI(t *testing.T) {
|
||||
if out.Entries[0].Kind != "delete" || out.Entries[0].Path != "notes/other.md" {
|
||||
t.Fatalf("newest notes/ entry = %+v, want the delete", out.Entries[0])
|
||||
}
|
||||
// the put that created other.md is an add, even in the filtered view
|
||||
if out.Entries[1].Kind != "add" || out.Entries[1].Path != "notes/other.md" {
|
||||
t.Fatalf("entry before the delete = %+v, want other.md's add", out.Entries[1])
|
||||
}
|
||||
// a device the registry never saw falls back to the op's own info
|
||||
if out.Entries[0].Device.Name != "dev2" {
|
||||
t.Fatalf("unknown device fallback = %+v", out.Entries[0].Device)
|
||||
|
||||
+205
-39
@@ -3,6 +3,7 @@
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let flatFiles = []; // [{path, name}] for wikilink resolution
|
||||
let dirIndex = new Map(); // dir path → tree node, for folder listings
|
||||
let currentPath = null;
|
||||
let expanded = new Set(); // dir paths that are open (folders start closed)
|
||||
let treeFirstLoad = true; // apply the "lone root folder opens" rule once per project
|
||||
@@ -92,7 +93,7 @@ async function boot() {
|
||||
initUpload();
|
||||
await refreshTree();
|
||||
const { path } = parseRoute();
|
||||
if (path) openFile(path);
|
||||
if (path) openPath(path);
|
||||
}
|
||||
setInterval(refreshTree, 15000); // pick up synced changes
|
||||
}
|
||||
@@ -161,7 +162,7 @@ function selectProject(p, path) {
|
||||
initUpload();
|
||||
initHistory();
|
||||
updateShareButton();
|
||||
refreshTree().then(() => { if (path) openFile(path); });
|
||||
refreshTree().then(() => { if (path) openPath(path); });
|
||||
if (!path) pushURL("/" + p.id);
|
||||
}
|
||||
|
||||
@@ -676,6 +677,7 @@ async function refreshTree() {
|
||||
root = await getJSON(apiBase + "tree");
|
||||
} catch { return; } // keep the last good tree
|
||||
flatFiles = [];
|
||||
dirIndex = new Map();
|
||||
const kids = root.children || [];
|
||||
// First render of a project's tree: every folder starts closed, except a
|
||||
// lone root folder — opening it spares the user a single shut folder.
|
||||
@@ -688,6 +690,10 @@ async function refreshTree() {
|
||||
nav.innerHTML = "";
|
||||
nav.appendChild(renderChildren(kids));
|
||||
markActive();
|
||||
// A folder listing shows live tree data — keep it in step with the tree.
|
||||
if (currentPath && dirIndex.has(currentPath) && $("content").querySelector(".dirlist")) {
|
||||
renderFolderListing(currentPath);
|
||||
}
|
||||
}
|
||||
|
||||
function renderChildren(children) {
|
||||
@@ -719,6 +725,7 @@ function renderNode(n) {
|
||||
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
|
||||
li.appendChild(row);
|
||||
if (n.dir) {
|
||||
dirIndex.set(n.path, n);
|
||||
if (serverConfig.mode === "hub") {
|
||||
const hist = document.createElement("span");
|
||||
hist.className = "dir-history";
|
||||
@@ -731,12 +738,21 @@ function renderNode(n) {
|
||||
const open = expanded.has(n.path);
|
||||
if (!open) li.classList.add("collapsed");
|
||||
row.setAttribute("aria-expanded", String(open));
|
||||
row.onclick = () => {
|
||||
const toggle = () => {
|
||||
li.classList.toggle("collapsed");
|
||||
const isCollapsed = li.classList.contains("collapsed");
|
||||
isCollapsed ? expanded.delete(n.path) : expanded.add(n.path);
|
||||
row.setAttribute("aria-expanded", String(!isCollapsed));
|
||||
};
|
||||
// The chevron only folds; the row selects the folder (opens it in the
|
||||
// tree and lists its contents in the main pane). Clicking the folder
|
||||
// whose listing is already showing folds/unfolds it, like a plain tree;
|
||||
// from any other view (file, history) it brings the listing back.
|
||||
chev.onclick = (e) => { e.stopPropagation(); toggle(); };
|
||||
row.onclick = () => {
|
||||
if (currentPath === n.path && $("content").querySelector(".dirlist")) { toggle(); return; }
|
||||
openFolder(n.path);
|
||||
};
|
||||
} else {
|
||||
flatFiles.push({ path: n.path, name: n.name });
|
||||
row.onclick = () => openFile(n.path);
|
||||
@@ -780,6 +796,122 @@ function revealInTree(p) {
|
||||
if (row) row.scrollIntoView({ block: "center" });
|
||||
}
|
||||
|
||||
/* ---- breadcrumb ----
|
||||
Every ancestor segment is a link to that folder's listing; the last
|
||||
segment is the current page. */
|
||||
function setCrumb(p) {
|
||||
const c = $("crumb");
|
||||
c.innerHTML = "";
|
||||
const parts = p.split("/");
|
||||
let acc = "";
|
||||
parts.forEach((seg, i) => {
|
||||
acc = acc ? acc + "/" + seg : seg;
|
||||
if (i) el(c, "span", "crumb-sep", "/");
|
||||
const last = i === parts.length - 1;
|
||||
const s = el(c, "span", last ? null : "crumb-seg", seg);
|
||||
if (!last) {
|
||||
const target = acc;
|
||||
s.title = target;
|
||||
s.onclick = () => { if (dirIndex.has(target)) openFolder(target); };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- folder pane ---- */
|
||||
|
||||
/* Open a path of either kind — a route or link doesn't know which it is
|
||||
until the tree has loaded. */
|
||||
function openPath(p) {
|
||||
if (dirIndex.has(p)) openFolder(p);
|
||||
else openFile(p);
|
||||
}
|
||||
|
||||
/* Selecting a folder: unfold it in the tree, highlight it, and list what's
|
||||
inside it in the main pane. */
|
||||
function openFolder(p) {
|
||||
if (!dirIndex.has(p)) return;
|
||||
currentPath = p;
|
||||
syncURL(p);
|
||||
expandTo(p);
|
||||
expanded.add(p); // the selected folder itself opens, not just its ancestors
|
||||
applyTreeExpansion();
|
||||
markActive();
|
||||
const row = document.querySelector(`#tree .row[data-path="${CSS.escape(p)}"]`);
|
||||
if (row) row.scrollIntoView({ block: "nearest" });
|
||||
closeSidebarOnMobile();
|
||||
setCrumb(p);
|
||||
$("meta").textContent = "";
|
||||
$("download").hidden = true;
|
||||
$("more-btn").hidden = !(serverConfig.mode === "hub" && currentProject);
|
||||
updateShareButton();
|
||||
renderFolderListing(p);
|
||||
}
|
||||
|
||||
function renderFolderListing(p) {
|
||||
const node = dirIndex.get(p);
|
||||
if (!node) return;
|
||||
const content = $("content");
|
||||
content.className = "view";
|
||||
content.innerHTML = "";
|
||||
const wrap = el(content, "div", "dirlist");
|
||||
const head = el(wrap, "h1", "dl-title");
|
||||
const hicon = el(head, "span", "dl-title-icon");
|
||||
hicon.innerHTML = svgIcon("folder");
|
||||
el(head, "span", null, node.name);
|
||||
const kids = (node.children || []).slice()
|
||||
.sort((a, b) => (b.dir - a.dir) || a.name.localeCompare(b.name));
|
||||
const dirs = kids.filter((c) => c.dir).length;
|
||||
const files = kids.length - dirs;
|
||||
const counts = [];
|
||||
if (dirs) counts.push(dirs + (dirs === 1 ? " folder" : " folders"));
|
||||
if (files) counts.push(files + (files === 1 ? " file" : " files"));
|
||||
el(wrap, "p", "dl-sub", counts.join(" · ") || "Empty folder");
|
||||
if (!kids.length) {
|
||||
el(wrap, "div", "dl-empty", "Nothing in this folder yet.");
|
||||
renderFolderHistory(wrap, p);
|
||||
return;
|
||||
}
|
||||
const list = el(wrap, "div", "dl-items");
|
||||
for (const c of kids) {
|
||||
const row = el(list, "div", "dl-row");
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute("role", "button");
|
||||
row.title = c.path;
|
||||
const icon = el(row, "span", "ticon");
|
||||
icon.innerHTML = svgIcon(c.dir ? "folder" : "doc");
|
||||
el(row, "span", "dl-name", c.name);
|
||||
let meta = "";
|
||||
if (c.dir) {
|
||||
const n = (c.children || []).length;
|
||||
meta = n + (n === 1 ? " item" : " items");
|
||||
} else {
|
||||
meta = [c.size ? humanSize(c.size) : "", c.time ? new Date(c.time).toLocaleDateString() : ""]
|
||||
.filter(Boolean).join(" · ");
|
||||
}
|
||||
el(row, "span", "dl-meta", meta);
|
||||
row.onclick = () => openPath(c.path);
|
||||
row.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); row.click(); } };
|
||||
}
|
||||
renderFolderHistory(wrap, p);
|
||||
}
|
||||
|
||||
/* The folder's change feed, straight from the journals: files added, edited,
|
||||
and deleted anywhere under it, newest first. Hub-only — a plain-folder
|
||||
viewer has no journals to read. */
|
||||
function renderFolderHistory(wrap, p) {
|
||||
if (!(serverConfig.mode === "hub" && currentProject)) return;
|
||||
const sec = el(wrap, "div", "dl-history");
|
||||
getJSON(apiBase + "history?prefix=" + encodeURIComponent(p + "/") + "&n=20").then((out) => {
|
||||
const entries = out.entries || [];
|
||||
if (!entries.length) return sec.remove();
|
||||
el(sec, "h3", "dl-h3", "Recent changes");
|
||||
const list = el(sec, "div", "history dl-hlist");
|
||||
for (const e of entries) list.appendChild(historyEntryRow(e));
|
||||
const more = el(sec, "button", "ai-btn dl-more", "Full history");
|
||||
more.onclick = () => showHistory({ prefix: p + "/" });
|
||||
}).catch(() => sec.remove());
|
||||
}
|
||||
|
||||
/* ---- file pane ---- */
|
||||
async function openFile(p) {
|
||||
currentPath = p;
|
||||
@@ -787,7 +919,7 @@ async function openFile(p) {
|
||||
markActive();
|
||||
revealInTree(p);
|
||||
closeSidebarOnMobile();
|
||||
$("crumb").textContent = p.split("/").join(" / ");
|
||||
setCrumb(p);
|
||||
updateShareButton();
|
||||
const dl = $("download");
|
||||
dl.href = apiBase + "download?path=" + encodeURIComponent(p);
|
||||
@@ -916,7 +1048,8 @@ function join(dir, rel) {
|
||||
(rendered, sandboxed), no account needed. Always the latest content. */
|
||||
function updateShareButton() {
|
||||
const btn = $("share-btn");
|
||||
btn.hidden = !(serverConfig.mode === "hub" && currentProject && currentPath);
|
||||
// Shares are per-file; a selected folder has nothing to mint.
|
||||
btn.hidden = !(serverConfig.mode === "hub" && currentProject && currentPath && !dirIndex.has(currentPath));
|
||||
btn.onclick = async () => {
|
||||
try {
|
||||
const r = await fetch(apiBase + "shares", {
|
||||
@@ -941,7 +1074,11 @@ function updateShareButton() {
|
||||
function initHistory() {
|
||||
const btn = $("history-btn");
|
||||
btn.hidden = !(serverConfig.mode === "hub" && currentProjectOrNull());
|
||||
btn.onclick = () => showHistory(currentPath ? { path: currentPath } : { prefix: "" });
|
||||
btn.onclick = () => {
|
||||
if (!currentPath) return showHistory({ prefix: "" });
|
||||
if (dirIndex.has(currentPath)) return showHistory({ prefix: currentPath + "/" });
|
||||
showHistory({ path: currentPath });
|
||||
};
|
||||
}
|
||||
|
||||
function currentProjectOrNull() {
|
||||
@@ -972,39 +1109,63 @@ async function showHistory(q) {
|
||||
if (!out.entries || out.entries.length === 0) {
|
||||
wrap.innerHTML = `<div class="empty">No history yet.</div>`;
|
||||
}
|
||||
for (const e of out.entries || []) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "hentry " + e.kind;
|
||||
const who = e.user_name ? `${e.user_name} <${e.user}>` : (e.user || e.author || "unknown");
|
||||
const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · ");
|
||||
const when = new Date(e.time).toLocaleString();
|
||||
row.innerHTML =
|
||||
`<div class="hline"><span class="hkind"></span><span class="hpath"></span><span class="htime"></span></div>` +
|
||||
`<div class="hmeta"><span class="hwho"></span><span class="hdev"></span><span class="hsize"></span><span class="hact"></span></div>`;
|
||||
row.querySelector(".hkind").innerHTML = svgIcon(e.kind === "delete" ? "x" : "dot");
|
||||
row.querySelector(".hpath").textContent = e.path;
|
||||
row.querySelector(".htime").textContent = when;
|
||||
row.querySelector(".hwho").textContent = who;
|
||||
row.querySelector(".hdev").textContent = dev;
|
||||
row.querySelector(".hsize").textContent = e.size ? humanSize(e.size) : "";
|
||||
if (e.kind !== "delete" && e.blob) {
|
||||
const view = document.createElement("a");
|
||||
view.textContent = "view";
|
||||
view.href = apiBase + "blob?sha=" + e.blob + "&name=" + encodeURIComponent(e.path);
|
||||
view.target = "_blank";
|
||||
const dl = document.createElement("a");
|
||||
dl.textContent = "download";
|
||||
dl.href = view.href + "&download=1";
|
||||
dl.setAttribute("download", e.path.split("/").pop());
|
||||
row.querySelector(".hact").append(view, " ", dl);
|
||||
}
|
||||
const p = e.path;
|
||||
row.querySelector(".hpath").onclick = () => showHistory({ path: p });
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
for (const e of out.entries || []) wrap.appendChild(historyEntryRow(e));
|
||||
content.appendChild(wrap);
|
||||
}
|
||||
|
||||
/* One change as a row: what happened (added / edited / deleted), to which
|
||||
file, by whom, from where — with view/download of that exact version. */
|
||||
const KIND_ICON = { add: "plus", edit: "edit", delete: "x" };
|
||||
const KIND_LABEL = { add: "added", edit: "edited", delete: "deleted" };
|
||||
function historyEntryRow(e) {
|
||||
const kind = e.kind === "put" ? "edit" : e.kind; // older servers report raw "put" ops
|
||||
const row = document.createElement("div");
|
||||
row.className = "hentry " + kind;
|
||||
const who = e.user_name ? `${e.user_name} <${e.user}>` : (e.user || e.author || "unknown");
|
||||
const dev = [e.device.name || e.device.id, e.device.os, e.device.ip].filter(Boolean).join(" · ");
|
||||
const when = new Date(e.time).toLocaleString();
|
||||
row.innerHTML =
|
||||
`<div class="hline"><span class="hkind"></span><span class="hpath"></span><span class="htag"></span><span class="htime"></span></div>` +
|
||||
`<div class="hmeta"><span class="hwho"></span><span class="hdev"></span><span class="hsize"></span><span class="hact"></span></div>`;
|
||||
row.querySelector(".hkind").innerHTML = svgIcon(KIND_ICON[kind] || "dot");
|
||||
row.querySelector(".hpath").textContent = e.path;
|
||||
row.querySelector(".htag").textContent = KIND_LABEL[kind] || kind;
|
||||
row.querySelector(".htime").textContent = when;
|
||||
row.querySelector(".hwho").textContent = who;
|
||||
row.querySelector(".hdev").textContent = dev;
|
||||
row.querySelector(".hsize").textContent = e.size ? humanSize(e.size) : "";
|
||||
if (kind !== "delete" && e.blob) {
|
||||
const view = document.createElement("a");
|
||||
view.textContent = "view";
|
||||
view.href = apiBase + "blob?sha=" + e.blob + "&name=" + encodeURIComponent(e.path);
|
||||
view.target = "_blank";
|
||||
const dl = document.createElement("a");
|
||||
dl.textContent = "download";
|
||||
dl.href = view.href + "&download=1";
|
||||
dl.setAttribute("download", e.path.split("/").pop());
|
||||
row.querySelector(".hact").append(view, " ", dl);
|
||||
}
|
||||
if (e.note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "hnote";
|
||||
// Linkify http(s) URLs (e.g. a Claude session link); everything else
|
||||
// stays plain text — notes are user/agent input, never markup.
|
||||
for (const tok of e.note.split(/(https?:\/\/\S+)/)) {
|
||||
if (/^https?:\/\//.test(tok)) {
|
||||
const a = document.createElement("a");
|
||||
a.href = tok; a.textContent = tok; a.target = "_blank"; a.rel = "noopener";
|
||||
note.appendChild(a);
|
||||
} else if (tok) {
|
||||
note.append(tok);
|
||||
}
|
||||
}
|
||||
row.appendChild(note);
|
||||
}
|
||||
const p = e.path;
|
||||
row.querySelector(".hpath").onclick = () => showHistory({ path: p });
|
||||
return row;
|
||||
}
|
||||
|
||||
function humanSize(n) {
|
||||
if (n < 1024) return n + " B";
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
@@ -1029,8 +1190,10 @@ function initUpload() {
|
||||
const file = input.files[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
const dir = currentPath && currentPath.includes("/")
|
||||
? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
|
||||
// A selected folder receives the upload; a selected file means "next to it".
|
||||
const dir = !currentPath ? ""
|
||||
: dirIndex.has(currentPath) ? currentPath
|
||||
: currentPath.includes("/") ? currentPath.slice(0, currentPath.lastIndexOf("/")) : "";
|
||||
const dest = dir ? dir + "/" + file.name : file.name;
|
||||
const status = $("meta");
|
||||
try {
|
||||
@@ -1204,6 +1367,9 @@ function paletteCandidates() {
|
||||
if (serverConfig.auth && serverConfig.auth.enabled) {
|
||||
add("power", "Sign out", "action", () => { location.href = "/auth/logout"; });
|
||||
}
|
||||
for (const d of dirIndex.keys()) {
|
||||
add("folder", d, "folder", () => openFolder(d));
|
||||
}
|
||||
for (const f of flatFiles) {
|
||||
add("doc", f.path, "file", () => openFile(f.path));
|
||||
}
|
||||
@@ -1343,7 +1509,7 @@ window.addEventListener("popstate", () => {
|
||||
const proj = projects.find((x) => x.id === project);
|
||||
if (proj) { selectProject(proj, path || null); return; }
|
||||
}
|
||||
if (path && path !== currentPath) openFile(path);
|
||||
if (path && path !== currentPath) openPath(path);
|
||||
});
|
||||
|
||||
boot();
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<symbol id="i-download" viewBox="0 0 24 24"><path d="M12 4v11m0 0 4-4m-4 4-4-4M5 19h14"/></symbol>
|
||||
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 20V9m0 0 4 4m-4-4-4 4M5 5h14"/></symbol>
|
||||
<symbol id="i-dot" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.5" fill="currentColor" stroke="none"/></symbol>
|
||||
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M4 20l1.2-4.2L16.6 4.4a2 2 0 0 1 2.9 2.9L8.2 18.8z"/></symbol>
|
||||
<symbol id="i-enter" viewBox="0 0 24 24"><path d="M9 10 4 15l5 5"/><path d="M20 4v7a4 4 0 0 1-4 4H4"/></symbol>
|
||||
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 6h16M4 12h16M4 18h16"/></symbol>
|
||||
<symbol id="i-dots" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="12" cy="12" r="1.4" fill="currentColor" stroke="none"/><circle cx="19" cy="12" r="1.4" fill="currentColor" stroke="none"/></symbol>
|
||||
|
||||
@@ -174,6 +174,9 @@ button, input, a.btn { font-family: inherit; }
|
||||
.icon-btn { display: none; width: 34px; height: 34px; border: none; background: transparent; color: var(--text-dim); cursor: pointer; border-radius: 7px; align-items: center; justify-content: center; }
|
||||
.icon-btn:hover { color: var(--text); background: var(--hover); }
|
||||
#crumb { font-size: 12.5px; color: var(--text); font-weight: 500; letter-spacing: -.01em; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#crumb .crumb-seg { color: var(--text-dim); cursor: pointer; }
|
||||
#crumb .crumb-seg:hover { color: var(--accent-bright); }
|
||||
#crumb .crumb-sep { color: var(--text-ghost); margin: 0 5px; }
|
||||
#meta { flex: 1; min-width: 0; font-size: 12px; color: var(--text-faint); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.btn {
|
||||
@@ -251,6 +254,27 @@ button, input, a.btn { font-family: inherit; }
|
||||
.tg-desc { font-size: 12px; color: var(--text-faint); margin-top: 3px; line-height: 1.5; }
|
||||
.admin-item.toggle input { width: 16px; height: 16px; margin-top: 2px; accent-color: var(--accent); flex: none; }
|
||||
|
||||
/* ---- folder listing ---- */
|
||||
.dirlist { max-width: 704px; margin: 0 auto; }
|
||||
.dl-title { display: flex; align-items: center; gap: 10px; font-size: 21px; font-weight: 640; letter-spacing: -.02em; margin: 0 0 4px; color: #f4f6f9; }
|
||||
.dl-title-icon { display: flex; color: var(--accent); }
|
||||
.dl-title-icon .ico { width: 20px; height: 20px; }
|
||||
.dl-sub { color: var(--text-faint); font-size: 12.5px; margin: 0 0 18px; }
|
||||
.dl-items { border: 1px solid var(--border); border-radius: var(--r-card); overflow: hidden; background: var(--bg-side); }
|
||||
.dl-row { display: flex; align-items: center; gap: 11px; padding: 10px 14px; border-bottom: 1px solid var(--border); cursor: pointer; }
|
||||
.dl-row:last-child { border-bottom: none; }
|
||||
.dl-row:hover { background: var(--hover); }
|
||||
.dl-row .ticon { flex: none; display: flex; color: var(--text-ghost); }
|
||||
.dl-row .ticon .ico { width: 16px; height: 16px; }
|
||||
.dl-row:hover .ticon { color: var(--text-faint); }
|
||||
.dl-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; color: var(--text); }
|
||||
.dl-meta { flex: none; font-size: 12px; color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||
.dl-empty { padding: 24px 14px; color: var(--text-faint); font-size: 13px; border: 1px dashed var(--border); border-radius: var(--r-card); text-align: center; }
|
||||
.dl-h3 { margin: 28px 0 8px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .07em; color: var(--text-ghost); font-weight: 600; }
|
||||
.dl-hlist { border: 1px solid var(--border); border-radius: var(--r-card); background: var(--bg-side); overflow: hidden; max-width: none; }
|
||||
.dl-hlist .hentry:last-child { border-bottom: none; }
|
||||
.dl-more { margin-top: 10px; }
|
||||
|
||||
/* ---- history ---- */
|
||||
.history { max-width: 860px; }
|
||||
.hentry { padding: 11px 12px; border-bottom: 1px solid var(--border); }
|
||||
@@ -258,7 +282,12 @@ button, input, a.btn { font-family: inherit; }
|
||||
.hline { display: flex; gap: 10px; align-items: center; }
|
||||
.hkind { display: inline-flex; color: var(--add); }
|
||||
.hkind .ico { width: 13px; height: 13px; }
|
||||
.hentry.edit .hkind { color: var(--accent); }
|
||||
.hentry.delete .hkind { color: var(--del); }
|
||||
.htag { flex: none; font-size: 10px; text-transform: uppercase; letter-spacing: .06em; font-weight: 600; padding: 1px 6px; border-radius: 4px; }
|
||||
.hentry.add .htag { color: var(--add); background: rgba(76,195,138,.12); }
|
||||
.hentry.edit .htag { color: var(--accent-bright); background: var(--glow); }
|
||||
.hentry.delete .htag { color: #ff8b8b; background: rgba(242,109,109,.12); }
|
||||
.hpath { font-weight: 500; cursor: pointer; color: var(--text); font-size: 13px; }
|
||||
.hpath:hover { color: var(--accent-bright); }
|
||||
.htime { margin-left: auto; color: var(--text-faint); font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
@@ -268,6 +297,10 @@ button, input, a.btn { font-family: inherit; }
|
||||
.hact { margin-left: auto; }
|
||||
.hact a { color: var(--accent-bright); text-decoration: none; margin-left: 10px; }
|
||||
.hact a:hover { text-decoration: underline; }
|
||||
.hnote { margin-top: 4px; padding-left: 23px; font-size: 12px; color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.hnote::before { content: "› "; color: var(--text-ghost); }
|
||||
.hnote a { color: var(--accent-bright); text-decoration: none; }
|
||||
.hnote a:hover { text-decoration: underline; }
|
||||
|
||||
/* ---- command palette ---- */
|
||||
#palette-overlay { position: fixed; inset: 0; background: rgba(6,7,9,.62); backdrop-filter: blur(3px); display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh; z-index: 100; }
|
||||
@@ -330,7 +363,7 @@ button, input, a.btn { font-family: inherit; }
|
||||
#invite-btn { min-height: 40px; padding: 0 14px; }
|
||||
#org-name { min-height: 44px; }
|
||||
.nav-add { min-width: 44px; min-height: 44px; }
|
||||
.markdown, .admin, .onboard, .history { max-width: 100%; }
|
||||
.markdown, .admin, .onboard, .history, .dirlist { 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