Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions web/ui/src/shared/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
*.log
.DS_Store
148 changes: 148 additions & 0 deletions web/ui/src/shared/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { createContext, useCallback, useContext, useRef, useState } from "react";
import type { ReactNode } from "react";
import Modal from "./Modal";

interface ConfirmOptions {
title: string;
message: string;
confirmLabel?: string;
/** Color the confirm button red. Defaults to true. */
danger?: boolean;
}

type ConfirmFn = (opts: ConfirmOptions) => Promise<boolean>;

const ConfirmContext = createContext<ConfirmFn | null>(null);

/**
* useConfirm returns a function that opens the shared confirm modal and
* resolves to true/false when the user clicks a button or dismisses. Use
* this instead of `window.confirm()` so every service gets the same
* themed dialog instead of the browser's native one.
*
* Must be called inside a <ConfirmProvider> (usually wrapped at the App
* root). Throws if no provider is mounted — this is intentional: a bare
* call in a test or untouched service is a louder failure than a silent
* fallback to window.confirm.
*
* Example:
*
* const confirm = useConfirm();
* if (await confirm({
* title: "Delete torrent",
* message: "This cannot be undone.",
* confirmLabel: "Delete",
* })) {
* deleteTorrent.mutate(...);
* }
*/
export function useConfirm(): ConfirmFn {
const fn = useContext(ConfirmContext);
if (!fn) throw new Error("useConfirm must be used within ConfirmProvider");
return fn;
}

/**
* ConfirmProvider wraps the React tree and provides the useConfirm() hook.
* Mount once at the App root, above any component that uses useConfirm.
* Rendering the provider also renders the modal itself when open, so there
* is no separate <ConfirmModal /> to place.
*/
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [opts, setOpts] = useState<ConfirmOptions | null>(null);
const resolveRef = useRef<((value: boolean) => void) | null>(null);

const confirm = useCallback<ConfirmFn>(
(options) =>
new Promise<boolean>((resolve) => {
resolveRef.current = resolve;
setOpts(options);
}),
[],
);

function close(result: boolean) {
resolveRef.current?.(result);
resolveRef.current = null;
setOpts(null);
}

const danger = opts?.danger ?? true;
const confirmLabel =
opts?.confirmLabel ?? (danger ? "Delete" : "Confirm");

return (
<ConfirmContext.Provider value={confirm}>
{children}
{opts && (
<Modal onClose={() => close(false)} width={400}>
<div style={{ padding: 24 }}>
<h3
style={{
margin: 0,
fontSize: 15,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
{opts.title}
</h3>
<p
style={{
margin: "10px 0 0",
fontSize: 13,
lineHeight: 1.5,
color: "var(--color-text-secondary)",
}}
>
{opts.message}
</p>
</div>

<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
padding: "12px 24px",
borderTop: "1px solid var(--color-border-subtle)",
}}
>
<button
onClick={() => close(false)}
style={{
background: "var(--color-bg-elevated)",
border: "1px solid var(--color-border-default)",
borderRadius: 6,
padding: "6px 14px",
fontSize: 13,
color: "var(--color-text-secondary)",
cursor: "pointer",
}}
>
Cancel
</button>
<button
autoFocus
onClick={() => close(true)}
style={{
background: danger
? "var(--color-danger)"
: "var(--color-accent)",
border: "none",
borderRadius: 6,
padding: "6px 14px",
fontSize: 13,
color: "#fff",
fontWeight: 600,
cursor: "pointer",
}}
>
{confirmLabel}
</button>
</div>
</Modal>
)}
</ConfirmContext.Provider>
);
}
89 changes: 89 additions & 0 deletions web/ui/src/shared/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Component } from "react";
import type { ReactNode, ErrorInfo } from "react";

interface Props {
children: ReactNode;
fallback?: ReactNode;
resetKey?: string;
}

interface State {
hasError: boolean;
error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

componentDidUpdate(prevProps: Props) {
if (this.props.resetKey !== prevProps.resetKey && this.state.hasError) {
this.setState({ hasError: false, error: null });
}
}

componentDidCatch(error: Error, info: ErrorInfo) {
console.error("[ErrorBoundary]", error, info.componentStack);
}

render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;

return (
<div
style={{
padding: 24,
display: "flex",
flexDirection: "column",
gap: 12,
}}
>
<h2
style={{
margin: 0,
fontSize: 16,
fontWeight: 600,
color: "var(--color-danger)",
}}
>
Failed to render
</h2>
<p
style={{
margin: 0,
fontSize: 13,
color: "var(--color-text-secondary)",
fontFamily: "var(--font-family-mono)",
}}
>
{this.state.error?.message ?? "Unknown error"}
</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
style={{
alignSelf: "flex-start",
background: "var(--color-bg-elevated)",
border: "1px solid var(--color-border-default)",
borderRadius: 5,
padding: "5px 14px",
fontSize: 13,
color: "var(--color-text-secondary)",
cursor: "pointer",
}}
>
Try again
</button>
</div>
);
}

return this.props.children;
}
}
75 changes: 75 additions & 0 deletions web/ui/src/shared/Modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useEffect } from "react";
import type { ReactNode, CSSProperties } from "react";

interface ModalProps {
onClose: () => void;
children: ReactNode;
/** Width of the inner container. Default: 520. */
width?: number | string;
maxWidth?: string;
maxHeight?: string;
/** Extra styles merged onto the inner container. */
innerStyle?: CSSProperties;
}

/**
* Generic modal shell — backdrop overlay, centered content, Escape-to-close,
* click-outside-to-close. All modals across every Beacon service frontend
* should use this so behaviour stays consistent and escape/click-away logic
* isn't duplicated per service.
*
* Colors come from CSS custom properties (`--color-bg-surface`,
* `--color-border-subtle`, `--shadow-modal`). Each service defines its own
* theme; this component doesn't know or care which.
*/
export default function Modal({
onClose,
children,
width = 520,
maxWidth = "calc(100vw - 48px)",
maxHeight = "calc(100vh - 80px)",
innerStyle,
}: ModalProps) {
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);

return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.6)",
backdropFilter: "blur(2px)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 200,
}}
onClick={onClose}
>
<div
style={{
background: "var(--color-bg-surface)",
border: "1px solid var(--color-border-subtle)",
borderRadius: 12,
width,
maxWidth,
maxHeight,
boxShadow: "var(--shadow-modal)",
display: "flex",
flexDirection: "column",
overflow: "hidden",
...innerStyle,
}}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>
</div>
);
}
67 changes: 67 additions & 0 deletions web/ui/src/shared/PageHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { ReactNode } from "react";

// Inline "external link" icon so this file has no dependency on lucide-react.
// The shared package keeps its dep surface to React only — each consuming
// service already has its own copy of lucide, but we don't want web-shared
// to reach into sibling node_modules for it.
function ExternalLinkIcon() {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ verticalAlign: "-1px" }}
>
<path d="M15 3h6v6" />
<path d="M10 14 21 3" />
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6" />
</svg>
);
}

interface PageHeaderProps {
title: string;
description: string;
docsUrl?: string;
action?: ReactNode;
}

export default function PageHeader({ title, description, docsUrl, action }: PageHeaderProps) {
return (
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 24 }}>
<div>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 600, color: "var(--color-text-primary)", letterSpacing: "-0.01em" }}>
{title}
</h1>
<p style={{ margin: "4px 0 0", fontSize: 13, color: "var(--color-text-secondary)" }}>
{description}
{docsUrl && (
<>
{" "}
<a
href={docsUrl}
target="_blank"
rel="noopener noreferrer"
style={{
color: "var(--color-accent)",
textDecoration: "none",
fontSize: 13,
whiteSpace: "nowrap",
}}
>
Learn more <ExternalLinkIcon />
</a>
</>
)}
</p>
</div>
{action}
</div>
);
}
Loading
Loading