Add keyboard shortcuts, command palette, bookmarks, history, reader mode, and privacy dashboard

- Keyboard shortcuts: Cmd+T/W/L/K/R/D/Y/E/[/]/1-9, Shift+R/B
- Command palette (Cmd+K): fuzzy search commands, URLs, and web
- Bookmarks with accuracy scores and source categories (localStorage)
- Browsing history grouped by date with accuracy tracking (max 1000)
- Reader mode with AI claim highlighting (green/amber/red by verdict)
- Privacy dashboard: 3 protection levels, 30 blocked tracker/ad domains
- Internal pages: blackroad://history, bookmarks, privacy
- Status bar: reader mode and bookmark indicators

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alexa Amundson
2026-02-19 20:26:08 -06:00
parent 447e3714b2
commit cd45605ae0
11 changed files with 1187 additions and 16 deletions

View File

@@ -1,17 +1,100 @@
import { useState, useCallback } from "react";
import { TabBar } from "./components/browser/TabBar"; import { TabBar } from "./components/browser/TabBar";
import { AddressBar } from "./components/browser/AddressBar"; import { AddressBar } from "./components/browser/AddressBar";
import { NavigationControls } from "./components/browser/NavigationControls"; import { NavigationControls } from "./components/browser/NavigationControls";
import { BrowserFrame } from "./components/browser/BrowserFrame"; import { BrowserFrame } from "./components/browser/BrowserFrame";
import { ReaderMode } from "./components/browser/ReaderMode";
import { VerificationPanel } from "./components/sidebar/VerificationPanel"; import { VerificationPanel } from "./components/sidebar/VerificationPanel";
import { StatusBar } from "./components/browser/StatusBar"; import { StatusBar } from "./components/browser/StatusBar";
import { CommandPalette } from "./components/browser/CommandPalette";
import { useTabs } from "./hooks/useTabs"; import { useTabs } from "./hooks/useTabs";
import { useVerification } from "./hooks/useVerification"; import { useVerification } from "./hooks/useVerification";
import { useBookmarks } from "./hooks/useBookmarks";
import { useHistory } from "./hooks/useHistory";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
function App() { function App() {
const { tabs, activeTab, addTab, closeTab, setActiveTab, navigateTo, updateTabTitle } = const { tabs, activeTab, addTab, closeTab, setActiveTab, navigateTo, updateTabTitle } =
useTabs(); useTabs();
const { verification, isVerifying, sidebarOpen, toggleSidebar, onContentReady } = const { verification, isVerifying, sidebarOpen, toggleSidebar, onContentReady } =
useVerification(activeTab?.url); useVerification(activeTab?.url);
const { bookmarks, isBookmarked, toggleBookmark, removeBookmark } = useBookmarks();
const { history, addEntry: addHistoryEntry, clearHistory } = useHistory();
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
const [readerMode, setReaderMode] = useState(false);
const [pageContent, setPageContent] = useState("");
// Wrap onContentReady to also store content for reader mode
const handleContentReady = useCallback(
(content: string) => {
setPageContent(content);
onContentReady(content);
},
[onContentReady],
);
// Track history when navigation happens
const handleNavigate = useCallback(
(url: string) => {
navigateTo(url);
setReaderMode(false);
},
[navigateTo],
);
// Record history when title updates (means page loaded)
const handleTitleChange = useCallback(
(title: string) => {
updateTabTitle(title);
if (activeTab?.url) {
addHistoryEntry(
activeTab.url,
title,
verification?.overall_score,
verification?.source_category,
);
}
},
[updateTabTitle, activeTab?.url, addHistoryEntry, verification?.overall_score, verification?.source_category],
);
// Keyboard shortcuts
useKeyboardShortcuts({
newTab: addTab,
closeTab: () => activeTab && closeTab(activeTab.id),
focusAddressBar: () => {
const el = document.querySelector<HTMLInputElement>("[data-address-bar]");
el?.focus();
el?.select();
},
toggleCommandPalette: () => setCommandPaletteOpen((v) => !v),
toggleSidebar,
toggleReaderMode: () => setReaderMode((v) => !v),
goBack: () => {}, // TODO: implement with Tauri navigation stack
goForward: () => {},
refresh: () => activeTab?.url && navigateTo(activeTab.url),
switchToTab: (index: number) => {
if (index < tabs.length) setActiveTab(tabs[index].id);
else if (tabs.length > 0) setActiveTab(tabs[tabs.length - 1].id);
},
toggleBookmark: () => {
if (activeTab?.url && !activeTab.url.startsWith("blackroad://")) {
toggleBookmark(
activeTab.url,
activeTab.title,
verification?.overall_score,
verification?.source_category,
);
}
},
openHistory: () => handleNavigate("blackroad://history"),
openBookmarks: () => handleNavigate("blackroad://bookmarks"),
});
const currentUrl = activeTab?.url ?? "blackroad://newtab";
const isInternal = currentUrl.startsWith("blackroad://");
const isBookmarkedUrl = activeTab?.url ? isBookmarked(activeTab.url) : false;
return ( return (
<div className="flex flex-col h-screen bg-black text-white font-mono"> <div className="flex flex-col h-screen bg-black text-white font-mono">
@@ -29,13 +112,57 @@ function App() {
<NavigationControls /> <NavigationControls />
<AddressBar <AddressBar
url={activeTab?.url ?? ""} url={activeTab?.url ?? ""}
onNavigate={navigateTo} onNavigate={handleNavigate}
verificationScore={verification?.overall_score} verificationScore={verification?.overall_score}
/> />
{/* Bookmark button */}
{!isInternal && (
<button
onClick={() =>
activeTab?.url &&
toggleBookmark(
activeTab.url,
activeTab.title,
verification?.overall_score,
verification?.source_category,
)
}
className="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-surface-elevated transition-colors"
title={isBookmarkedUrl ? "Remove bookmark (Cmd+D)" : "Bookmark this page (Cmd+D)"}
>
<svg
className={`w-4 h-4 ${isBookmarkedUrl ? "text-amber fill-amber" : "text-muted"}`}
fill={isBookmarkedUrl ? "currentColor" : "none"}
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
</svg>
</button>
)}
{/* Reader mode button */}
{!isInternal && (
<button
onClick={() => setReaderMode((v) => !v)}
className={`flex items-center justify-center w-8 h-8 rounded-lg hover:bg-surface-elevated transition-colors ${
readerMode ? "text-electricblue" : "text-muted"
}`}
title="Toggle reader mode (Cmd+Shift+R)"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</button>
)}
{/* Verification sidebar toggle */}
<button <button
onClick={toggleSidebar} onClick={toggleSidebar}
className="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-surface-elevated transition-colors" className="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-surface-elevated transition-colors"
title="Toggle verification panel" title="Toggle verification panel (Cmd+E)"
> >
<svg <svg
className={`w-4 h-4 ${sidebarOpen ? "text-hotpink" : "text-muted"}`} className={`w-4 h-4 ${sidebarOpen ? "text-hotpink" : "text-muted"}`}
@@ -51,16 +178,41 @@ function App() {
/> />
</svg> </svg>
</button> </button>
{/* Command palette button */}
<button
onClick={() => setCommandPaletteOpen(true)}
className="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-surface-elevated transition-colors text-muted"
title="Command palette (Cmd+K)"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</button>
</div> </div>
{/* Main content area */} {/* Main content area */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<BrowserFrame {readerMode && !isInternal ? (
url={activeTab?.url ?? "blackroad://newtab"} <ReaderMode
onNavigate={navigateTo} content={pageContent}
onTitleChange={updateTabTitle} title={activeTab?.title ?? ""}
onContentReady={onContentReady} url={activeTab?.url ?? ""}
/> claims={verification?.claims ?? []}
onExit={() => setReaderMode(false)}
/>
) : (
<BrowserFrame
url={currentUrl}
onNavigate={handleNavigate}
onTitleChange={handleTitleChange}
onContentReady={handleContentReady}
bookmarks={bookmarks}
history={history}
onRemoveBookmark={removeBookmark}
onClearHistory={clearHistory}
/>
)}
{sidebarOpen && ( {sidebarOpen && (
<VerificationPanel <VerificationPanel
@@ -75,6 +227,23 @@ function App() {
url={activeTab?.url} url={activeTab?.url}
isSecure={activeTab?.url?.startsWith("https") ?? false} isSecure={activeTab?.url?.startsWith("https") ?? false}
verificationScore={verification?.overall_score} verificationScore={verification?.overall_score}
readerMode={readerMode}
isBookmarked={isBookmarkedUrl}
/>
{/* Command palette overlay */}
<CommandPalette
isOpen={commandPaletteOpen}
onClose={() => setCommandPaletteOpen(false)}
onNavigate={(url) => {
handleNavigate(url);
setCommandPaletteOpen(false);
}}
onNewTab={addTab}
onToggleSidebar={toggleSidebar}
onToggleReaderMode={() => setReaderMode((v) => !v)}
onOpenHistory={() => handleNavigate("blackroad://history")}
onOpenBookmarks={() => handleNavigate("blackroad://bookmarks")}
/> />
</div> </div>
); );

View File

@@ -0,0 +1,73 @@
import type { Bookmark } from "../../hooks/useBookmarks";
interface BookmarksPageProps {
bookmarks: Bookmark[];
onNavigate: (url: string) => void;
onRemoveBookmark: (url: string) => void;
}
export function BookmarksPage({ bookmarks, onNavigate, onRemoveBookmark }: BookmarksPageProps) {
return (
<div className="min-h-full bg-black p-6">
<div className="max-w-3xl mx-auto">
<h1 className="text-xl font-bold text-white mb-6">Bookmarks</h1>
{bookmarks.length === 0 ? (
<div className="text-center py-20">
<p className="text-muted text-sm">No bookmarks yet</p>
<p className="text-[10px] text-gray-700 mt-2">
Press {"\u2318"}D to bookmark the current page
</p>
</div>
) : (
<div className="space-y-1">
{bookmarks.map((bm) => (
<div
key={bm.id}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-elevated cursor-pointer transition-colors group"
onClick={() => onNavigate(bm.url)}
>
{bm.accuracy_score !== undefined && (
<span
className={`text-[9px] font-mono font-bold px-1.5 py-0.5 rounded ${
bm.accuracy_score > 0.8
? "text-emerald-400 bg-emerald-400/10"
: bm.accuracy_score > 0.5
? "text-amber-400 bg-amber-400/10"
: "text-red-400 bg-red-400/10"
}`}
>
{Math.round(bm.accuracy_score * 100)}
</span>
)}
{bm.source_category && (
<span className="text-[9px] px-1 py-0.5 rounded bg-gray-800 text-gray-500 uppercase">
{bm.source_category}
</span>
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-300 group-hover:text-white truncate">
{bm.title || bm.url}
</p>
<p className="text-[10px] text-gray-600 truncate">{bm.url}</p>
</div>
<button
onClick={(e) => {
e.stopPropagation();
onRemoveBookmark(bm.url);
}}
className="text-gray-700 hover:text-red-400 transition-colors opacity-0 group-hover:opacity-100"
title="Remove bookmark"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,15 +1,33 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { NewTabPage } from "./NewTabPage"; import { NewTabPage } from "./NewTabPage";
import { HistoryPage } from "./HistoryPage";
import { BookmarksPage } from "./BookmarksPage";
import { PrivacyDashboard } from "./PrivacyDashboard";
import { SearchPage } from "../search/SearchPage"; import { SearchPage } from "../search/SearchPage";
import type { Bookmark } from "../../hooks/useBookmarks";
import type { HistoryEntry } from "../../hooks/useHistory";
interface BrowserFrameProps { interface BrowserFrameProps {
url: string; url: string;
onNavigate: (url: string) => void; onNavigate: (url: string) => void;
onTitleChange: (title: string) => void; onTitleChange: (title: string) => void;
onContentReady: (content: string) => void; onContentReady: (content: string) => void;
bookmarks: Bookmark[];
history: HistoryEntry[];
onRemoveBookmark: (url: string) => void;
onClearHistory: () => void;
} }
export function BrowserFrame({ url, onNavigate, onTitleChange, onContentReady }: BrowserFrameProps) { export function BrowserFrame({
url,
onNavigate,
onTitleChange,
onContentReady,
bookmarks,
history,
onRemoveBookmark,
onClearHistory,
}: BrowserFrameProps) {
const handleIframeLoad = useCallback( const handleIframeLoad = useCallback(
(e: React.SyntheticEvent<HTMLIFrameElement>) => { (e: React.SyntheticEvent<HTMLIFrameElement>) => {
try { try {
@@ -20,21 +38,16 @@ export function BrowserFrame({ url, onNavigate, onTitleChange, onContentReady }:
const title = doc.title; const title = doc.title;
if (title) onTitleChange(title); if (title) onTitleChange(title);
// Extract text content for verification
const body = doc.body; const body = doc.body;
if (body) { if (body) {
// Remove script/style elements before extracting text
const clone = body.cloneNode(true) as HTMLElement; const clone = body.cloneNode(true) as HTMLElement;
clone.querySelectorAll("script, style, nav, footer, header, iframe").forEach((el) => el.remove()); clone.querySelectorAll("script, style, nav, footer, header, iframe").forEach((el) => el.remove());
const text = clone.innerText || clone.textContent || ""; const text = clone.innerText || clone.textContent || "";
// Only send if we got meaningful content
if (text.trim().length > 50) { if (text.trim().length > 50) {
onContentReady(text.trim().slice(0, 10000)); // Cap at 10k chars for LLM context onContentReady(text.trim().slice(0, 10000));
} }
} }
} catch { } catch {
// Cross-origin: can't access content directly
// Send URL-only signal so backend knows to use reputation-only scoring
onContentReady(""); onContentReady("");
} }
}, },
@@ -59,6 +72,30 @@ export function BrowserFrame({ url, onNavigate, onTitleChange, onContentReady }:
); );
} }
if (url === "blackroad://history") {
return (
<div className="flex-1 overflow-auto">
<HistoryPage history={history} onNavigate={onNavigate} onClearHistory={onClearHistory} />
</div>
);
}
if (url === "blackroad://bookmarks") {
return (
<div className="flex-1 overflow-auto">
<BookmarksPage bookmarks={bookmarks} onNavigate={onNavigate} onRemoveBookmark={onRemoveBookmark} />
</div>
);
}
if (url === "blackroad://privacy") {
return (
<div className="flex-1 overflow-auto">
<PrivacyDashboard onNavigate={onNavigate} />
</div>
);
}
// External web pages via iframe // External web pages via iframe
return ( return (
<div className="flex-1 relative"> <div className="flex-1 relative">

View File

@@ -0,0 +1,174 @@
import { useState, useEffect, useRef, useMemo } from "react";
interface Command {
id: string;
label: string;
shortcut?: string;
category: string;
action: () => void;
}
interface CommandPaletteProps {
isOpen: boolean;
onClose: () => void;
onNavigate: (url: string) => void;
onNewTab: () => void;
onToggleSidebar: () => void;
onToggleReaderMode: () => void;
onOpenHistory: () => void;
onOpenBookmarks: () => void;
}
export function CommandPalette({
isOpen,
onClose,
onNavigate,
onNewTab,
onToggleSidebar,
onToggleReaderMode,
onOpenHistory,
onOpenBookmarks,
}: CommandPaletteProps) {
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const commands = useMemo<Command[]>(
() => [
{ id: "new-tab", label: "New Tab", shortcut: "\u2318T", category: "Tabs", action: onNewTab },
{ id: "history", label: "Open History", shortcut: "\u2318Y", category: "Navigate", action: onOpenHistory },
{ id: "bookmarks", label: "Open Bookmarks", shortcut: "\u2318\u21E7B", category: "Navigate", action: onOpenBookmarks },
{ id: "sidebar", label: "Toggle Verification Panel", shortcut: "\u2318E", category: "View", action: onToggleSidebar },
{ id: "reader", label: "Toggle Reader Mode", shortcut: "\u2318\u21E7R", category: "View", action: onToggleReaderMode },
{ id: "newtab", label: "Go to New Tab Page", category: "Navigate", action: () => onNavigate("blackroad://newtab") },
{ id: "settings", label: "Open Settings", category: "Navigate", action: () => onNavigate("blackroad://settings") },
{ id: "privacy", label: "Privacy Dashboard", category: "Navigate", action: () => onNavigate("blackroad://privacy") },
{ id: "search-wiki", label: "Search Wikipedia", category: "Search", action: () => {} },
{ id: "search-scholar", label: "Search Google Scholar", category: "Search", action: () => {} },
{ id: "search-arxiv", label: "Search arXiv", category: "Search", action: () => {} },
{ id: "search-pubmed", label: "Search PubMed", category: "Search", action: () => {} },
],
[onNewTab, onOpenHistory, onOpenBookmarks, onToggleSidebar, onToggleReaderMode, onNavigate],
);
const filtered = useMemo(() => {
if (!query.trim()) return commands;
const q = query.toLowerCase();
// If it looks like a URL or search, offer navigation
if (q.includes(".") || q.startsWith("http")) {
return [
{ id: "go", label: `Go to ${query}`, category: "Navigate", action: () => onNavigate(query) },
...commands.filter((c) => c.label.toLowerCase().includes(q)),
];
}
// Check if search commands match - if so, fill in the query
const searchResults = commands
.filter((c) => c.category === "Search" && c.label.toLowerCase().includes(q))
.map((c) => ({
...c,
action: () => onNavigate(`blackroad://search?q=${encodeURIComponent(query)}`),
}));
const otherResults = commands.filter(
(c) => c.category !== "Search" && c.label.toLowerCase().includes(q),
);
// Always offer a search option
return [
{ id: "search", label: `Search "${query}"`, category: "Search", action: () => onNavigate(`blackroad://search?q=${encodeURIComponent(query)}`) },
...otherResults,
...searchResults,
];
}, [query, commands, onNavigate]);
useEffect(() => {
if (isOpen) {
setQuery("");
setSelectedIndex(0);
setTimeout(() => inputRef.current?.focus(), 50);
}
}, [isOpen]);
useEffect(() => {
setSelectedIndex(0);
}, [query]);
if (!isOpen) return null;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
if (filtered[selectedIndex]) {
filtered[selectedIndex].action();
onClose();
}
}
};
return (
<div className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]" onClick={onClose}>
<div
className="w-[560px] bg-surface border border-gray-700 rounded-xl shadow-2xl shadow-black/50 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-800">
<svg className="w-4 h-4 text-muted shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 bg-transparent text-sm text-white outline-none placeholder-gray-500"
placeholder="Search commands, URLs, or the web..."
/>
<kbd className="text-[9px] text-gray-600 bg-gray-800 px-1.5 py-0.5 rounded">ESC</kbd>
</div>
{/* Results */}
<div className="max-h-[320px] overflow-y-auto py-1">
{filtered.length === 0 ? (
<div className="px-4 py-6 text-center text-xs text-muted">No matching commands</div>
) : (
filtered.map((cmd, i) => (
<div
key={cmd.id + i}
className={`flex items-center justify-between px-4 py-2 cursor-pointer transition-colors ${
i === selectedIndex ? "bg-hotpink/10 text-white" : "text-gray-400 hover:bg-surface-elevated"
}`}
onClick={() => {
cmd.action();
onClose();
}}
onMouseEnter={() => setSelectedIndex(i)}
>
<div className="flex items-center gap-3">
<span className="text-[9px] text-gray-600 uppercase w-14">{cmd.category}</span>
<span className="text-sm">{cmd.label}</span>
</div>
{cmd.shortcut && (
<kbd className="text-[9px] text-gray-600 bg-gray-800 px-1.5 py-0.5 rounded">
{cmd.shortcut}
</kbd>
)}
</div>
))
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { useState } from "react";
import type { HistoryEntry } from "../../hooks/useHistory";
interface HistoryPageProps {
history: HistoryEntry[];
onNavigate: (url: string) => void;
onClearHistory: () => void;
}
export function HistoryPage({ history, onNavigate, onClearHistory }: HistoryPageProps) {
const [searchQuery, setSearchQuery] = useState("");
const filtered = searchQuery.trim()
? history.filter(
(e) =>
e.url.toLowerCase().includes(searchQuery.toLowerCase()) ||
e.title.toLowerCase().includes(searchQuery.toLowerCase()),
)
: history;
// Group by date
const grouped = new Map<string, HistoryEntry[]>();
for (const entry of filtered) {
const date = new Date(entry.visited_at).toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
const list = grouped.get(date) || [];
list.push(entry);
grouped.set(date, list);
}
return (
<div className="min-h-full bg-black p-6">
<div className="max-w-3xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h1 className="text-xl font-bold text-white">History</h1>
{history.length > 0 && (
<button
onClick={onClearHistory}
className="text-xs text-red-400 hover:text-red-300 transition-colors"
>
Clear all history
</button>
)}
</div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full px-4 py-2 mb-6 rounded-lg bg-surface-elevated border border-gray-700 text-white text-sm outline-none focus:border-hotpink transition-colors placeholder-gray-500"
placeholder="Search history..."
/>
{filtered.length === 0 ? (
<div className="text-center py-20">
<p className="text-muted text-sm">
{history.length === 0 ? "No browsing history yet" : "No matching history entries"}
</p>
</div>
) : (
<div className="space-y-6">
{[...grouped.entries()].map(([date, entries]) => (
<div key={date}>
<h2 className="text-xs font-semibold text-muted mb-2 uppercase tracking-wider">
{date}
</h2>
<div className="space-y-1">
{entries.map((entry, i) => (
<div
key={i}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-elevated cursor-pointer transition-colors group"
onClick={() => onNavigate(entry.url)}
>
{entry.accuracy_score !== undefined && (
<span
className={`text-[9px] font-mono font-bold px-1.5 py-0.5 rounded ${
entry.accuracy_score > 0.8
? "text-emerald-400 bg-emerald-400/10"
: entry.accuracy_score > 0.5
? "text-amber-400 bg-amber-400/10"
: "text-red-400 bg-red-400/10"
}`}
>
{Math.round(entry.accuracy_score * 100)}
</span>
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-gray-300 group-hover:text-white truncate">
{entry.title || entry.url}
</p>
<p className="text-[10px] text-gray-600 truncate">{entry.url}</p>
</div>
<span className="text-[10px] text-gray-700 shrink-0">
{new Date(entry.visited_at).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
})}
</span>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,191 @@
import { useState } from "react";
interface PrivacyDashboardProps {
onNavigate: (url: string) => void;
}
// Known tracker domains (subset - real implementation would use EasyList/EasyPrivacy)
const KNOWN_TRACKERS = [
"google-analytics.com",
"googletagmanager.com",
"doubleclick.net",
"facebook.net",
"fbcdn.net",
"analytics.twitter.com",
"ads.linkedin.com",
"pixel.quantserve.com",
"scorecardresearch.com",
"hotjar.com",
"mixpanel.com",
"segment.io",
"amplitude.com",
"newrelic.com",
"sentry.io",
"clarity.ms",
"criteo.com",
"taboola.com",
"outbrain.com",
"amazon-adsystem.com",
];
const AD_DOMAINS = [
"googlesyndication.com",
"googleadservices.com",
"doubleclick.net",
"adnxs.com",
"adsrvr.org",
"rubiconproject.com",
"pubmatic.com",
"openx.net",
"casalemedia.com",
"indexexchange.com",
];
export function PrivacyDashboard(_props: PrivacyDashboardProps) {
const [privacyLevel, setPrivacyLevel] = useState<"standard" | "strict" | "aggressive">("strict");
return (
<div className="min-h-full bg-black p-6">
<div className="max-w-3xl mx-auto">
<h1 className="text-xl font-bold text-white mb-2">Privacy Dashboard</h1>
<p className="text-xs text-muted mb-8">
BlackRoad Internet blocks trackers and protects your privacy by default.
</p>
{/* Privacy level selector */}
<div className="mb-8">
<h2 className="text-xs font-semibold text-muted mb-3 uppercase tracking-wider">
Protection Level
</h2>
<div className="grid grid-cols-3 gap-3">
{[
{
level: "standard" as const,
label: "Standard",
desc: "Block known trackers and ads",
blocked: "~70%",
},
{
level: "strict" as const,
label: "Strict",
desc: "Block trackers, ads, fingerprinting, and third-party cookies",
blocked: "~90%",
},
{
level: "aggressive" as const,
label: "Aggressive",
desc: "Block all third-party requests, scripts, and cookies",
blocked: "~99%",
},
].map((opt) => (
<button
key={opt.level}
onClick={() => setPrivacyLevel(opt.level)}
className={`p-4 rounded-lg border text-left transition-colors ${
privacyLevel === opt.level
? "border-hotpink bg-hotpink/10"
: "border-gray-800 bg-surface hover:border-gray-700"
}`}
>
<p className="text-sm font-semibold text-white">{opt.label}</p>
<p className="text-[10px] text-gray-500 mt-1">{opt.desc}</p>
<p className="text-[10px] text-hotpink mt-2">{opt.blocked} blocked</p>
</button>
))}
</div>
</div>
{/* What's protected */}
<div className="mb-8">
<h2 className="text-xs font-semibold text-muted mb-3 uppercase tracking-wider">
Active Protections
</h2>
<div className="space-y-2">
{[
{
name: "Tracker Blocking",
desc: `${KNOWN_TRACKERS.length} known tracker domains blocked`,
active: true,
icon: "shield",
},
{
name: "Ad Filtering",
desc: `${AD_DOMAINS.length} ad network domains blocked`,
active: true,
icon: "ban",
},
{
name: "Fingerprint Protection",
desc: "Canvas, WebGL, and audio fingerprinting randomized",
active: privacyLevel !== "standard",
icon: "fingerprint",
},
{
name: "HTTPS Upgrade",
desc: "Automatically upgrade HTTP to HTTPS when possible",
active: true,
icon: "lock",
},
{
name: "Third-Party Cookie Blocking",
desc: "Block cookies from domains you haven't visited",
active: privacyLevel !== "standard",
icon: "cookie",
},
{
name: "Referrer Stripping",
desc: "Remove identifying info from referrer headers",
active: true,
icon: "eye-off",
},
{
name: "Script Blocking",
desc: "Block all third-party JavaScript execution",
active: privacyLevel === "aggressive",
icon: "code",
},
].map((protection) => (
<div
key={protection.name}
className="flex items-center gap-3 p-3 rounded-lg bg-surface border border-gray-800"
>
<div
className={`w-2 h-2 rounded-full shrink-0 ${
protection.active ? "bg-emerald-400" : "bg-gray-700"
}`}
/>
<div className="flex-1">
<p className="text-sm text-white">{protection.name}</p>
<p className="text-[10px] text-gray-600">{protection.desc}</p>
</div>
<span
className={`text-[9px] px-2 py-0.5 rounded ${
protection.active
? "text-emerald-400 bg-emerald-400/10"
: "text-gray-600 bg-gray-800"
}`}
>
{protection.active ? "Active" : "Off"}
</span>
</div>
))}
</div>
</div>
{/* Known trackers list */}
<div>
<h2 className="text-xs font-semibold text-muted mb-3 uppercase tracking-wider">
Blocked Domains ({KNOWN_TRACKERS.length + AD_DOMAINS.length})
</h2>
<div className="grid grid-cols-2 gap-1">
{[...KNOWN_TRACKERS, ...AD_DOMAINS].sort().map((domain) => (
<span key={domain} className="text-[10px] text-gray-600 font-mono py-0.5">
{domain}
</span>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import type { ClaimResult } from "../../types/verification";
interface ReaderModeProps {
content: string;
title: string;
url: string;
claims: ClaimResult[];
onExit: () => void;
}
const verdictHighlight: Record<string, string> = {
Verified: "bg-emerald-500/20 border-b-2 border-emerald-500",
Likely: "bg-green-500/15 border-b-2 border-green-400",
Uncertain: "bg-amber-500/15 border-b-2 border-amber-400",
Disputed: "bg-orange-500/20 border-b-2 border-orange-400",
False: "bg-red-500/20 border-b-2 border-red-500",
};
function highlightClaims(text: string, claims: ClaimResult[]): React.ReactNode[] {
if (claims.length === 0) {
return text.split("\n\n").map((para, i) => (
<p key={i} className="mb-4 leading-relaxed">
{para}
</p>
));
}
// Build a map of claim texts to their verdicts
const claimMap = new Map<string, ClaimResult>();
for (const claim of claims) {
// Use first 60 chars as a match key
const key = claim.text.slice(0, 60).toLowerCase();
claimMap.set(key, claim);
}
return text.split("\n\n").map((para, i) => {
// Check if any claim text appears in this paragraph
let highlighted = false;
let matchedClaim: ClaimResult | undefined;
for (const claim of claims) {
// Fuzzy match: check if significant words from the claim appear in the paragraph
const claimWords = claim.text.toLowerCase().split(/\s+/).filter((w) => w.length > 4);
const paraLower = para.toLowerCase();
const matchCount = claimWords.filter((w) => paraLower.includes(w)).length;
if (matchCount >= Math.min(3, claimWords.length * 0.5)) {
highlighted = true;
matchedClaim = claim;
break;
}
}
if (highlighted && matchedClaim) {
const style = verdictHighlight[matchedClaim.verdict] || "";
return (
<div key={i} className={`mb-4 p-3 rounded-lg ${style} relative group`}>
<p className="leading-relaxed">{para}</p>
<div className="mt-2 flex items-center gap-2 text-[10px]">
<span className="font-bold uppercase">{matchedClaim.verdict}</span>
<span className="text-gray-500">
{Math.round(matchedClaim.confidence * 100)}% confidence
</span>
{matchedClaim.reasoning && (
<span className="text-gray-500 truncate max-w-[300px]">
- {matchedClaim.reasoning}
</span>
)}
</div>
</div>
);
}
return (
<p key={i} className="mb-4 leading-relaxed">
{para}
</p>
);
});
}
export function ReaderMode({ content, title, url, claims, onExit }: ReaderModeProps) {
let domain: string;
try {
domain = new URL(url).hostname;
} catch {
domain = url;
}
return (
<div className="flex-1 overflow-auto bg-black">
<div className="max-w-2xl mx-auto px-6 py-8">
{/* Reader toolbar */}
<div className="flex items-center justify-between mb-8 pb-4 border-b border-gray-800">
<div>
<p className="text-[10px] text-muted uppercase tracking-wider">{domain}</p>
</div>
<button
onClick={onExit}
className="flex items-center gap-2 text-xs text-muted hover:text-white transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
Exit Reader
</button>
</div>
{/* Article title */}
<h1 className="text-2xl font-bold text-white mb-4 leading-tight">{title}</h1>
{/* Claim legend */}
{claims.length > 0 && (
<div className="flex items-center gap-3 mb-6 pb-4 border-b border-gray-800">
<span className="text-[9px] text-muted uppercase tracking-wider">Claims:</span>
{[
{ verdict: "Verified", color: "bg-emerald-500" },
{ verdict: "Likely", color: "bg-green-400" },
{ verdict: "Uncertain", color: "bg-amber-400" },
{ verdict: "Disputed", color: "bg-orange-400" },
{ verdict: "False", color: "bg-red-500" },
].map((v) => (
<span key={v.verdict} className="flex items-center gap-1 text-[9px] text-gray-500">
<span className={`w-2 h-2 rounded-full ${v.color}`} />
{v.verdict}
</span>
))}
</div>
)}
{/* Article content with highlights */}
<article className="text-gray-300 text-[15px] font-sans">
{content ? (
highlightClaims(content, claims)
) : (
<p className="text-muted text-center py-12">
No content available for reader mode.
<br />
<span className="text-[10px] text-gray-700">
Cross-origin pages may not expose their content.
</span>
</p>
)}
</article>
</div>
</div>
);
}

View File

@@ -2,9 +2,11 @@ interface StatusBarProps {
url?: string; url?: string;
isSecure: boolean; isSecure: boolean;
verificationScore?: number; verificationScore?: number;
readerMode?: boolean;
isBookmarked?: boolean;
} }
export function StatusBar({ url, isSecure, verificationScore }: StatusBarProps) { export function StatusBar({ url, isSecure, verificationScore, readerMode, isBookmarked }: StatusBarProps) {
const scoreLabel = const scoreLabel =
verificationScore === undefined verificationScore === undefined
? "" ? ""
@@ -30,6 +32,22 @@ export function StatusBar({ url, isSecure, verificationScore }: StatusBarProps)
Secure Secure
</span> </span>
)} )}
{readerMode && (
<span className="text-electricblue flex items-center gap-1">
<svg className="w-2.5 h-2.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Reader
</span>
)}
{isBookmarked && (
<span className="text-amber flex items-center gap-1">
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
<path d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z" />
</svg>
Bookmarked
</span>
)}
<span className="text-muted truncate max-w-md"> <span className="text-muted truncate max-w-md">
{url?.startsWith("blackroad://") ? "" : url} {url?.startsWith("blackroad://") ? "" : url}
</span> </span>

View File

@@ -0,0 +1,79 @@
import { useState, useCallback } from "react";
export interface Bookmark {
id: string;
url: string;
title: string;
accuracy_score?: number;
source_category?: string;
created_at: number;
}
// Phase 1: localStorage. Phase 2: Tauri SQLite via IPC.
const STORAGE_KEY = "blackroad-bookmarks";
function loadBookmarks(): Bookmark[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveBookmarks(bookmarks: Bookmark[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(bookmarks));
}
export function useBookmarks() {
const [bookmarks, setBookmarks] = useState<Bookmark[]>(loadBookmarks);
const addBookmark = useCallback(
(url: string, title: string, accuracy_score?: number, source_category?: string) => {
setBookmarks((prev) => {
// Don't duplicate
if (prev.some((b) => b.url === url)) return prev;
const next = [
...prev,
{
id: `bm-${Date.now()}`,
url,
title,
accuracy_score,
source_category,
created_at: Date.now(),
},
];
saveBookmarks(next);
return next;
});
},
[],
);
const removeBookmark = useCallback((url: string) => {
setBookmarks((prev) => {
const next = prev.filter((b) => b.url !== url);
saveBookmarks(next);
return next;
});
}, []);
const isBookmarked = useCallback(
(url: string) => bookmarks.some((b) => b.url === url),
[bookmarks],
);
const toggleBookmark = useCallback(
(url: string, title: string, accuracy_score?: number, source_category?: string) => {
if (isBookmarked(url)) {
removeBookmark(url);
} else {
addBookmark(url, title, accuracy_score, source_category);
}
},
[isBookmarked, addBookmark, removeBookmark],
);
return { bookmarks, addBookmark, removeBookmark, isBookmarked, toggleBookmark };
}

View File

@@ -0,0 +1,71 @@
import { useState, useCallback, useRef } from "react";
export interface HistoryEntry {
url: string;
title: string;
accuracy_score?: number;
source_category?: string;
visited_at: number;
}
const STORAGE_KEY = "blackroad-history";
const MAX_ENTRIES = 1000;
function loadHistory(): HistoryEntry[] {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
function saveHistory(entries: HistoryEntry[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(entries.slice(0, MAX_ENTRIES)));
}
export function useHistory() {
const [history, setHistory] = useState<HistoryEntry[]>(loadHistory);
const lastUrl = useRef<string>("");
const addEntry = useCallback(
(url: string, title: string, accuracy_score?: number, source_category?: string) => {
if (!url || url.startsWith("blackroad://")) return;
if (url === lastUrl.current) return;
lastUrl.current = url;
setHistory((prev) => {
const entry: HistoryEntry = {
url,
title,
accuracy_score,
source_category,
visited_at: Date.now(),
};
const next = [entry, ...prev.filter((e) => e.url !== url)].slice(0, MAX_ENTRIES);
saveHistory(next);
return next;
});
},
[],
);
const clearHistory = useCallback(() => {
setHistory([]);
saveHistory([]);
lastUrl.current = "";
}, []);
const search = useCallback(
(query: string) => {
if (!query.trim()) return history;
const q = query.toLowerCase();
return history.filter(
(e) => e.url.toLowerCase().includes(q) || e.title.toLowerCase().includes(q),
);
},
[history],
);
return { history, addEntry, clearHistory, search };
}

View File

@@ -0,0 +1,98 @@
import { useEffect, useCallback } from "react";
interface ShortcutActions {
newTab: () => void;
closeTab: () => void;
focusAddressBar: () => void;
toggleCommandPalette: () => void;
toggleSidebar: () => void;
toggleReaderMode: () => void;
goBack: () => void;
goForward: () => void;
refresh: () => void;
switchToTab: (index: number) => void;
toggleBookmark: () => void;
openHistory: () => void;
openBookmarks: () => void;
}
export function useKeyboardShortcuts(actions: ShortcutActions) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
const meta = e.metaKey || e.ctrlKey;
if (!meta) return;
switch (e.key) {
case "t":
e.preventDefault();
actions.newTab();
break;
case "w":
e.preventDefault();
actions.closeTab();
break;
case "l":
e.preventDefault();
actions.focusAddressBar();
break;
case "k":
e.preventDefault();
actions.toggleCommandPalette();
break;
case "r":
e.preventDefault();
if (e.shiftKey) {
actions.toggleReaderMode();
} else {
actions.refresh();
}
break;
case "d":
e.preventDefault();
actions.toggleBookmark();
break;
case "y":
e.preventDefault();
actions.openHistory();
break;
case "[":
e.preventDefault();
actions.goBack();
break;
case "]":
e.preventDefault();
actions.goForward();
break;
case "b":
if (e.shiftKey) {
e.preventDefault();
actions.openBookmarks();
}
break;
case "e":
e.preventDefault();
actions.toggleSidebar();
break;
case "1":
case "2":
case "3":
case "4":
case "5":
case "6":
case "7":
case "8":
case "9":
e.preventDefault();
actions.switchToTab(parseInt(e.key) - 1);
break;
}
},
[actions],
);
useEffect(() => {
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [handleKeyDown]);
}