tmp/webapp-docs/frontend/components/LinksMenu.js

/**
 * The header links menu: the project's GitHub repo, the live web app's home (the fairyfox hub),
 * and the project docs, followed (below a separator) by the app's own legal pages (Privacy, Terms,
 * Cookies), plus the display-language picker. Every item opens in a new tab with
 * `rel="noopener noreferrer"`; the legal pages are same-origin static pages under `/legal/`, opened
 * in a new tab too so the app keeps its in-memory state.
 *
 * Two presentations, chosen by width (the hydration-safe {@link useCompact}; desktop renders from
 * first paint, unchanged):
 *   • Desktop (>768px): a single icon button that opens a small dropdown popover.
 *   • Compact (<=768px): the links fold DIRECTLY into the header overflow menu as inline rows — no
 *     nested trigger, no separate bottom sheet (which read as a stray, differently-shaped panel).
 * @module gui/components/LinksMenu
 */
import { useEffect, useState } from "react";
import { useIntl, defineMessages } from "react-intl";
import { Select } from "./Field.jsx";
import { useCompact } from "../lib/useCompact.js";
import { APP_VERSION } from "../lib/version.js";
import { AUTO_LOCALE, SUPPORTED_LOCALES, LOCALES } from "../i18n/index.js";
import { MenuIcon, GitHubIcon, BookIcon, HomeIcon, ExternalLinkIcon, ShieldIcon, FileTextIcon, CookieIcon, DownloadIcon, ServerIcon } from "./icons.jsx";

// External destinations. Kept here (not in `online.js`) so the menu owns its own link set.
import { jsxDEV as _jsxDEV, Fragment as _Fragment } from "react/jsx-dev-runtime";
const LINKS = {
  github: "https://github.com/1fairyfox/random-ai-prompt",
  releases: "https://github.com/1fairyfox/random-ai-prompt/releases",
  selfhost: "https://github.com/1fairyfox/random-ai-prompt#how-to-get-it",
  docs: "https://fairyfox.io/random-ai-prompt/",
  home: "https://fairyfox.io"
};

// The app's own legal pages — same-origin static pages served from `gui/public/legal/`.
const LEGAL = {
  privacy: "/legal/privacy.html",
  terms: "/legal/terms.html",
  cookies: "/legal/cookies.html"
};
const msgs = defineMessages({
  menu: {
    id: "linksMenu.menu",
    defaultMessage: "Menu"
  },
  links: {
    id: "linksMenu.links",
    defaultMessage: "Links"
  },
  github: {
    id: "linksMenu.github",
    defaultMessage: "GitHub repository"
  },
  githubDesc: {
    id: "linksMenu.githubDesc",
    defaultMessage: "Source code, issues, and releases"
  },
  docs: {
    id: "linksMenu.docs",
    defaultMessage: "Project docs"
  },
  docsDesc: {
    id: "linksMenu.docsDesc",
    defaultMessage: "API reference and developer guide"
  },
  home: {
    id: "linksMenu.home",
    defaultMessage: "fairyfox.io"
  },
  homeDesc: {
    id: "linksMenu.homeDesc",
    defaultMessage: "The fairyfox project home"
  },
  download: {
    id: "linksMenu.download",
    defaultMessage: "Get the desktop app"
  },
  downloadDesc: {
    id: "linksMenu.downloadDesc",
    defaultMessage: "Pre-built downloads for Windows, macOS, and Linux"
  },
  selfhost: {
    id: "linksMenu.selfhost",
    defaultMessage: "Run it yourself"
  },
  selfhostDesc: {
    id: "linksMenu.selfhostDesc",
    defaultMessage: "Self-host the online edition or build from source"
  },
  privacy: {
    id: "linksMenu.privacy",
    defaultMessage: "Privacy Policy"
  },
  privacyDesc: {
    id: "linksMenu.privacyDesc",
    defaultMessage: "What we collect (almost nothing)"
  },
  terms: {
    id: "linksMenu.terms",
    defaultMessage: "Terms & Conditions"
  },
  termsDesc: {
    id: "linksMenu.termsDesc",
    defaultMessage: "The rules for using the app"
  },
  cookies: {
    id: "linksMenu.cookies",
    defaultMessage: "Cookies Policy"
  },
  cookiesDesc: {
    id: "linksMenu.cookiesDesc",
    defaultMessage: "We don't use cookies"
  },
  language: {
    id: "linksMenu.language",
    defaultMessage: "Language"
  },
  localeAuto: {
    id: "linksMenu.localeAuto",
    defaultMessage: "Auto (browser)",
    description: "Locale option that follows the browser's language"
  }
});

/**
 * @returns {JSX.Element} The header links menu.
 */
export default function LinksMenu({
  settings,
  setSettings
}) {
  const intl = useIntl();
  const compact = useCompact();
  const [open, setOpen] = useState(false);

  // App-wide display-language picker lives in this menu (alongside the project links + legal pages).
  const localeOptions = [{
    value: AUTO_LOCALE,
    label: intl.formatMessage(msgs.localeAuto)
  }, ...SUPPORTED_LOCALES.map(code => ({
    value: code,
    label: LOCALES[code].label
  }))];

  // Close on Escape (desktop dropdown only).
  useEffect(() => {
    if (!open) return undefined;
    const onKey = e => {
      if (e.key === "Escape") setOpen(false);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [open]);

  // `external: true` items get the little outbound-link glyph; the legal pages are part of the app
  // (same origin), so they omit it even though they also open in a new tab.
  const groups = [[{
    href: LINKS.github,
    Icon: GitHubIcon,
    label: msgs.github,
    desc: msgs.githubDesc,
    external: true
  }, {
    href: LINKS.docs,
    Icon: BookIcon,
    label: msgs.docs,
    desc: msgs.docsDesc,
    external: true
  }, {
    href: LINKS.home,
    Icon: HomeIcon,
    label: msgs.home,
    desc: msgs.homeDesc,
    external: true
  }], [{
    href: LINKS.releases,
    Icon: DownloadIcon,
    label: msgs.download,
    desc: msgs.downloadDesc,
    external: true
  }, {
    href: LINKS.selfhost,
    Icon: ServerIcon,
    label: msgs.selfhost,
    desc: msgs.selfhostDesc,
    external: true
  }], [{
    href: LEGAL.privacy,
    Icon: ShieldIcon,
    label: msgs.privacy,
    desc: msgs.privacyDesc
  }, {
    href: LEGAL.terms,
    Icon: FileTextIcon,
    label: msgs.terms,
    desc: msgs.termsDesc
  }, {
    href: LEGAL.cookies,
    Icon: CookieIcon,
    label: msgs.cookies,
    desc: msgs.cookiesDesc
  }]];
  const langGroup = setSettings && /*#__PURE__*/_jsxDEV("div", {
    className: "links-group links-lang",
    role: "group",
    children: /*#__PURE__*/_jsxDEV(Select, {
      label: intl.formatMessage(msgs.language),
      value: settings?.locale ?? AUTO_LOCALE,
      onChange: v => setSettings(s => ({
        ...s,
        locale: v
      })),
      options: localeOptions
    }, void 0, false)
  }, void 0, false);
  const linkGroups = groups.map((items, gi) => /*#__PURE__*/_jsxDEV("div", {
    className: "links-group",
    role: "group",
    children: [gi > 0 && /*#__PURE__*/_jsxDEV("div", {
      className: "links-sep",
      role: "separator"
    }, void 0, false), items.map(({
      href,
      Icon,
      label,
      desc,
      external
    }) => /*#__PURE__*/_jsxDEV("a", {
      className: "links-item",
      role: "menuitem",
      href: href,
      target: "_blank",
      rel: "noopener noreferrer",
      onClick: () => setOpen(false),
      children: [/*#__PURE__*/_jsxDEV("span", {
        className: "links-item-icon",
        "aria-hidden": "true",
        children: /*#__PURE__*/_jsxDEV(Icon, {}, void 0, false)
      }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
        className: "links-item-text",
        children: [/*#__PURE__*/_jsxDEV("span", {
          className: "links-item-label",
          children: intl.formatMessage(label)
        }, void 0, false), /*#__PURE__*/_jsxDEV("span", {
          className: "links-item-desc",
          children: intl.formatMessage(desc)
        }, void 0, false)]
      }, void 0, true), external && /*#__PURE__*/_jsxDEV("span", {
        className: "links-item-ext",
        "aria-hidden": "true",
        children: /*#__PURE__*/_jsxDEV(ExternalLinkIcon, {}, void 0, false)
      }, void 0, false)]
    }, href, true))]
  }, gi, true));

  // Compact (<=768px): fold the links straight into the header overflow menu — the project links,
  // then legal pages, then the language picker — instead of a nested trigger that opened a separate
  // bottom sheet. No trigger button, no popover, no scrim.
  if (compact) {
    return /*#__PURE__*/_jsxDEV("div", {
      className: "links-menu links-inline",
      role: "group",
      "aria-label": intl.formatMessage(msgs.links),
      children: [langGroup && /*#__PURE__*/_jsxDEV(_Fragment, {
        children: [langGroup, /*#__PURE__*/_jsxDEV("div", {
          className: "links-sep",
          role: "separator"
        }, void 0, false)]
      }, void 0, true), linkGroups, /*#__PURE__*/_jsxDEV("div", {
        className: "links-sep",
        role: "separator"
      }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
        className: "links-version",
        title: `Version ${APP_VERSION}`,
        children: ["v", APP_VERSION]
      }, void 0, true)]
    }, void 0, true);
  }

  // Desktop (>768px): the icon-button dropdown (unchanged).
  return /*#__PURE__*/_jsxDEV("div", {
    className: "links-menu",
    children: [/*#__PURE__*/_jsxDEV("button", {
      className: `links-trigger${open ? " on" : ""}`,
      onClick: () => setOpen(o => !o),
      title: intl.formatMessage(msgs.menu),
      "aria-label": intl.formatMessage(msgs.menu),
      "aria-haspopup": "menu",
      "aria-expanded": open,
      children: [/*#__PURE__*/_jsxDEV(MenuIcon, {}, void 0, false), /*#__PURE__*/_jsxDEV("span", {
        className: "ctl-label",
        children: intl.formatMessage(msgs.links)
      }, void 0, false)]
    }, void 0, true), open && /*#__PURE__*/_jsxDEV(_Fragment, {
      children: [/*#__PURE__*/_jsxDEV("div", {
        className: "links-scrim",
        onClick: () => setOpen(false),
        "aria-hidden": "true"
      }, void 0, false), /*#__PURE__*/_jsxDEV("div", {
        className: "links-pop",
        role: "menu",
        "aria-label": intl.formatMessage(msgs.links),
        children: [langGroup && /*#__PURE__*/_jsxDEV(_Fragment, {
          children: [langGroup, /*#__PURE__*/_jsxDEV("div", {
            className: "links-sep",
            role: "separator"
          }, void 0, false)]
        }, void 0, true), linkGroups]
      }, void 0, true)]
    }, void 0, true)]
  }, void 0, true);
}