mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(web): search moves beside the brand in the sidebar header, with a ⌘K hover tooltip
Icon-only trigger in the vault header (Linear-style); a tiny search.ts emitter asks Browser's palette to open — no plumbing through the shell. Custom tooltip card (label + kbd chip, arrow) on hover/focus. Topbar search and its centered styles removed. 47/47 e2e incl. header-trigger spec. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VbiaaVM2ACxeRi8ySG9ybc
This commit is contained in:
co-authored by
Claude Fable 5
parent
75f2b0bfe9
commit
3f974d96e4
@@ -88,6 +88,14 @@ test("back/forward walks file → folder → file", async ({ page }) => {
|
||||
await expect(page.locator(".dl-title")).toContainText("notes");
|
||||
});
|
||||
|
||||
test("header search button opens the palette", async ({ page }) => {
|
||||
await login(page);
|
||||
await wikiId(page);
|
||||
await page.click("#search-btn");
|
||||
await expect(page.locator("#palette")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("palette (⌘K) fuzzy-jumps to a file", async ({ page }) => {
|
||||
await login(page);
|
||||
const pid = await wikiId(page);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { urlForPath, urlForView, type Route } from "../router";
|
||||
import { currentNavType, navigate, useLocationPath } from "../nav";
|
||||
import { copyText } from "../util";
|
||||
import { toast } from "../toast";
|
||||
import { onSearchRequest } from "../search";
|
||||
import { AppShell, Icon, Topbar, closeSidebarOnMobile } from "../components/shell";
|
||||
import { FileTree, ancestorsOf } from "../components/FileTree";
|
||||
import { Breadcrumbs } from "../components/Breadcrumbs";
|
||||
@@ -141,6 +142,7 @@ export default function Browser(props: {
|
||||
const [share, setShare] = useState<{ url: string; copied: boolean } | null>(null);
|
||||
const [moreOpen, setMoreOpen] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
useEffect(() => onSearchRequest(() => setPaletteOpen(true)), []);
|
||||
const downloadRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
const panel = props.panel ?? null;
|
||||
@@ -348,9 +350,6 @@ export default function Browser(props: {
|
||||
meta={meta}
|
||||
actions={
|
||||
<>
|
||||
<button id="search-btn" className="btn ghost" title="Search (⌘K)" onClick={() => setPaletteOpen(true)}>
|
||||
<Icon name="search" /> <span className="lbl">Search</span> <kbd>⌘K</kbd>
|
||||
</button>
|
||||
{canShare && (
|
||||
<button id="share-btn" className="btn icon-only" title="Share" aria-label="Share" onClick={shareNow}>
|
||||
<Icon name="share" />
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function HubApp({ config }: { config: ServerConfig }) {
|
||||
|
||||
// Top of the sidebar is the brand; project and account actions live in
|
||||
// their own sections below (PropelAuth-style layout).
|
||||
const vault = <VaultHeader name={brand} onHome={() => navigate("/")} />;
|
||||
const vault = <VaultHeader name={brand} onHome={() => navigate("/")} search={!!current} />;
|
||||
|
||||
const accountBar = config.me ? (
|
||||
<AccountBar
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function VolumeApp({ config }: { config: ServerConfig }) {
|
||||
apiBase="/api/"
|
||||
route={route}
|
||||
hub={false}
|
||||
sidebar={{ vault: <VaultHeader name={name} showSignout={config.auth.enabled} /> }}
|
||||
sidebar={{ vault: <VaultHeader name={name} showSignout={config.auth.enabled} search /> }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { requestSearch } from "../search";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
@@ -118,8 +119,9 @@ export function VaultHeader(props: {
|
||||
name: string;
|
||||
onHome?: () => void; // hub: the project name doubles as a home link
|
||||
showSignout?: boolean; // volume mode: sign-out stays in the header (no account bar)
|
||||
search?: boolean; // icon-only ⌘K search trigger beside the brand
|
||||
}) {
|
||||
const { name, onHome, showSignout } = props;
|
||||
const { name, onHome, showSignout, search } = props;
|
||||
return (
|
||||
<header id="vault">
|
||||
<span id="vault-badge" aria-hidden="true">
|
||||
@@ -141,6 +143,22 @@ export function VaultHeader(props: {
|
||||
{name}
|
||||
</span>
|
||||
<div className="vault-actions">
|
||||
{search && (
|
||||
<button
|
||||
id="search-btn"
|
||||
className="icon-btn2 has-tip"
|
||||
aria-label="Search"
|
||||
onClick={() => {
|
||||
requestSearch();
|
||||
closeSidebarOnMobile();
|
||||
}}
|
||||
>
|
||||
<Icon name="search" />
|
||||
<span className="tip" role="tooltip">
|
||||
Search <kbd>⌘K</kbd>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{showSignout && (
|
||||
<a id="signout" href="/auth/logout" title="Sign out" aria-label="Sign out">
|
||||
<Icon name="power" />
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Tiny cross-component signal: the sidebar's search button asks whichever
|
||||
// surface owns the ⌘K palette (Browser) to open it — same in-repo-emitter
|
||||
// spirit as nav.ts, no context plumbing through the shell.
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
export function onSearchRequest(l: Listener): () => void {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
}
|
||||
|
||||
export function requestSearch(): void {
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
@@ -217,17 +217,27 @@ button, input, a.btn { font-family: inherit; }
|
||||
.btn:hover { background: var(--hover); color: var(--text); border-color: var(--border-2); }
|
||||
.btn .ico { width: 15px; height: 15px; }
|
||||
.btn.ghost { color: var(--text-dim); }
|
||||
/* The search control sits dead-center in the topbar, twice its natural
|
||||
width, reading as an input field. */
|
||||
#search-btn {
|
||||
position: absolute; left: 50%; transform: translateX(-50%);
|
||||
width: 260px; justify-content: flex-start;
|
||||
/* Hover tooltip for icon-only controls (the header search): label + kbd
|
||||
chip in a floating card below the button, arrow pointing up. */
|
||||
.has-tip { position: relative; }
|
||||
.has-tip .tip {
|
||||
position: absolute; top: calc(100% + 8px); left: 50%; transform: translateX(-50%);
|
||||
display: flex; align-items: center; gap: 7px; white-space: nowrap;
|
||||
padding: 6px 9px; border-radius: 8px; border: 1px solid var(--border-2);
|
||||
background: var(--surface); color: var(--text); font-size: 12.5px; font-weight: 500;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, .35);
|
||||
opacity: 0; visibility: hidden; transition: opacity .12s ease .15s; z-index: 40;
|
||||
}
|
||||
#search-btn kbd { margin-left: auto; }
|
||||
#search-btn kbd {
|
||||
.has-tip .tip::before {
|
||||
content: ""; position: absolute; top: -4.5px; left: 50%;
|
||||
width: 8px; height: 8px; transform: translateX(-50%) rotate(45deg);
|
||||
background: var(--surface); border-left: 1px solid var(--border-2); border-top: 1px solid var(--border-2);
|
||||
}
|
||||
.has-tip:hover .tip, .has-tip:focus-visible .tip { opacity: 1; visibility: visible; }
|
||||
.has-tip .tip kbd {
|
||||
font: 11px var(--ui); color: var(--text-faint);
|
||||
background: var(--hover); border: 1px solid var(--border-2); border-radius: 5px;
|
||||
padding: 1px 5px; margin-left: 2px;
|
||||
padding: 1px 5px;
|
||||
}
|
||||
#more-menu {
|
||||
position: absolute; right: 12px; top: calc(100% - 4px); z-index: 80;
|
||||
@@ -463,8 +473,7 @@ button, input, a.btn { font-family: inherit; }
|
||||
.icon-btn { display: inline-flex; width: 44px; height: 44px; }
|
||||
#content { padding: 24px 18px 70px; }
|
||||
#topbar { padding: 0 8px; gap: 4px; }
|
||||
#search-btn { position: static; transform: none; width: auto; justify-content: center; }
|
||||
#search-btn kbd, .btn .lbl { display: none; }
|
||||
.btn .lbl { display: none; }
|
||||
#topbar .btn { min-width: 44px; min-height: 44px; padding: 0; justify-content: center; gap: 0; }
|
||||
#topbar .btn .ico { width: 18px; height: 18px; }
|
||||
#more-btn:not([hidden]) { display: inline-flex; }
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>BearDrive</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🐻</text></svg>">
|
||||
<script type="module" crossorigin src="/assets/index-XRsupwXA.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CCQsgUR1.css">
|
||||
<script type="module" crossorigin src="/assets/index-Ce0U9QAi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-4kYunG_6.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -220,6 +220,35 @@ h2 { font-size: clamp(26px, 3.2vw, 34px); margin: 0 0 12px; letter-spacing: -.02
|
||||
.claude ul li::before { content: "✓"; position: absolute; left: 0; color: var(--brand); font-weight: 600; }
|
||||
.claude ul b { color: var(--text); font-weight: 600; }
|
||||
|
||||
/* auto-share reply mock */
|
||||
.autoshare { text-align: center; }
|
||||
.autoshare .lede { margin-left: auto; margin-right: auto; }
|
||||
.reply {
|
||||
max-width: 660px; margin: 6px auto 0; text-align: left;
|
||||
background: #101010; border: 1px solid var(--border); border-radius: 10px;
|
||||
box-shadow: 0 0 0 1px rgba(245,166,35,.05), 0 24px 64px rgba(0,0,0,.55);
|
||||
overflow: hidden;
|
||||
}
|
||||
.reply-line {
|
||||
padding: 24px 26px; font: 15px/1.7 var(--mono); color: var(--text);
|
||||
display: flex; gap: 13px; align-items: baseline;
|
||||
}
|
||||
.reply-dot { color: var(--brand); font-size: 11px; line-height: 1.7; }
|
||||
.reply-path { color: #6fbfff; word-break: break-all; }
|
||||
.reply-link {
|
||||
color: var(--brand); text-decoration: none; cursor: pointer;
|
||||
padding: 1px 4px; border-radius: 5px; transition: background .15s;
|
||||
}
|
||||
.reply-link:hover { background: rgba(245,166,35,.16); }
|
||||
.reply-cap {
|
||||
border-top: 1px solid #1e1e1e; background: #0c0c0c;
|
||||
padding: 13px 26px; font: 12.5px/1.6 var(--mono); color: var(--text-faint);
|
||||
}
|
||||
.reply-cap code {
|
||||
font: 11.5px var(--mono); background: var(--card);
|
||||
border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* open source / cloud */
|
||||
.oss { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; }
|
||||
.oss-card {
|
||||
@@ -345,6 +374,25 @@ every tool works content-addressed blobs append-only, no locks, offl
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="autoshare" class="autoshare">
|
||||
<div class="wrap">
|
||||
<p class="kicker">SHARE WITHOUT A SHARE STEP</p>
|
||||
<h2>The link is born with the file.</h2>
|
||||
<p class="lede">The moment your agent writes a file, the path in its reply is
|
||||
already a clickable, members-only hub link. You didn't run a command, you didn't
|
||||
copy a path — any teammate who's signed in opens it in one click, rendered and at
|
||||
the latest version. It's gated to your project, so it's safe to paste in any
|
||||
internal channel.</p>
|
||||
<div class="reply" aria-label="Example agent reply with an auto-generated hub link">
|
||||
<div class="reply-line">
|
||||
<span class="reply-dot">●</span>
|
||||
<span>Deal page written to <span class="reply-path">shared/wiki/deals/motive.md</span> <span class="reply-link" role="link" tabindex="0" title="Opens for signed-in teammates on the project">🔗</span></span>
|
||||
</div>
|
||||
<div class="reply-cap">↑ your agent's own reply — the <span class="reply-link" style="cursor:default">🔗</span> is a live hub link it added automatically. No <code>bdrive share</code>, no copy-paste, nobody asking you to resend it.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="why">
|
||||
<div class="wrap">
|
||||
<p class="kicker">WHY BEARDRIVE</p>
|
||||
|
||||
Reference in New Issue
Block a user