HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
19.6 kB
/**
* Shared presentation primitives for the Interactive tab.
*
* Why one file for the lot: the Interactive surface re-uses the
* same 6 primitives dozens of times (toast, button, panel, empty
* state, skeleton, status badge). Keeping them co-located keeps
* the import surface narrow and the visual language tight:
*
* bg tokens: #0f0f0f / #1a1a1a / #1f1f1f / #121212
* borders: #3f3f3f (default), #555 (hover)
* accent: #3ea6ff β†’ selected/ring/primary-CTA color
* text tokens: #f1f1f1 (primary), #aaa (secondary), #777 (tertiary)
*
* Accessibility:
* - `PrimaryButton` + `SecondaryButton` always render a <button>,
* never a div; visible focus ring is driven by the default
* Tailwind `focus-visible` utility.
* - Toasts use `role="status"` with `aria-live="polite"` so
* screen readers announce them without stealing focus.
* - All interactive cards expose `aria-label` and render as
* <button type="button"> so keyboard users get Enter/Space.
*/
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { AlertTriangle, CheckCircle2, Info, Loader2, XCircle, X } from "lucide-react";
import type { ExperienceStatus } from "./types";
import { InteractiveApiError } from "./types";
// ────────────────────────────────────────────────────────────────
// Buttons
// ────────────────────────────────────────────────────────────────
type ButtonSize = "sm" | "md" | "lg";
type BaseButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
size?: ButtonSize;
loading?: boolean;
icon?: React.ReactNode;
};
const SIZE_CLASSES: Record<ButtonSize, string> = {
sm: "px-2.5 py-1.5 text-xs",
md: "px-3.5 py-2 text-sm",
lg: "px-4 py-2.5 text-base",
};
export function PrimaryButton({
size = "md", loading, icon, children, className, disabled, ...rest
}: BaseButtonProps) {
return (
<button
type="button"
disabled={disabled || loading}
className={[
"inline-flex items-center gap-2 rounded-md font-medium",
"bg-[#3ea6ff] text-black",
"hover:bg-[#62b6ff] active:bg-[#2f8fe3]",
"disabled:opacity-50 disabled:cursor-not-allowed",
"transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0f0f0f]",
SIZE_CLASSES[size],
className || "",
].join(" ")}
{...rest}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden /> : icon}
{children}
</button>
);
}
export function SecondaryButton({
size = "md", loading, icon, children, className, disabled, ...rest
}: BaseButtonProps) {
return (
<button
type="button"
disabled={disabled || loading}
className={[
"inline-flex items-center gap-2 rounded-md font-medium",
"bg-[#1f1f1f] text-[#f1f1f1] border border-[#3f3f3f]",
"hover:bg-[#282828] hover:border-[#555]",
"disabled:opacity-50 disabled:cursor-not-allowed",
"transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0f0f0f]",
SIZE_CLASSES[size],
className || "",
].join(" ")}
{...rest}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden /> : icon}
{children}
</button>
);
}
export function DangerButton({
size = "md", loading, icon, children, className, disabled, ...rest
}: BaseButtonProps) {
return (
<button
type="button"
disabled={disabled || loading}
className={[
"inline-flex items-center gap-2 rounded-md font-medium",
"bg-transparent text-red-400 border border-red-500/40",
"hover:bg-red-500/10 hover:border-red-500/70",
"disabled:opacity-50 disabled:cursor-not-allowed",
"transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0f0f0f]",
SIZE_CLASSES[size],
className || "",
].join(" ")}
{...rest}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" aria-hidden /> : icon}
{children}
</button>
);
}
// ────────────────────────────────────────────────────────────────
// Panel β€” titled section wrapper used by editor tabs
// ────────────────────────────────────────────────────────────────
export function Panel({
title, subtitle, actions, children, className,
}: {
title?: React.ReactNode;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<section
className={[
"bg-[#1a1a1a] border border-[#3f3f3f] rounded-lg",
className || "",
].join(" ")}
>
{(title || actions) && (
<header className="flex items-start justify-between gap-3 px-5 py-4 border-b border-[#3f3f3f]">
<div>
{title && <h2 className="text-sm font-semibold text-[#f1f1f1]">{title}</h2>}
{subtitle && <p className="mt-1 text-xs text-[#aaa]">{subtitle}</p>}
</div>
{actions && <div className="flex items-center gap-2 shrink-0">{actions}</div>}
</header>
)}
<div className="p-5">{children}</div>
</section>
);
}
// ────────────────────────────────────────────────────────────────
// EmptyState
// ────────────────────────────────────────────────────────────────
export function EmptyState({
icon, title, description, action,
}: {
icon: React.ReactNode;
title: string;
description?: string;
action?: React.ReactNode;
}) {
return (
<div className="border border-dashed border-[#3f3f3f] rounded-lg p-10 text-center bg-[#151515]">
<div className="w-12 h-12 mx-auto text-[#3ea6ff] mb-3" aria-hidden>
{icon}
</div>
<div className="text-base font-medium text-[#f1f1f1]">{title}</div>
{description && (
<p className="text-sm text-[#aaa] mt-1 max-w-md mx-auto">{description}</p>
)}
{action && <div className="mt-5 flex justify-center">{action}</div>}
</div>
);
}
// ────────────────────────────────────────────────────────────────
// Skeleton loaders (grid card + list row)
// ────────────────────────────────────────────────────────────────
export function SkeletonCard() {
return (
<div
className="bg-[#1f1f1f] border border-[#3f3f3f] rounded-lg p-5 animate-pulse"
role="status" aria-label="Loading project"
>
<div className="h-4 bg-[#2a2a2a] rounded w-3/4 mb-3" />
<div className="h-3 bg-[#2a2a2a] rounded w-full mb-2" />
<div className="h-3 bg-[#2a2a2a] rounded w-5/6 mb-4" />
<div className="flex gap-2">
<div className="h-3 bg-[#2a2a2a] rounded w-16" />
<div className="h-3 bg-[#2a2a2a] rounded w-20" />
</div>
</div>
);
}
export function SkeletonRow() {
return (
<div
className="bg-[#1f1f1f] border border-[#3f3f3f] rounded p-3 animate-pulse flex gap-3 items-center"
role="status" aria-label="Loading row"
>
<div className="h-4 bg-[#2a2a2a] rounded w-24" />
<div className="h-4 bg-[#2a2a2a] rounded flex-1" />
<div className="h-4 bg-[#2a2a2a] rounded w-12" />
</div>
);
}
// ────────────────────────────────────────────────────────────────
// ErrorBanner β€” inline retriable error
// ────────────────────────────────────────────────────────────────
export function ErrorBanner({
title = "Something went wrong", message, onRetry,
}: {
title?: string;
message: string;
onRetry?: () => void;
}) {
return (
<div
role="alert"
className="border border-red-500/40 bg-red-500/5 rounded-md p-4 flex gap-3 items-start"
>
<AlertTriangle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" aria-hidden />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-red-400">{title}</div>
<p className="text-xs text-red-300/80 mt-1 break-words">{message}</p>
{onRetry && (
<button
type="button"
onClick={onRetry}
className="mt-3 text-xs font-medium text-[#3ea6ff] hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] rounded"
>
Try again
</button>
)}
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────
// StatusBadge
// ────────────────────────────────────────────────────────────────
const STATUS_STYLES: Record<ExperienceStatus, string> = {
draft: "text-yellow-300 bg-yellow-400/10 border-yellow-400/30",
in_review: "text-blue-300 bg-blue-400/10 border-blue-400/30",
approved: "text-emerald-300 bg-emerald-400/10 border-emerald-400/30",
archived: "text-[#aaa] bg-white/5 border-white/10",
published: "text-green-300 bg-green-400/10 border-green-400/30",
};
const STATUS_LABEL: Record<ExperienceStatus, string> = {
draft: "Draft",
in_review: "In review",
approved: "Approved",
archived: "Archived",
published: "Published",
};
export function StatusBadge({ status }: { status: ExperienceStatus }) {
return (
<span
className={[
"text-[10px] uppercase tracking-wide px-2 py-0.5 rounded border",
STATUS_STYLES[status] || STATUS_STYLES.draft,
].join(" ")}
>
{STATUS_LABEL[status] || status}
</span>
);
}
// ────────────────────────────────────────────────────────────────
// Toast system β€” accessible, auto-dismissing
// ────────────────────────────────────────────────────────────────
type ToastVariant = "success" | "error" | "info" | "warning";
interface ToastItem {
id: number;
variant: ToastVariant;
title: string;
message?: string;
timeoutMs: number;
}
interface ToastContextValue {
toast(t: { variant?: ToastVariant; title: string; message?: string; timeoutMs?: number }): void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
// Graceful no-op when the provider isn't mounted, so individual
// panels stay testable outside <ToastProvider>.
return {
toast({ title }) {
// eslint-disable-next-line no-console
console.info(`[toast] ${title}`);
},
};
}
return ctx;
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [items, setItems] = useState<ToastItem[]>([]);
const remove = useCallback((id: number) => {
setItems((xs) => xs.filter((x) => x.id !== id));
}, []);
const toast = useCallback<ToastContextValue["toast"]>((t) => {
const id = Date.now() + Math.random();
const next: ToastItem = {
id,
variant: t.variant ?? "info",
title: t.title,
message: t.message,
timeoutMs: t.timeoutMs ?? (t.variant === "error" ? 6000 : 3500),
};
setItems((xs) => [...xs, next]);
window.setTimeout(() => remove(id), next.timeoutMs);
}, [remove]);
const ctx = useMemo<ToastContextValue>(() => ({ toast }), [toast]);
return (
<ToastContext.Provider value={ctx}>
{children}
<div
aria-live="polite"
aria-atomic="false"
className="fixed bottom-4 right-4 z-[60] flex flex-col gap-2 pointer-events-none"
>
{items.map((item) => (
<ToastCard key={item.id} item={item} onClose={() => remove(item.id)} />
))}
</div>
</ToastContext.Provider>
);
}
function ToastCard({ item, onClose }: { item: ToastItem; onClose: () => void }) {
const palette: Record<ToastVariant, { border: string; bg: string; icon: React.ReactNode }> = {
success: {
border: "border-emerald-500/40", bg: "bg-emerald-500/10",
icon: <CheckCircle2 className="w-4 h-4 text-emerald-400" />,
},
error: {
border: "border-red-500/40", bg: "bg-red-500/10",
icon: <XCircle className="w-4 h-4 text-red-400" />,
},
warning: {
border: "border-amber-500/40", bg: "bg-amber-500/10",
icon: <AlertTriangle className="w-4 h-4 text-amber-400" />,
},
info: {
border: "border-[#3ea6ff]/40", bg: "bg-[#3ea6ff]/10",
icon: <Info className="w-4 h-4 text-[#3ea6ff]" />,
},
};
const p = palette[item.variant];
return (
<div
role="status"
className={[
"pointer-events-auto min-w-[260px] max-w-sm rounded-md backdrop-blur-sm",
"border px-3 py-2.5 flex items-start gap-3 shadow-lg",
p.border, p.bg,
].join(" ")}
>
<div className="mt-0.5">{p.icon}</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-[#f1f1f1]">{item.title}</div>
{item.message && (
<div className="text-xs text-[#cfd8dc] mt-0.5 break-words">{item.message}</div>
)}
</div>
<button
type="button"
onClick={onClose}
aria-label="Dismiss notification"
className="text-[#aaa] hover:text-[#f1f1f1] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] rounded"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
// ────────────────────────────────────────────────────────────────
// useAsyncResource β€” data loader with loading/error/data states
// ────────────────────────────────────────────────────────────────
export interface AsyncResource<T> {
data: T | null;
loading: boolean;
error: string | null;
/** HTTP status (or null) so callers can branch on 401 / 404 /
* 5xx without parsing free-form error messages. */
errorStatus: number | null;
/** Machine-readable code from the backend's uniform error shape
* (e.g. 'not_authenticated', 'not_found'). Empty string on
* non-API errors. */
errorCode: string;
reload: () => void;
setData: React.Dispatch<React.SetStateAction<T | null>>;
}
export function useAsyncResource<T>(
load: (signal: AbortSignal) => Promise<T>,
deps: React.DependencyList,
): AsyncResource<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [errorStatus, setErrorStatus] = useState<number | null>(null);
const [errorCode, setErrorCode] = useState<string>("");
const [token, setToken] = useState(0);
useEffect(() => {
const ctrl = new AbortController();
let cancelled = false;
setLoading(true);
setError(null);
setErrorStatus(null);
setErrorCode("");
load(ctrl.signal)
.then((result) => {
if (!cancelled) setData(result);
})
.catch((err: Error) => {
if (!cancelled && err.name !== "AbortError") {
setError(err.message || "Unexpected error");
if (err instanceof InteractiveApiError) {
setErrorStatus(err.status);
setErrorCode(err.code);
}
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
ctrl.abort();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, token]);
const reload = useCallback(() => setToken((t) => t + 1), []);
return { data, loading, error, errorStatus, errorCode, reload, setData };
}
// ────────────────────────────────────────────────────────────────
// Modal β€” accessible dialog with backdrop + ESC to close
// ────────────────────────────────────────────────────────────────
export function Modal({
open, onClose, title, children, footer, widthClass = "max-w-lg",
}: {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
footer?: React.ReactNode;
widthClass?: string;
}) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-4"
role="dialog" aria-modal="true" aria-label={title}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
className={[
"w-full bg-[#1a1a1a] border border-[#3f3f3f] rounded-lg shadow-2xl",
widthClass,
].join(" ")}
>
<header className="flex items-center justify-between px-5 py-3 border-b border-[#3f3f3f]">
<h2 className="text-sm font-semibold text-[#f1f1f1]">{title}</h2>
<button
type="button"
onClick={onClose}
aria-label="Close dialog"
className="text-[#aaa] hover:text-[#f1f1f1] focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] rounded p-1"
>
<X className="w-4 h-4" />
</button>
</header>
<div className="px-5 py-4 max-h-[70vh] overflow-y-auto">{children}</div>
{footer && (
<footer className="flex items-center justify-end gap-2 px-5 py-3 border-t border-[#3f3f3f]">
{footer}
</footer>
)}
</div>
</div>
);
}