Ctrl K
ComunidadGuías…

admin-web/src/App.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import {
  type ChangeEvent,
  type ReactNode,
  useCallback,
  useEffect,
  useMemo,
  useState,
} from "react";
import {
  type AuthMode,
  ApiFailure,
  mutate,
  probeAuthMode,
  request,
  requestObjectUrl,
} from "./api";
import type {
  FeedbackDetail,
  FeedbackStatus,
  FeedbackSummary,
  Report,
  ReportDetail as ReportDetailData,
} from "./types";
import { useResource } from "./useResource";

function usePath() {
  const [path, setPath] = useState(location.pathname);
  useEffect(() => {
    const update = () => setPath(location.pathname);
    addEventListener("popstate", update);
    return () => removeEventListener("popstate", update);
  }, []);
  const navigate = useCallback((url: string) => {
    history.pushState(null, "", url);
    dispatchEvent(new PopStateEvent("popstate"));
  }, []);
  return { path, navigate };
}

function Link({
  href,
  className,
  activeWhenNested = false,
  children,
}: {
  href: string;
  className?: string;
  activeWhenNested?: boolean;
  children: ReactNode;
}) {
  const { path, navigate } = usePath();
  const active =
    path === href || (activeWhenNested && path.startsWith(`${href}/`));
  return (
    <a
      href={href}
      className={className}
      aria-current={active ? "page" : undefined}
      onClick={(event) => {
        if (!event.metaKey && !event.ctrlKey) {
          event.preventDefault();
          navigate(href);
        }
      }}
    >
      {children}
    </a>
  );
}

function StateView<T>({
  resource,
  children,
}: {
  resource: ReturnType<typeof useResource<T>>;
  children: (data: T) => ReactNode;
}) {
  if (resource.loading && !resource.data)
    return <div className="state">Loading…</div>;
  if (resource.error && !resource.data) {
    const forbidden =
      resource.error instanceof ApiFailure && resource.error.status === 403;
    return (
      <div className="state error" role="alert">
        <h2>{forbidden ? "Access denied" : "Could not load data"}</h2>
        <p>{resource.error.message}</p>
        <button type="button" onClick={resource.refetch}>
          Retry
        </button>
      </div>
    );
  }
  return resource.data ? children(resource.data) : null;
}

function Reports({ authMode }: { authMode: AuthMode }) {
  const resource = useResource(
    () => request<Report[]>("/reports?status=open&limit=100", authMode),
    "reports",
  );
  return (
    <Page
      eyebrow="Moderation"
      title="Open reports"
      description="Review reports across every Buzz community."
    >
      <StateView resource={resource}>
        {(reports) =>
          reports.length ? (
            <div className="cards">
              {reports.map((report) => (
                <Link
                  key={report.id}
                  href={`/reports/${report.id}`}
                  className="card-link"
                >
                  <article className="record-card">
                    <span className="record-icon report-icon">
                      <ReportIcon />
                    </span>
                    <div className="record-primary">
                      <span className="tag">{report.reportType}</span>
                      <strong>{report.communityHost}</strong>
                      <code>
                        {report.targetKind}: {short(report.target)}
                      </code>
                    </div>
                    <div className="record-date">
                      <span>Submitted</span>
                      <time>{date(report.createdAt)}</time>
                    </div>
                    <ArrowIcon />
                  </article>
                </Link>
              ))}
            </div>
          ) : (
            <Empty />
          )
        }
      </StateView>
    </Page>
  );
}

function ReportDetail({ id, authMode }: { id: string; authMode: AuthMode }) {
  const resource = useResource(
    () => request<ReportDetailData>(`/reports/${id}`, authMode),
    id,
  );
  return (
    <Page
      eyebrow="Moderation"
      title="Report detail"
      description="The full report as submitted to the relay."
      back="/reports"
    >
      <StateView resource={resource}>
        {(report) => (
          <article className="detail">
            <div className="detail-heading">
              <span className="record-icon report-icon">
                <ReportIcon />
              </span>
              <div>
                <span className="tag">{report.reportType}</span>
                <h2>{report.communityHost}</h2>
              </div>
            </div>
            <dl>
              <dt>Status</dt>
              <dd>
                <span className="status">{report.status}</span>
              </dd>
              <dt>Reporter</dt>
              <dd>
                <code>{report.reporterPubkey}</code>
              </dd>
              <dt>Target</dt>
              <dd>
                <code>{report.target}</code>
              </dd>
              {report.targetKind === "event" ? (
                <>
                  <dt>Message</dt>
                  <dd>
                    {report.message ? (
                      <div className="reported-message">
                        {report.message.deletedAt ? (
                          <span className="status">Deleted</span>
                        ) : null}
                        <p>{report.message.content}</p>
                        <div className="reported-message-meta">
                          <span>Author</span>
                          <code>{report.message.authorPubkey}</code>
                          <span>Created</span>
                          <time>{date(report.message.createdAt)}</time>
                        </div>
                      </div>
                    ) : (
                      <p className="message-unavailable">
                        Message content is unavailable.
                      </p>
                    )}
                  </dd>
                </>
              ) : null}
              <dt>Note</dt>
              <dd className="sensitive">
                {report.note ?? "No note provided."}
              </dd>
            </dl>
          </article>
        )}
      </StateView>
    </Page>
  );
}

function FeedbackList({ authMode }: { authMode: AuthMode }) {
  const resource = useResource(
    () => request<FeedbackSummary[]>("/feedback", authMode),
    "feedback",
  );
  const [query, setQuery] = useState("");
  const [community, setCommunity] = useState("all");
  const [timeRange, setTimeRange] = useState("all");
  const [statusFilter, setStatusFilter] = useState("all");
  // Successful PATCH responses override the server-loaded status so the row
  // reflects the new value without a full refetch. Keyed by feedback id.
  const [overrides, setOverrides] = useState<Record<string, FeedbackStatus>>(
    {},
  );

  // Mutations require a named principal; disabled mode's server rejects them
  // (probe reports canAct: false), so the write control is hidden there rather
  // than offering an action that can only fail.
  const canWrite = authMode === "nip98";

  const applyStatus = useCallback((id: string, status: FeedbackStatus) => {
    setOverrides((current) => ({ ...current, [id]: status }));
  }, []);

  return (
    <Page
      eyebrow="Product"
      title="Feedback"
      description="Recent product feedback from across Buzz."
    >
      <StateView resource={resource}>
        {(items) => {
          if (!items.length) return <Empty />;
          return (
            <FeedbackResults
              items={items}
              query={query}
              community={community}
              timeRange={timeRange}
              statusFilter={statusFilter}
              overrides={overrides}
            >
              {({ communities, filtered }) => (
                <>
                  <div className="feedback-filters">
                    <label className="search-field">
                      <span>Search feedback</span>
                      <div>
                        <SearchIcon />
                        <input
                          type="search"
                          placeholder="Search feedback"
                          value={query}
                          onChange={(event) => setQuery(event.target.value)}
                        />
                      </div>
                    </label>
                    <label>
                      <span>Community</span>
                      <select
                        value={community}
                        onChange={(event) => setCommunity(event.target.value)}
                      >
                        <option value="all">All communities</option>
                        {communities.map((host) => (
                          <option key={host} value={host}>
                            {host}
                          </option>
                        ))}
                      </select>
                    </label>
                    <label>
                      <span>Received</span>
                      <select
                        value={timeRange}
                        onChange={(event) => setTimeRange(event.target.value)}
                      >
                        <option value="all">Any time</option>
                        <option value="day">Last 24 hours</option>
                        <option value="week">Last 7 days</option>
                        <option value="month">Last 30 days</option>
                      </select>
                    </label>
                    <label>
                      <span>Status</span>
                      <select
                        value={statusFilter}
                        onChange={(event) =>
                          setStatusFilter(event.target.value)
                        }
                      >
                        <option value="all">Any status</option>
                        <option value="new">New</option>
                        <option value="reviewed">Reviewed</option>
                        <option value="archived">Archived</option>
                      </select>
                    </label>
                  </div>
                  <p className="result-count" aria-live="polite">
                    {filtered.length} of {items.length} submissions
                  </p>
                  {filtered.length ? (
                    <div className="cards">
                      {filtered.map((item) => (
                        <article
                          key={item.id}
                          className="record-card feedback-card feedback-record"
                        >
                          <Link
                            href={`/feedback/${item.id}`}
                            className="feedback-main-link"
                          >
                            <span className="record-icon feedback-icon">
                              <CategoryIcon category={item.category} />
                            </span>
                            <div className="record-primary">
                              <CategoryTag category={item.category} />
                              <strong>{item.bodySummary}</strong>
                              <span className="record-provenance">
                                <Provenance host={item.communityHost} />
                                <code>{short(item.submitterPubkey)}</code>
                              </span>
                            </div>
                          </Link>
                          <FeedbackStatusControl
                            id={item.id}
                            status={overrides[item.id] ?? item.status}
                            authMode={authMode}
                            canWrite={canWrite}
                            onApplied={applyStatus}
                          />
                          <div className="record-date">
                            <span>Received</span>
                            <time>{date(item.receivedAt)}</time>
                          </div>
                          <Link
                            href={`/feedback/${item.id}`}
                            className="record-open-link"
                          >
                            <span className="visually-hidden">
                              Open feedback from {hostLabel(item.communityHost)}
                            </span>
                            <ArrowIcon />
                          </Link>
                        </article>
                      ))}
                    </div>
                  ) : (
                    <div className="state">No matching feedback.</div>
                  )}
                </>
              )}
            </FeedbackResults>
          );
        }}
      </StateView>
    </Page>
  );
}

const STATUS_LABELS: Record<FeedbackStatus, string> = {
  new: "New",
  reviewed: "Reviewed",
  archived: "Archived",
};

const STATUS_OPTIONS: FeedbackStatus[] = ["new", "reviewed", "archived"];

/// The per-row lifecycle control. In writable mode it PATCHes the relay and
/// adopts the status from the PATCH response (never optimistically — a failed
/// write leaves the prior status and surfaces an error). In read-only mode it
/// shows the authoritative status as a static badge, since disabled-mode
/// mutations are rejected server-side.
function FeedbackStatusControl({
  id,
  status,
  authMode,
  canWrite,
  onApplied,
}: {
  id: string;
  status: FeedbackStatus;
  authMode: AuthMode;
  canWrite: boolean;
  onApplied: (id: string, status: FeedbackStatus) => void;
}) {
  const [pending, setPending] = useState(false);
  const [error, setError] = useState(false);

  if (!canWrite) {
    return (
      <span className="feedback-status">
        <span className={`status status-${status}`}>
          {STATUS_LABELS[status]}
        </span>
      </span>
    );
  }

  const onChange = async (event: ChangeEvent<HTMLSelectElement>) => {
    const next = event.target.value as FeedbackStatus;
    setPending(true);
    setError(false);
    try {
      const updated = await mutate<{ status: FeedbackStatus }>(
        `/feedback/${encodeURIComponent(id)}`,
        "PATCH",
        { status: next },
        authMode,
      );
      onApplied(id, updated.status);
    } catch {
      setError(true);
    } finally {
      setPending(false);
    }
  };

  return (
    <label className="feedback-status">
      <span className="visually-hidden">Status</span>
      <select value={status} onChange={onChange} disabled={pending}>
        {STATUS_OPTIONS.map((option) => (
          <option key={option} value={option}>
            {STATUS_LABELS[option]}
          </option>
        ))}
      </select>
      {error ? (
        <span className="status-error" role="alert">
          Update failed
        </span>
      ) : null}
    </label>
  );
}

/// Feedback whose source community was purged carries no host. Render an
/// explicit provenance-unavailable marker rather than an empty slot.
function Provenance({ host }: { host: string | null }) {
  if (host) return host;
  return <span className="provenance-unavailable">Community unavailable</span>;
}

function hostLabel(host: string | null): string {
  return host ?? "an unavailable community";
}

function FeedbackResults({
  items,
  query,
  community,
  timeRange,
  statusFilter,
  overrides,
  children,
}: {
  items: FeedbackSummary[];
  query: string;
  community: string;
  timeRange: string;
  statusFilter: string;
  overrides: Record<string, FeedbackStatus>;
  children: (results: {
    communities: string[];
    filtered: FeedbackSummary[];
  }) => ReactNode;
}) {
  const results = useMemo(() => {
    const communities = [...new Set(items.map((item) => item.communityHost))]
      .filter((host): host is string => Boolean(host))
      .sort((left, right) => left.localeCompare(right));
    const normalizedQuery = query.trim().toLocaleLowerCase();
    const after = timeRangeStart(timeRange);
    const filtered = items.filter((item) => {
      const status = overrides[item.id] ?? item.status;
      if (community !== "all" && item.communityHost !== community) return false;
      if (statusFilter !== "all" && status !== statusFilter) return false;
      if (after !== undefined) {
        const receivedAt = new Date(item.receivedAt).valueOf();
        if (Number.isNaN(receivedAt) || receivedAt < after) return false;
      }
      if (!normalizedQuery) return true;
      return [
        item.bodySummary,
        item.communityHost ?? "",
        item.category ?? "uncategorized",
        item.submitterPubkey,
      ].some((value) => value.toLocaleLowerCase().includes(normalizedQuery));
    });
    return { communities, filtered };
  }, [items, query, community, timeRange, statusFilter, overrides]);
  return children(results);
}

function FeedbackDetailView({
  id,
  authMode,
}: {
  id: string;
  authMode: AuthMode;
}) {
  const resource = useResource(
    () => request<FeedbackDetail>(`/feedback/${id}`, authMode),
    id,
  );
  return (
    <Page
      eyebrow="Product"
      title="Feedback detail"
      description="The complete feedback submission and its source."
      back="/feedback"
      backLabel="Back to feedback"
    >
      <StateView resource={resource}>
        {(feedback) => {
          // Without an authoritative host we cannot validate attachment URLs or
          // derive their fetch paths safely, so we render none and mark the
          // provenance unavailable rather than guessing an origin.
          const attachments = feedback.communityHost
            ? feedbackAttachments(
                feedback.id,
                feedback.tags,
                feedback.communityHost,
              )
            : [];
          const body = stripAttachmentMarkdown(feedback.body, attachments);
          return (
            <article className="detail">
              <div className="detail-heading">
                <span className="record-icon feedback-icon">
                  <CategoryIcon category={feedback.category} />
                </span>
                <div>
                  <CategoryTag category={feedback.category} />
                  <h2>
                    <Provenance host={feedback.communityHost} />
                  </h2>
                </div>
              </div>
              <dl>
                <dt>Feedback</dt>
                <dd className="sensitive feedback-body">{body}</dd>
                {attachments.length ? (
                  <>
                    <dt>Attachments</dt>
                    <dd className="attachments">
                      {attachments.map((attachment) => (
                        <Attachment
                          key={`${attachment.hash}-${attachment.path}`}
                          attachment={attachment}
                          authMode={authMode}
                        />
                      ))}
                    </dd>
                  </>
                ) : null}
                <dt>Submitted by</dt>
                <dd>
                  <code>{feedback.submitterPubkey}</code>
                </dd>
                <dt>Event</dt>
                <dd>
                  <code>{feedback.eventId}</code>
                </dd>
                <dt>Created</dt>
                <dd>{date(feedback.eventCreatedAt)}</dd>
                <dt>Received</dt>
                <dd>{date(feedback.receivedAt)}</dd>
              </dl>
            </article>
          );
        }}
      </StateView>
    </Page>
  );
}

interface FeedbackAttachment {
  path: string;
  sourceUrl: string;
  mimeType: string;
  hash: string;
  size?: number;
  dimensions?: string;
  filename?: string;
}

function timeRangeStart(range: string) {
  const durations: Record<string, number> = {
    day: 24 * 60 * 60 * 1000,
    week: 7 * 24 * 60 * 60 * 1000,
    month: 30 * 24 * 60 * 60 * 1000,
  };
  const duration = durations[range];
  return duration ? Date.now() - duration : undefined;
}

function feedbackAttachments(
  feedbackId: string,
  tags: string[][],
  communityHost: string,
): FeedbackAttachment[] {
  return tags.flatMap((tag) => {
    if (tag[0] !== "imeta") return [];
    const values = new Map<string, string>();
    for (const entry of tag.slice(1)) {
      const separator = entry.indexOf(" ");
      if (separator > 0) {
        values.set(entry.slice(0, separator), entry.slice(separator + 1));
      }
    }
    const url = values.get("url");
    const mimeType = values.get("m");
    const hash = values.get("x");
    const safeUrl = url && safeAttachmentUrl(url, communityHost);
    if (!safeUrl || !mimeType || !hash) return [];
    const parsedSize = Number(values.get("size"));
    return [
      {
        path: `/feedback/${encodeURIComponent(feedbackId)}/attachments/${hash}`,
        sourceUrl: safeUrl,
        mimeType,
        hash,
        size:
          Number.isFinite(parsedSize) && parsedSize > 0
            ? parsedSize
            : undefined,
        dimensions: values.get("dim"),
        filename: values.get("filename"),
      },
    ];
  });
}

function safeAttachmentUrl(value: string, communityHost: string) {
  try {
    const url = new URL(value, `${location.protocol}//${communityHost}`);
    return ["http:", "https:"].includes(url.protocol) &&
      url.host.toLocaleLowerCase() === communityHost.toLocaleLowerCase() &&
      url.pathname.startsWith("/media/")
      ? url.href
      : undefined;
  } catch {
    return undefined;
  }
}

function stripAttachmentMarkdown(
  body: string,
  attachments?: FeedbackAttachment[],
) {
  const knownUrls = attachments
    ? new Set(attachments.map((attachment) => attachment.sourceUrl))
    : undefined;
  return body
    .replace(/!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (match, url) => {
      const isMedia = knownUrls ? knownUrls.has(url) : url.includes("/media/");
      return isMedia ? "" : match;
    })
    .replace(/\n{3,}/g, "\n\n")
    .trim();
}

function Attachment({
  attachment,
  authMode,
}: {
  attachment: FeedbackAttachment;
  authMode: AuthMode;
}) {
  const [objectUrl, setObjectUrl] = useState<string>();
  const [objectType, setObjectType] = useState<string>();
  const [failed, setFailed] = useState(false);
  const path = attachment.path;
  const name =
    attachment.filename ?? `attachment-${attachment.hash.slice(0, 8)}`;
  const metadata = [
    attachment.mimeType,
    attachment.dimensions,
    attachment.size ? formatBytes(attachment.size) : undefined,
  ]
    .filter(Boolean)
    .join(" · ");

  useEffect(() => {
    // The API requires an Authorization header, so the bytes are fetched here
    // and handed to the DOM as an object URL revoked on replacement/unmount.
    let url: string | undefined;
    let active = true;
    setObjectUrl(undefined);
    setObjectType(undefined);
    setFailed(false);
    requestObjectUrl(path, authMode)
      .then((created) => {
        if (!active) {
          URL.revokeObjectURL(created.url);
          return;
        }
        url = created.url;
        setObjectUrl(created.url);
        setObjectType(created.type);
      })
      .catch(() => {
        if (active) setFailed(true);
      });
    return () => {
      active = false;
      if (url) URL.revokeObjectURL(url);
    };
  }, [path, authMode]);

  const detail = failed ? "Could not load attachment" : metadata;

  // Inline rendering is gated on the server-VERIFIED blob type, never the
  // reporter-supplied `attachment.mimeType`: the relay only labels sniffed
  // passive raster images as `image/*` and forces everything else to
  // `application/octet-stream`, so a hostile payload can never render inline.
  if (objectUrl && objectType?.startsWith("image/")) {
    return (
      <figure className="image-attachment">
        <a href={objectUrl} target="_blank" rel="noreferrer">
          <img src={objectUrl} alt={name} />
        </a>
        <figcaption>
          <span>{name}</span>
          <small>{detail}</small>
        </figcaption>
      </figure>
    );
  }

  const label = (
    <>
      <FileIcon />
      <span>
        <strong>{name}</strong>
        <small>{detail}</small>
      </span>
    </>
  );

  // Until the bytes are fetched there is nothing a link could point at: the
  // API path itself would open unauthenticated in a new tab.
  if (!objectUrl) return <div className="file-attachment">{label}</div>;

  // Non-image (or not-yet-typed) payloads are download-only. The object URL
  // already wraps `application/octet-stream` bytes, and the `download`
  // attribute with no `target="_blank"` means clicking saves the file rather
  // than navigating to a typed document on the admin origin.
  return (
    <a className="file-attachment" href={objectUrl} download={name}>
      {label}
      <ArrowIcon />
    </a>
  );
}

function formatBytes(bytes: number) {
  if (bytes < 1024) return `${bytes} B`;
  if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

function Page({
  eyebrow,
  title,
  description,
  back,
  backLabel,
  children,
}: {
  eyebrow: string;
  title: string;
  description: string;
  back?: string;
  backLabel?: string;
  children: ReactNode;
}) {
  return (
    <section>
      <header className="page-title">
        {back ? (
          <Link href={back} className="back-link">
            <ArrowIcon /> {backLabel ?? "Back to reports"}
          </Link>
        ) : null}
        <p>{eyebrow}</p>
        <h1>{title}</h1>
        <span>{description}</span>
      </header>
      {children}
    </section>
  );
}

function Empty() {
  return <div className="state">No records.</div>;
}

function short(value: string) {
  return value.length > 20 ? `${value.slice(0, 10)}…${value.slice(-8)}` : value;
}

function date(value: string) {
  const parsed = new Date(value);
  return Number.isNaN(parsed.valueOf())
    ? "Unknown date"
    : parsed.toLocaleString();
}

function BuzzMark() {
  return (
    <svg viewBox="0 0 466 309" aria-hidden="true">
      <path d="M91.7 62.8a91.7 91.7 0 0 0 0 183.4H128V62.8H91.7Zm282.6 0H338v183.4h36.3a91.7 91.7 0 1 0 0-183.4Z" />
      <path
        fillRule="evenodd"
        d="M162 0h142a34 34 0 0 1 34 34v241a34 34 0 0 1-34 34H162a34 34 0 0 1-34-34V34a34 34 0 0 1 34-34Zm31.3 57.4a27 27 0 1 0 0 54 27 27 0 0 0 0-54Zm82.7 0a27 27 0 1 0 0 54 27 27 0 0 0 0-54Zm-109.7 99.8h136.9v38.3H166.3v-38.3Zm.6 77.9h136.2v37.6H166.9v-37.6Z"
        clipRule="evenodd"
      />
    </svg>
  );
}

function ReportIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true">
      <path d="M12 3 4.5 6v5.2c0 4.7 3.2 8.8 7.5 9.8 4.3-1 7.5-5.1 7.5-9.8V6L12 3Z" />
      <path d="M12 7.5v5M12 16.5h.01" />
    </svg>
  );
}

function FeedbackIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true">
      <path d="M5 5.5h14v10H9l-4 3v-13Z" />
      <path d="M8.5 9h7M8.5 12h4.5" />
    </svg>
  );
}

function CategoryTag({ category }: { category?: string }) {
  const labels: Record<string, string> = {
    bug: "Bug",
    praise: "Praise",
    "needs-work": "Needs work",
  };
  return (
    <span className="tag">
      <CategoryIcon category={category} />
      {category ? (labels[category] ?? category) : "Uncategorized"}
    </span>
  );
}

function CategoryIcon({ category }: { category?: string }) {
  if (category === "bug") return <BugIcon />;
  if (category === "praise") return <ThumbsUpIcon />;
  if (category === "needs-work") return <WrenchIcon />;
  return <FeedbackIcon />;
}

function BugIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true">
      <path d="M12 20v-9" />
      <path d="M14 7a4 4 0 0 1 4 4v3a6 6 0 0 1-12 0v-3a4 4 0 0 1 4-4z" />
      <path d="M14.12 3.88 16 2M21 21a4 4 0 0 0-3.81-4M21 5a4 4 0 0 1-3.55 3.97M22 13h-4M3 21a4 4 0 0 1 3.81-4M3 5a4 4 0 0 0 3.55 3.97M6 13H2M8 2l1.88 1.88M9 7.13V6a3 3 0 1 1 6 0v1.13" />
    </svg>
  );
}

function ThumbsUpIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true">
      <path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z" />
      <path d="M7 10v12" />
    </svg>
  );
}

function WrenchIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true">
      <path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z" />
    </svg>
  );
}

function SearchIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true">
      <circle cx="11" cy="11" r="6.5" />
      <path d="m16 16 4 4" />
    </svg>
  );
}

function FileIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true">
      <path d="M6 3h8l4 4v14H6V3Z" />
      <path d="M14 3v5h4M9 13h6M9 17h4" />
    </svg>
  );
}

function ArrowIcon() {
  return (
    <svg className="arrow-icon" viewBox="0 0 24 24" aria-hidden="true">
      <path d="m9 18 6-6-6-6" />
    </svg>
  );
}

/// Shown in nip98 mode when no NIP-07 extension is available. Instructs the
/// operator to install nos2x or Alby before continuing.
function Nip07Screen() {
  return (
    <div className="app">
      <div className="state auth-prompt">
        <h2>Nostr extension required</h2>
        <p>
          This relay uses NIP-98 HTTP Auth. Install a NIP-07 browser extension
          such as{" "}
          <a
            href="https://github.com/fiatjaf/nos2x"
            target="_blank"
            rel="noreferrer"
          >
            nos2x
          </a>{" "}
          or{" "}
          <a href="https://getalby.com" target="_blank" rel="noreferrer">
            Alby
          </a>
          , then reload this page. Your Nostr key will be used to sign each
          request.
        </p>
        <button type="button" onClick={() => location.reload()}>
          Reload
        </button>
      </div>
    </div>
  );
}

export function App() {
  const { path } = usePath();

  // Probe the relay once to discover the auth mode. `null` means the probe is
  // still in flight. Once resolved, the mode is stable for the session.
  const [authMode, setAuthMode] = useState<AuthMode | null>(null);
  useEffect(() => {
    let active = true;
    probeAuthMode().then((mode) => {
      if (active) setAuthMode(mode);
    });
    return () => {
      active = false;
    };
  }, []);

  const report = path.match(/^\/reports\/([^/]+)$/);
  const feedback = path.match(/^\/feedback\/([^/]+)$/);

  // Probe still in flight — render nothing to avoid a visible flash.
  if (authMode === null) return null;

  // NIP-98 mode: require a NIP-07 extension.
  if (authMode === "nip98" && !(window as Window & { nostr?: unknown }).nostr)
    return <Nip07Screen />;

  const content = report ? (
    <ReportDetail id={report[1]} authMode={authMode} />
  ) : feedback ? (
    <FeedbackDetailView id={feedback[1]} authMode={authMode} />
  ) : path === "/feedback" ? (
    <FeedbackList authMode={authMode} />
  ) : (
    <Reports authMode={authMode} />
  );
  return (
    <div className="app">
      <header className="app-header">
        <Link href="/reports" className="brand">
          <span className="brand-mark">
            <BuzzMark />
          </span>
          <span>
            Buzz <b>Admin</b>
          </span>
        </Link>
        <nav>
          <Link href="/reports" className="nav-link" activeWhenNested>
            <ReportIcon /> Reports
          </Link>
          <Link href="/feedback" className="nav-link" activeWhenNested>
            <FeedbackIcon /> Feedback
          </Link>
        </nav>
      </header>
      <main>{content}</main>
    </div>
  );
}