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:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
73
apps/browser/src/components/browser/BookmarksPage.tsx
Normal file
73
apps/browser/src/components/browser/BookmarksPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
174
apps/browser/src/components/browser/CommandPalette.tsx
Normal file
174
apps/browser/src/components/browser/CommandPalette.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
apps/browser/src/components/browser/HistoryPage.tsx
Normal file
113
apps/browser/src/components/browser/HistoryPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
apps/browser/src/components/browser/PrivacyDashboard.tsx
Normal file
191
apps/browser/src/components/browser/PrivacyDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
apps/browser/src/components/browser/ReaderMode.tsx
Normal file
148
apps/browser/src/components/browser/ReaderMode.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
79
apps/browser/src/hooks/useBookmarks.ts
Normal file
79
apps/browser/src/hooks/useBookmarks.ts
Normal 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 };
|
||||||
|
}
|
||||||
71
apps/browser/src/hooks/useHistory.ts
Normal file
71
apps/browser/src/hooks/useHistory.ts
Normal 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 };
|
||||||
|
}
|
||||||
98
apps/browser/src/hooks/useKeyboardShortcuts.ts
Normal file
98
apps/browser/src/hooks/useKeyboardShortcuts.ts
Normal 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]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user