Imports the visual direction from a claude.ai/design session ("PulseNode
Dashboard.dc.html") and reconciles it with the app's existing choices
rather than a wholesale swap: kept the IBM Plex Sans/Mono + Space
Grotesk typography and the pulse-green accent concept from the earlier
pass, adopted the imported design's structural ideas - a real
tonal/elevation token system (derived divider/muted colors via
color-mix instead of hardcoded per-theme duplicates, actual box-shadow
elevation), a 12-column bento grid with per-widget-type column spans
instead of uniform equal-width cards, fading-edge section dividers,
and a shared WidgetCard/MetricBar/StatusTag vocabulary so every widget
type stops repeating its own card markup.
Icons come from @phosphor-icons/react's /ssr entrypoint (bundled at
build time) rather than the imported design's unpkg.com CDN script -
that script is fine for the standalone design-tool preview, but a
runtime third-party call would break the self-hosted-only principle
already established (fonts self-hosted via next/font, no external
requests). New app/icon.svg reuses the same nav badge mark as the
favicon, replacing the never-touched create-next-app default.
Two new pieces of real functionality prompted by the design's mockup
toast/live-indicator, not just decoration: a "live" WebSocket
connection-status indicator (lib/ws/client.ts now tracks and exposes
real connection state), and a toast that fires on actual config:update
and config:error events - the latter finally surfaces config validation
failures in the browser, previously visible only in server logs despite
being designed for exactly this back in M1.
69 lines
2.4 KiB
TypeScript
69 lines
2.4 KiB
TypeScript
"use client";
|
|
|
|
import { useState, type FormEvent } from "react";
|
|
import type { SearchEngine, Widget } from "@/lib/config/schema";
|
|
import { WidgetCard } from "@/components/widgets/WidgetCard";
|
|
|
|
type SearchWidget = Extract<Widget, { type: "search" }>;
|
|
|
|
const ENGINE_URLS: Record<string, string> = {
|
|
duckduckgo: "https://duckduckgo.com/?q=",
|
|
google: "https://www.google.com/search?q=",
|
|
bing: "https://www.bing.com/search?q=",
|
|
};
|
|
|
|
const ENGINE_LABEL: Record<string, string> = {
|
|
duckduckgo: "DuckDuckGo",
|
|
google: "Google",
|
|
bing: "Bing",
|
|
};
|
|
|
|
export function SearchWidget({ widget }: { widget: SearchWidget }) {
|
|
const [engine, setEngine] = useState(widget.defaultEngine ?? widget.engines[0]);
|
|
const [query, setQuery] = useState("");
|
|
|
|
function handleSubmit(event: FormEvent) {
|
|
event.preventDefault();
|
|
const trimmed = query.trim();
|
|
if (!trimmed) return;
|
|
window.open(`${ENGINE_URLS[engine]}${encodeURIComponent(trimmed)}`, "_blank", "noreferrer");
|
|
}
|
|
|
|
return (
|
|
<WidgetCard span={4}>
|
|
<form onSubmit={handleSubmit} className="flex flex-1 flex-col justify-center gap-2">
|
|
<div className="text-[10px] tracking-[0.1em] text-accent uppercase">Search</div>
|
|
<input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Search the web…"
|
|
autoComplete="off"
|
|
className="min-h-9 w-full rounded-md border border-border bg-bg px-2.5 py-1.5 text-sm text-fg outline-none placeholder:text-fg-muted focus-visible:border-accent"
|
|
/>
|
|
{widget.engines.length > 1 && (
|
|
<div className="inline-flex self-start overflow-hidden rounded-md border border-border">
|
|
{widget.engines.map((option) => (
|
|
<label
|
|
key={option}
|
|
className={`cursor-pointer px-2.5 py-1 text-[12px] first:border-l-0 [&+label]:border-l [&+label]:border-border ${
|
|
option === engine ? "text-accent shadow-[inset_0_0_0_1px_var(--pn-accent)]" : "text-fg-muted"
|
|
}`}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name="engine"
|
|
value={option}
|
|
checked={option === engine}
|
|
onChange={() => setEngine(option as SearchEngine)}
|
|
className="sr-only"
|
|
/>
|
|
{ENGINE_LABEL[option] ?? option}
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
</form>
|
|
</WidgetCard>
|
|
);
|
|
}
|