@igstack/app-catalog-frontend-core 4.0.0-alpha-20260920124718 → 4.0.0-alpha-20260920161438
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/__tests__/modules/appCatalog/ui/components/QuickJumpBar.focus.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.d.ts +0 -0
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js +5 -0
- package/dist/esm/modules/appCatalog/ui/components/QuickJumpBar.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +3 -2
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/components/QuickJumpBar.focus.test.tsx +67 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +78 -0
- package/src/modules/appCatalog/ui/components/QuickJumpBar.tsx +13 -0
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +7 -3
|
File without changes
|
|
File without changes
|
|
@@ -51,6 +51,11 @@ function QuickJumpBar({ app }) {
|
|
|
51
51
|
const inputRef = React__default.useRef(null);
|
|
52
52
|
const hintId = React__default.useId();
|
|
53
53
|
React__default.useEffect(() => setValue(""), [jumps]);
|
|
54
|
+
React__default.useEffect(() => {
|
|
55
|
+
var _a;
|
|
56
|
+
if (window.matchMedia("(pointer: coarse)").matches) return;
|
|
57
|
+
(_a = inputRef.current) == null ? void 0 : _a.focus({ preventScroll: true });
|
|
58
|
+
}, [jumps]);
|
|
54
59
|
React__default.useEffect(() => {
|
|
55
60
|
if (slug && !jumps.some((jump) => quickJumpSlug(jump) === slug)) setSlug("");
|
|
56
61
|
}, [jumps, slug, setSlug]);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"QuickJumpBar.js","sources":["../../../../../../src/modules/appCatalog/ui/components/QuickJumpBar.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ChevronDown, ExternalLink } from 'lucide-react'\nimport React from 'react'\n\nimport { useUrlSyncedState } from '~/modules/appCatalog/hooks/useUrlSyncedState'\nimport {\n buildQuickJumpUrl,\n groupQuickJumps,\n quickJumpFieldName,\n quickJumpShortTitle,\n quickJumpSlug,\n} from '~/modules/appCatalog/utils/quickJump'\nimport { cn } from '~/lib/utils'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from '~/ui/dropdown-menu'\n\n/** Whether the open button should stand down for this app's Quick Jump. */\nexport function hasQuickJumps(app: Resource): boolean {\n return groupQuickJumps(app).some((group) => group.jumps.length > 0)\n}\n\n/**\n * One horizontal inset and one icon gap for every segment AND for the open\n * button, so the two seams either side of the field measure the same.\n *\n * Vertical padding is deliberately absent: height comes from a `min-h` on each\n * segment. Borrowing it from a sibling through `items-stretch` is what left the\n * picker a 20.5px tap target once it wrapped onto its own line, and what made the\n * row 33.5px on an app with jumps but 32px on an app without.\n */\nconst SEGMENT =\n 'inline-flex items-center gap-1.5 px-3 text-sm whitespace-nowrap'\n\n/** One focus ring per focusable segment, or tabbing the row never says where\n you are: the shell's ring belongs to the field (below), these to the ends. */\nconst SEGMENT_FOCUS =\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring'\n\n/**\n * The destination picker. Sized to the app's WIDEST short title (see the ghost\n * below), not to a constant: a constant was simultaneously 100px too narrow for\n * the long titles and three quarters empty on an app with one short one. Widest,\n * rather than current, is what keeps the field and Jump from sliding sideways\n * when the destination changes — the only thing the constant was protecting.\n *\n * Its own full-width line below `sm`, where the three segments together do not\n * fit in a phone's panel at all. It carries the short title — the full one is in\n * the menu, where you are choosing between systems.\n */\nconst PICKER = [\n SEGMENT,\n // `calc(var(--radius)_-_1px)` is the shell's radius less its own border, so the\n // inner corners are concentric with the outer ones. Written out rather than\n // interpolated from a constant: Tailwind reads these class names out of the\n // source text, and a template hole is not a class name.\n 'w-full min-h-10 shrink-0 rounded-t-[calc(var(--radius)_-_1px)] border-b border-border bg-muted text-left font-semibold',\n 'sm:w-auto sm:min-h-8 sm:max-w-[min(20rem,100%)] sm:rounded-t-none sm:rounded-l-[calc(var(--radius)_-_1px)] sm:border-b-0',\n].join(' ')\n\nconst JUMP = [\n SEGMENT,\n 'min-h-8 shrink-0 rounded-br-[calc(var(--radius)_-_1px)] border-l border-border font-bold sm:rounded-br-none sm:rounded-r-[calc(var(--radius)_-_1px)]',\n SEGMENT_FOCUS,\n].join(' ')\n\n/**\n * The leap, as a free-standing button does it: it lifts onto its toes on\n * `:hover` and crouches on `:active`. Deliberately NOT an on-click animation —\n * these links open a new tab, which takes focus the same instant, so a keyframe\n * fired on click runs in a backgrounded tab and the user only sees it on the way\n * back.\n *\n * The transition has to name `translate` and `scale`: in Tailwind v4 those\n * utilities set those separate properties, which a `transform` transition never\n * sees.\n *\n * This belongs to the open button ALONE. Jump is welded into a bordered frame,\n * where the same lift covered the shell's top border along its whole width and\n * the crouch opened 3.5px of card above it while overhanging the bottom. A\n * segment presses inward instead — see `JUMP_FILL`.\n */\nexport const LEAP =\n 'origin-bottom transition-[background-color,translate,scale] duration-100 ease-out motion-safe:hover:-translate-y-[1.5px] motion-safe:active:translate-y-px motion-safe:active:scale-y-[0.92]'\n\n/**\n * The brand fill for a button that leaves for the app. Darkened, because\n * `--primary` under `--primary-foreground` measures 3.8:1 — below WCAG AA. 90%\n * measured 4.56:1, which clears AA by 0.06 at a size where bold does not count\n * as large text; 85% measures 5.19:1.\n *\n * A token-level fix would be better and is a brand decision, not this\n * component's: every filled button in the app inherits the same ratio.\n */\nexport const LEAVE_FILL = [\n 'bg-[color-mix(in_oklab,var(--color-primary)_85%,black)] text-primary-foreground',\n 'hover:bg-[color-mix(in_oklab,var(--color-primary)_75%,black)]',\n].join(' ')\n\n/**\n * The open button when the app also has a Quick Jump. A row holds exactly ONE\n * solid brand fill and it belongs on the submit — the one element in a\n * field-shaped control that has to announce \"this acts on what you typed\".\n * Sharing the fill made the two read as a single four-part blob.\n *\n * The button keeps its primacy through position and its self-describing label,\n * not through being the loudest thing in the row. The branch is decided by the\n * app's data, never by the input's value: the hierarchy must not flicker while\n * the user types.\n *\n * The border is not decoration. `--secondary` and the picker's `--muted` sit\n * 0.015 apart in lightness, so without it the button and the shell's first\n * segment read as one continuous panel straight across the gap — the same\n * failure again, in grey.\n */\nexport const QUIET_FILL =\n 'border border-border bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground'\n\n/** Jump's fill: the leave fill, pressed inward rather than crouching. */\nconst JUMP_FILL = [\n LEAVE_FILL,\n 'active:bg-[color-mix(in_oklab,var(--color-primary)_68%,black)]',\n 'active:shadow-[inset_0_2px_3px_rgb(0_0_0/0.22)]',\n 'transition-[background-color,box-shadow] duration-150 ease-out',\n].join(' ')\n\n/**\n * Quick Jump: `[ destination ▾ | id | Jump ]`, one control sitting next to the\n * app's own open button. The picker only names and swaps the destination; Jump\n * is the sole thing that navigates. Renders nothing for an app with no jumps.\n */\nexport function QuickJumpBar({ app }: { app: Resource }) {\n const identities = React.useMemo(() => groupQuickJumps(app), [app])\n const jumps = React.useMemo(\n () => identities.flatMap((group) => group.jumps),\n [identities],\n )\n // The chosen destination lives in the url (`?qj=tracker.view-case`), so a\n // \"use this one\" link is shareable. Empty = the app's first jump.\n const [slug, setSlug] = useUrlSyncedState<string>({\n key: 'qj',\n defaultValue: '',\n encode: (value) => value || undefined,\n })\n const [value, setValue] = React.useState('')\n const inputRef = React.useRef<HTMLInputElement>(null)\n const hintId = React.useId()\n\n // A typed id belongs to the app it was typed on.\n React.useEffect(() => setValue(''), [jumps])\n\n // A `?qj=` naming another app's jump would silently resolve to this app's\n // first one, leaving the url claiming something untrue.\n React.useEffect(() => {\n if (slug && !jumps.some((jump) => quickJumpSlug(jump) === slug)) setSlug('')\n }, [jumps, slug, setSlug])\n\n if (jumps.length === 0) return null\n\n const selected =\n jumps.find((jump) => quickJumpSlug(jump) === slug) ?? jumps[0]\n if (!selected) return null\n\n const trimmed = value.trim()\n // Never null in practice: groupQuickJumps already dropped the jumps this app\n // cannot build a url for.\n const href =\n (trimmed ? buildQuickJumpUrl(selected, trimmed, app.appUrl) : null) ??\n undefined\n\n // What the picker is sized to. Longest by characters rather than by measured\n // text: close enough at one weight and one size, and it costs no layout pass.\n const widest = jumps\n .map(quickJumpShortTitle)\n .reduce((longest, title) =>\n title.length > longest.length ? title : longest,\n )\n\n return (\n <div\n className={cn(\n 'group flex w-full max-w-full flex-wrap items-stretch rounded-lg border border-input bg-card shadow-[inset_0_1px_2px_rgb(0_0_0/0.09)]',\n // Any segment, not just the field: the picker and Jump are focusable\n // too, and a shell that stays unlit for two of its three ends reads as\n // if tabbing had left the control.\n 'has-[:focus-visible]:border-ring has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/25',\n 'sm:w-auto',\n )}\n >\n {jumps.length > 1 ? (\n <DropdownMenu>\n <DropdownMenuTrigger\n title={`${selected.title} — choose a destination`}\n className={cn(\n PICKER,\n SEGMENT_FOCUS,\n 'cursor-pointer transition-colors hover:bg-accent',\n )}\n >\n {/* The shown label and an invisible copy of the widest one share a\n single grid cell, so the column is as wide as the widest and the\n segment cannot change width when the destination changes.\n\n The cap has to be on this box, not only on the segment: a\n `max-width` on a flex item does not clamp that item's\n max-content contribution, so the shell's own width would still\n be computed from the uncapped label and leave dead card-coloured\n space after Jump. */}\n <span className=\"grid min-w-0 max-w-[296px]\">\n {/* Caret inside the shown cell, immediately after the label — as\n a sibling of the whole box it would be stranded at the far\n edge on every destination but the widest, where it reads as\n the field's boundary and leaves the picker looking like a wide\n empty select. The ghost reserves its width instead. */}\n <span className=\"flex min-w-0 items-center gap-1.5 [grid-area:1/1]\">\n <span className=\"truncate\">\n {quickJumpShortTitle(selected)}\n </span>\n <ChevronDown className=\"size-4 shrink-0 text-foreground/70\" />\n </span>\n {/* Omitted when the widest destination is the one on screen: the\n shown cell already measures that wide, and a second copy of\n the same words is one a text query has to disambiguate. */}\n {widest !== quickJumpShortTitle(selected) && (\n <span\n aria-hidden\n // 16px caret + the 6px gap it sits behind.\n className=\"invisible h-0 pr-[22px] whitespace-nowrap [grid-area:1/1]\"\n >\n {widest}\n </span>\n )}\n </span>\n </DropdownMenuTrigger>\n {/* Portalled, never narrower than the trigger, and sized to its\n content beyond that: the trigger ellipsises, the menu never does. */}\n <DropdownMenuContent\n align=\"start\"\n sideOffset={6}\n className=\"max-w-[min(90vw,34rem)] min-w-[var(--radix-dropdown-menu-trigger-width)]\"\n >\n <DropdownMenuRadioGroup\n value={quickJumpSlug(selected)}\n onValueChange={setSlug}\n >\n {identities.map((group) => (\n <React.Fragment key={group.identity}>\n <DropdownMenuLabel className=\"px-2 py-1 text-[0.65rem] font-extrabold tracking-wider text-muted-foreground uppercase\">\n {group.identity}\n </DropdownMenuLabel>\n {group.jumps.map((jump) => (\n <DropdownMenuRadioItem\n key={quickJumpSlug(jump)}\n value={quickJumpSlug(jump)}\n // Concentric with the menu: 8px inside a 12px box with\n // 4px of padding.\n className=\"rounded-[8px] pl-7 whitespace-nowrap\"\n >\n {jump.title}\n </DropdownMenuRadioItem>\n ))}\n </React.Fragment>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n ) : (\n <span title={selected.title} className={cn(PICKER, 'truncate')}>\n {quickJumpShortTitle(selected)}\n </span>\n )}\n\n {/* The field owns a positioning context so the hint below can be anchored\n to it. The input is taken out of flow inside it: an `<input>` keeps an\n intrinsic 20-character width that wins inside an auto-width flex row,\n and the field's width is a design decision, not a font metric. */}\n <div\n className={cn(\n 'relative flex min-h-8 w-[170px] min-w-[120px] flex-[1_1_170px] border-border',\n // Grows into the row's leftovers below `sm`, where it shares a line\n // with Jump. It must NOT grow from `sm` up: the shell is auto-width\n // there, so growing fills nothing but does hand the field the\n // picker's clamped-away max-content overflow.\n 'sm:flex-[0_1_170px] sm:border-l',\n )}\n >\n <input\n ref={inputRef}\n type=\"text\"\n // Namespaced per identity: the browser remembers values typed for this\n // identifier and offers them on every app that asks for it.\n name={quickJumpFieldName(selected.identity)}\n autoComplete=\"on\"\n aria-label={selected.identity}\n // Bare identity, not \"Enter <identity>\": the verb costs ~40px of a\n // 170px field that the longest identifiers need.\n placeholder={selected.identity}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n // Selecting on focus rather than on click, or a second click inside\n // could never place a caret.\n onFocus={(e) => e.currentTarget.select()}\n onKeyDown={(e) => {\n if (e.key !== 'Enter' || !href) return\n window.open(href, '_blank', 'noopener,noreferrer')\n }}\n className={cn(\n 'absolute inset-0 h-full w-full bg-transparent px-3 font-mono text-sm font-medium outline-none placeholder:font-sans placeholder:text-muted-foreground',\n // Hovering the dormant Jump lights up the field it is waiting on.\n 'transition-[box-shadow,background-color] duration-150',\n 'group-has-[.qj-dormant:hover]:bg-ring/10 group-has-[.qj-dormant:hover]:shadow-[inset_0_0_0_2px_var(--color-ring)]',\n 'group-has-[.qj-dormant:hover]:placeholder:text-foreground',\n )}\n />\n\n {/* Hovering a button that does nothing is someone asking why. The answer\n points at the FIELD, not at the button under the cursor — which is\n why it hangs off the field's own box rather than off a hard-coded\n picker width that stopped being true once the picker was sized to its\n content. The field lights up at the same time.\n\n Not `aria-hidden`: it is the dormant button's description, which is\n also why that button no longer carries a `title` saying the same\n thing in different words. */}\n <p\n id={hintId}\n className={cn(\n 'pointer-events-none absolute top-[calc(100%+0.5rem)] left-0 z-30 m-0 flex items-center rounded-md bg-foreground px-2 py-1 text-xs font-semibold whitespace-nowrap text-background',\n 'before:absolute before:-top-[3px] before:left-[0.9rem] before:size-2 before:rotate-45 before:rounded-[1px] before:bg-foreground',\n // `translate`, not `transform`: v4 utilities set the separate\n // `translate` property, which a transform transition never sees.\n '-translate-y-[3px] opacity-0 transition-[opacity,translate] duration-150',\n 'group-has-[.qj-dormant:hover]:translate-y-0 group-has-[.qj-dormant:hover]:opacity-100',\n 'group-has-[.qj-dormant:focus-visible]:translate-y-0 group-has-[.qj-dormant:focus-visible]:opacity-100',\n )}\n >\n Type a {selected.identity} here first\n </p>\n </div>\n\n {/* A real <button> while dormant. An <a> without href is not focusable,\n so the whole \"press it and it tells you to type\" behaviour — and the\n hint's :focus-visible branch — would have been mouse-only. */}\n {href ? (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n // Title, not the url: the browser already puts the url in the\n // status bar, and some of these are 250 characters long. It doubles\n // as the full text when the picker has ellipsised it.\n title={`Jump to ${selected.title}`}\n className={cn(JUMP, JUMP_FILL)}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </a>\n ) : (\n <button\n type=\"button\"\n aria-describedby={hintId}\n onClick={() => inputRef.current?.focus()}\n // Tinted toward the action rather than greyed: on `bg-muted` it was\n // the picker's twin, so the row read as two dropdowns around a\n // field and nothing in it looked raised.\n className={cn(\n JUMP,\n 'qj-dormant cursor-text bg-primary/12 text-muted-foreground',\n )}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </button>\n )}\n </div>\n )\n}\n"],"names":["React","value"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"QuickJumpBar.js","sources":["../../../../../../src/modules/appCatalog/ui/components/QuickJumpBar.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ChevronDown, ExternalLink } from 'lucide-react'\nimport React from 'react'\n\nimport { useUrlSyncedState } from '~/modules/appCatalog/hooks/useUrlSyncedState'\nimport {\n buildQuickJumpUrl,\n groupQuickJumps,\n quickJumpFieldName,\n quickJumpShortTitle,\n quickJumpSlug,\n} from '~/modules/appCatalog/utils/quickJump'\nimport { cn } from '~/lib/utils'\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuLabel,\n DropdownMenuRadioGroup,\n DropdownMenuRadioItem,\n DropdownMenuTrigger,\n} from '~/ui/dropdown-menu'\n\n/** Whether the open button should stand down for this app's Quick Jump. */\nexport function hasQuickJumps(app: Resource): boolean {\n return groupQuickJumps(app).some((group) => group.jumps.length > 0)\n}\n\n/**\n * One horizontal inset and one icon gap for every segment AND for the open\n * button, so the two seams either side of the field measure the same.\n *\n * Vertical padding is deliberately absent: height comes from a `min-h` on each\n * segment. Borrowing it from a sibling through `items-stretch` is what left the\n * picker a 20.5px tap target once it wrapped onto its own line, and what made the\n * row 33.5px on an app with jumps but 32px on an app without.\n */\nconst SEGMENT =\n 'inline-flex items-center gap-1.5 px-3 text-sm whitespace-nowrap'\n\n/** One focus ring per focusable segment, or tabbing the row never says where\n you are: the shell's ring belongs to the field (below), these to the ends. */\nconst SEGMENT_FOCUS =\n 'focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ring'\n\n/**\n * The destination picker. Sized to the app's WIDEST short title (see the ghost\n * below), not to a constant: a constant was simultaneously 100px too narrow for\n * the long titles and three quarters empty on an app with one short one. Widest,\n * rather than current, is what keeps the field and Jump from sliding sideways\n * when the destination changes — the only thing the constant was protecting.\n *\n * Its own full-width line below `sm`, where the three segments together do not\n * fit in a phone's panel at all. It carries the short title — the full one is in\n * the menu, where you are choosing between systems.\n */\nconst PICKER = [\n SEGMENT,\n // `calc(var(--radius)_-_1px)` is the shell's radius less its own border, so the\n // inner corners are concentric with the outer ones. Written out rather than\n // interpolated from a constant: Tailwind reads these class names out of the\n // source text, and a template hole is not a class name.\n 'w-full min-h-10 shrink-0 rounded-t-[calc(var(--radius)_-_1px)] border-b border-border bg-muted text-left font-semibold',\n 'sm:w-auto sm:min-h-8 sm:max-w-[min(20rem,100%)] sm:rounded-t-none sm:rounded-l-[calc(var(--radius)_-_1px)] sm:border-b-0',\n].join(' ')\n\nconst JUMP = [\n SEGMENT,\n 'min-h-8 shrink-0 rounded-br-[calc(var(--radius)_-_1px)] border-l border-border font-bold sm:rounded-br-none sm:rounded-r-[calc(var(--radius)_-_1px)]',\n SEGMENT_FOCUS,\n].join(' ')\n\n/**\n * The leap, as a free-standing button does it: it lifts onto its toes on\n * `:hover` and crouches on `:active`. Deliberately NOT an on-click animation —\n * these links open a new tab, which takes focus the same instant, so a keyframe\n * fired on click runs in a backgrounded tab and the user only sees it on the way\n * back.\n *\n * The transition has to name `translate` and `scale`: in Tailwind v4 those\n * utilities set those separate properties, which a `transform` transition never\n * sees.\n *\n * This belongs to the open button ALONE. Jump is welded into a bordered frame,\n * where the same lift covered the shell's top border along its whole width and\n * the crouch opened 3.5px of card above it while overhanging the bottom. A\n * segment presses inward instead — see `JUMP_FILL`.\n */\nexport const LEAP =\n 'origin-bottom transition-[background-color,translate,scale] duration-100 ease-out motion-safe:hover:-translate-y-[1.5px] motion-safe:active:translate-y-px motion-safe:active:scale-y-[0.92]'\n\n/**\n * The brand fill for a button that leaves for the app. Darkened, because\n * `--primary` under `--primary-foreground` measures 3.8:1 — below WCAG AA. 90%\n * measured 4.56:1, which clears AA by 0.06 at a size where bold does not count\n * as large text; 85% measures 5.19:1.\n *\n * A token-level fix would be better and is a brand decision, not this\n * component's: every filled button in the app inherits the same ratio.\n */\nexport const LEAVE_FILL = [\n 'bg-[color-mix(in_oklab,var(--color-primary)_85%,black)] text-primary-foreground',\n 'hover:bg-[color-mix(in_oklab,var(--color-primary)_75%,black)]',\n].join(' ')\n\n/**\n * The open button when the app also has a Quick Jump. A row holds exactly ONE\n * solid brand fill and it belongs on the submit — the one element in a\n * field-shaped control that has to announce \"this acts on what you typed\".\n * Sharing the fill made the two read as a single four-part blob.\n *\n * The button keeps its primacy through position and its self-describing label,\n * not through being the loudest thing in the row. The branch is decided by the\n * app's data, never by the input's value: the hierarchy must not flicker while\n * the user types.\n *\n * The border is not decoration. `--secondary` and the picker's `--muted` sit\n * 0.015 apart in lightness, so without it the button and the shell's first\n * segment read as one continuous panel straight across the gap — the same\n * failure again, in grey.\n */\nexport const QUIET_FILL =\n 'border border-border bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground'\n\n/** Jump's fill: the leave fill, pressed inward rather than crouching. */\nconst JUMP_FILL = [\n LEAVE_FILL,\n 'active:bg-[color-mix(in_oklab,var(--color-primary)_68%,black)]',\n 'active:shadow-[inset_0_2px_3px_rgb(0_0_0/0.22)]',\n 'transition-[background-color,box-shadow] duration-150 ease-out',\n].join(' ')\n\n/**\n * Quick Jump: `[ destination ▾ | id | Jump ]`, one control sitting next to the\n * app's own open button. The picker only names and swaps the destination; Jump\n * is the sole thing that navigates. Renders nothing for an app with no jumps.\n */\nexport function QuickJumpBar({ app }: { app: Resource }) {\n const identities = React.useMemo(() => groupQuickJumps(app), [app])\n const jumps = React.useMemo(\n () => identities.flatMap((group) => group.jumps),\n [identities],\n )\n // The chosen destination lives in the url (`?qj=tracker.view-case`), so a\n // \"use this one\" link is shareable. Empty = the app's first jump.\n const [slug, setSlug] = useUrlSyncedState<string>({\n key: 'qj',\n defaultValue: '',\n encode: (value) => value || undefined,\n })\n const [value, setValue] = React.useState('')\n const inputRef = React.useRef<HTMLInputElement>(null)\n const hintId = React.useId()\n\n // A typed id belongs to the app it was typed on.\n React.useEffect(() => setValue(''), [jumps])\n\n // Opening a card is a deliberate act, and this field is the one thing in the\n // header you act on by typing — so it takes the caret, and you can open an app\n // and enter an id without reaching for the mouse. Keyed on the app's jumps, so\n // it re-applies when you move to another app inside the same mounted panel.\n //\n // `preventScroll`, or focusing an element inside the scrollable panel scrolls\n // the panel to it. Not on a coarse pointer: there focus pops the virtual\n // keyboard over half of what you just opened.\n React.useEffect(() => {\n if (window.matchMedia('(pointer: coarse)').matches) return\n inputRef.current?.focus({ preventScroll: true })\n }, [jumps])\n\n // A `?qj=` naming another app's jump would silently resolve to this app's\n // first one, leaving the url claiming something untrue.\n React.useEffect(() => {\n if (slug && !jumps.some((jump) => quickJumpSlug(jump) === slug)) setSlug('')\n }, [jumps, slug, setSlug])\n\n if (jumps.length === 0) return null\n\n const selected =\n jumps.find((jump) => quickJumpSlug(jump) === slug) ?? jumps[0]\n if (!selected) return null\n\n const trimmed = value.trim()\n // Never null in practice: groupQuickJumps already dropped the jumps this app\n // cannot build a url for.\n const href =\n (trimmed ? buildQuickJumpUrl(selected, trimmed, app.appUrl) : null) ??\n undefined\n\n // What the picker is sized to. Longest by characters rather than by measured\n // text: close enough at one weight and one size, and it costs no layout pass.\n const widest = jumps\n .map(quickJumpShortTitle)\n .reduce((longest, title) =>\n title.length > longest.length ? title : longest,\n )\n\n return (\n <div\n className={cn(\n 'group flex w-full max-w-full flex-wrap items-stretch rounded-lg border border-input bg-card shadow-[inset_0_1px_2px_rgb(0_0_0/0.09)]',\n // Any segment, not just the field: the picker and Jump are focusable\n // too, and a shell that stays unlit for two of its three ends reads as\n // if tabbing had left the control.\n 'has-[:focus-visible]:border-ring has-[:focus-visible]:ring-[3px] has-[:focus-visible]:ring-ring/25',\n 'sm:w-auto',\n )}\n >\n {jumps.length > 1 ? (\n <DropdownMenu>\n <DropdownMenuTrigger\n title={`${selected.title} — choose a destination`}\n className={cn(\n PICKER,\n SEGMENT_FOCUS,\n 'cursor-pointer transition-colors hover:bg-accent',\n )}\n >\n {/* The shown label and an invisible copy of the widest one share a\n single grid cell, so the column is as wide as the widest and the\n segment cannot change width when the destination changes.\n\n The cap has to be on this box, not only on the segment: a\n `max-width` on a flex item does not clamp that item's\n max-content contribution, so the shell's own width would still\n be computed from the uncapped label and leave dead card-coloured\n space after Jump. */}\n <span className=\"grid min-w-0 max-w-[296px]\">\n {/* Caret inside the shown cell, immediately after the label — as\n a sibling of the whole box it would be stranded at the far\n edge on every destination but the widest, where it reads as\n the field's boundary and leaves the picker looking like a wide\n empty select. The ghost reserves its width instead. */}\n <span className=\"flex min-w-0 items-center gap-1.5 [grid-area:1/1]\">\n <span className=\"truncate\">\n {quickJumpShortTitle(selected)}\n </span>\n <ChevronDown className=\"size-4 shrink-0 text-foreground/70\" />\n </span>\n {/* Omitted when the widest destination is the one on screen: the\n shown cell already measures that wide, and a second copy of\n the same words is one a text query has to disambiguate. */}\n {widest !== quickJumpShortTitle(selected) && (\n <span\n aria-hidden\n // 16px caret + the 6px gap it sits behind.\n className=\"invisible h-0 pr-[22px] whitespace-nowrap [grid-area:1/1]\"\n >\n {widest}\n </span>\n )}\n </span>\n </DropdownMenuTrigger>\n {/* Portalled, never narrower than the trigger, and sized to its\n content beyond that: the trigger ellipsises, the menu never does. */}\n <DropdownMenuContent\n align=\"start\"\n sideOffset={6}\n className=\"max-w-[min(90vw,34rem)] min-w-[var(--radix-dropdown-menu-trigger-width)]\"\n >\n <DropdownMenuRadioGroup\n value={quickJumpSlug(selected)}\n onValueChange={setSlug}\n >\n {identities.map((group) => (\n <React.Fragment key={group.identity}>\n <DropdownMenuLabel className=\"px-2 py-1 text-[0.65rem] font-extrabold tracking-wider text-muted-foreground uppercase\">\n {group.identity}\n </DropdownMenuLabel>\n {group.jumps.map((jump) => (\n <DropdownMenuRadioItem\n key={quickJumpSlug(jump)}\n value={quickJumpSlug(jump)}\n // Concentric with the menu: 8px inside a 12px box with\n // 4px of padding.\n className=\"rounded-[8px] pl-7 whitespace-nowrap\"\n >\n {jump.title}\n </DropdownMenuRadioItem>\n ))}\n </React.Fragment>\n ))}\n </DropdownMenuRadioGroup>\n </DropdownMenuContent>\n </DropdownMenu>\n ) : (\n <span title={selected.title} className={cn(PICKER, 'truncate')}>\n {quickJumpShortTitle(selected)}\n </span>\n )}\n\n {/* The field owns a positioning context so the hint below can be anchored\n to it. The input is taken out of flow inside it: an `<input>` keeps an\n intrinsic 20-character width that wins inside an auto-width flex row,\n and the field's width is a design decision, not a font metric. */}\n <div\n className={cn(\n 'relative flex min-h-8 w-[170px] min-w-[120px] flex-[1_1_170px] border-border',\n // Grows into the row's leftovers below `sm`, where it shares a line\n // with Jump. It must NOT grow from `sm` up: the shell is auto-width\n // there, so growing fills nothing but does hand the field the\n // picker's clamped-away max-content overflow.\n 'sm:flex-[0_1_170px] sm:border-l',\n )}\n >\n <input\n ref={inputRef}\n type=\"text\"\n // Namespaced per identity: the browser remembers values typed for this\n // identifier and offers them on every app that asks for it.\n name={quickJumpFieldName(selected.identity)}\n autoComplete=\"on\"\n aria-label={selected.identity}\n // Bare identity, not \"Enter <identity>\": the verb costs ~40px of a\n // 170px field that the longest identifiers need.\n placeholder={selected.identity}\n value={value}\n onChange={(e) => setValue(e.target.value)}\n // Selecting on focus rather than on click, or a second click inside\n // could never place a caret.\n onFocus={(e) => e.currentTarget.select()}\n onKeyDown={(e) => {\n if (e.key !== 'Enter' || !href) return\n window.open(href, '_blank', 'noopener,noreferrer')\n }}\n className={cn(\n 'absolute inset-0 h-full w-full bg-transparent px-3 font-mono text-sm font-medium outline-none placeholder:font-sans placeholder:text-muted-foreground',\n // Hovering the dormant Jump lights up the field it is waiting on.\n 'transition-[box-shadow,background-color] duration-150',\n 'group-has-[.qj-dormant:hover]:bg-ring/10 group-has-[.qj-dormant:hover]:shadow-[inset_0_0_0_2px_var(--color-ring)]',\n 'group-has-[.qj-dormant:hover]:placeholder:text-foreground',\n )}\n />\n\n {/* Hovering a button that does nothing is someone asking why. The answer\n points at the FIELD, not at the button under the cursor — which is\n why it hangs off the field's own box rather than off a hard-coded\n picker width that stopped being true once the picker was sized to its\n content. The field lights up at the same time.\n\n Not `aria-hidden`: it is the dormant button's description, which is\n also why that button no longer carries a `title` saying the same\n thing in different words. */}\n <p\n id={hintId}\n className={cn(\n 'pointer-events-none absolute top-[calc(100%+0.5rem)] left-0 z-30 m-0 flex items-center rounded-md bg-foreground px-2 py-1 text-xs font-semibold whitespace-nowrap text-background',\n 'before:absolute before:-top-[3px] before:left-[0.9rem] before:size-2 before:rotate-45 before:rounded-[1px] before:bg-foreground',\n // `translate`, not `transform`: v4 utilities set the separate\n // `translate` property, which a transform transition never sees.\n '-translate-y-[3px] opacity-0 transition-[opacity,translate] duration-150',\n 'group-has-[.qj-dormant:hover]:translate-y-0 group-has-[.qj-dormant:hover]:opacity-100',\n 'group-has-[.qj-dormant:focus-visible]:translate-y-0 group-has-[.qj-dormant:focus-visible]:opacity-100',\n )}\n >\n Type a {selected.identity} here first\n </p>\n </div>\n\n {/* A real <button> while dormant. An <a> without href is not focusable,\n so the whole \"press it and it tells you to type\" behaviour — and the\n hint's :focus-visible branch — would have been mouse-only. */}\n {href ? (\n <a\n href={href}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n // Title, not the url: the browser already puts the url in the\n // status bar, and some of these are 250 characters long. It doubles\n // as the full text when the picker has ellipsised it.\n title={`Jump to ${selected.title}`}\n className={cn(JUMP, JUMP_FILL)}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </a>\n ) : (\n <button\n type=\"button\"\n aria-describedby={hintId}\n onClick={() => inputRef.current?.focus()}\n // Tinted toward the action rather than greyed: on `bg-muted` it was\n // the picker's twin, so the row read as two dropdowns around a\n // field and nothing in it looked raised.\n className={cn(\n JUMP,\n 'qj-dormant cursor-text bg-primary/12 text-muted-foreground',\n )}\n >\n Jump\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </button>\n )}\n </div>\n )\n}\n"],"names":["React","value"],"mappings":"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"}
|
|
@@ -112,6 +112,7 @@ function AppDetails({
|
|
|
112
112
|
const [draftSource, setDraftSource] = React__default.useState(null);
|
|
113
113
|
const user = useUser();
|
|
114
114
|
const isAdmin = (user == null ? void 0 : user.isAdmin) ?? false;
|
|
115
|
+
const displayTags = (app.tags ?? []).filter((tag) => !tag.includes(":"));
|
|
115
116
|
const sourceUrls = ((_a = app.sources) == null ? void 0 : _a.map((s) => typeof s === "string" ? s : s.url)) ?? [];
|
|
116
117
|
const displaySources = draftSource !== null ? [...sourceUrls, draftSource] : sourceUrls;
|
|
117
118
|
const pulseByUrl = React__default.useMemo(() => {
|
|
@@ -329,9 +330,9 @@ function AppDetails({
|
|
|
329
330
|
link.url
|
|
330
331
|
)) })
|
|
331
332
|
] }),
|
|
332
|
-
|
|
333
|
+
displayTags.length > 0 && /* @__PURE__ */ jsxs("div", { className: "mt-6", children: [
|
|
333
334
|
/* @__PURE__ */ jsx("h3", { className: "mb-2 text-sm font-medium", children: "Tags" }),
|
|
334
|
-
/* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children:
|
|
335
|
+
/* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: displayTags.map((tag) => /* @__PURE__ */ jsx(Badge, { variant: "secondary", className: "text-xs", children: tag }, tag)) })
|
|
335
336
|
] }),
|
|
336
337
|
app.teams && app.teams.length > 0 && /* @__PURE__ */ jsxs("div", { className: "mt-6", children: [
|
|
337
338
|
/* @__PURE__ */ jsx("h3", { className: "mb-2 text-sm font-medium", children: "Teams" }),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppDetails.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/AppDetails.tsx"],"sourcesContent":["import type {\n Resource,\n SourceReference,\n} from '@igstack/app-catalog-backend-core'\nimport {\n AppWindow,\n CalendarPlus,\n Clock,\n ExternalLink,\n Plus,\n Trash2,\n} from 'lucide-react'\nimport React from 'react'\nimport { useHotkeys } from 'react-hotkeys-hook'\nimport { cn } from '~/lib/utils'\nimport { Badge } from '~/ui/badge'\nimport { Button } from '~/ui/button'\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from '~/ui/accordion'\nimport { AccessRequestSection } from '../components/AccessRequestSection'\nimport { AccessPrerequisiteChain } from '../components/AccessPrerequisiteChain'\nimport {\n LEAP,\n LEAVE_FILL,\n QUIET_FILL,\n QuickJumpBar,\n hasQuickJumps,\n} from '../components/QuickJumpBar'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { PersonBadge } from '../components/PersonBadge'\nimport { useUser } from '~/modules/auth'\nimport { InlineEditableField } from '../components/InlineEditableField'\nimport { MarkdownText } from '../components/MarkdownText'\nimport { ScreenshotGallery } from '../components/ScreenshotGallery'\nimport { SourcePulse } from '../components/SourcePulse'\nimport { useUpdateApp } from '../../hooks/useUpdateApp'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\nimport { TierVariantsSection } from '../components/TierVariantsSection'\nimport { SubResourcesSection } from '../components/SubResourcesSection'\nimport { getChildResources } from '../../utils/resolveHelpers'\nimport { CommentsSection } from './CommentsSection'\nimport { displayUrl } from '~/modules/appCatalog/utils/displayUrl'\n\nfunction getIconUrl(iconName: string): string {\n return `/api/icons/${iconName}`\n}\n\nfunction AppIcon({ app, className }: { app: Resource; className?: string }) {\n const [imageError, setImageError] = React.useState(false)\n\n // Use iconName from backend if available\n if (app.iconName && !imageError) {\n return (\n <div className={cn('size-12 shrink-0', className)}>\n <img\n src={getIconUrl(app.iconName)}\n alt={`${app.abbreviation || app.displayName} icon`}\n className=\"size-12 rounded-lg object-contain\"\n onError={() => setImageError(true)}\n />\n </div>\n )\n }\n\n // Fallback icon\n return (\n <div\n className={cn(\n 'flex items-center justify-center rounded-lg bg-primary/10 text-primary size-12 shrink-0',\n className,\n )}\n >\n <AppWindow className=\"size-6\" />\n </div>\n )\n}\n\nfunction AppScreenshot({ app }: { app: Resource }) {\n const [imageError, setImageError] = React.useState(false)\n const [isLoadingImage, setIsLoadingImage] = React.useState(true)\n\n // Check if app has screenshots\n const screenshotId = app.screenshotIds?.[0]\n if (!screenshotId) {\n return (\n <div className=\"w-full bg-muted/50 rounded-lg overflow-hidden flex items-center justify-center min-h-64\">\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n No screenshot available\n </div>\n </div>\n )\n }\n\n const screenshotImageUrl = `/api/screenshots/${screenshotId}?size=512`\n\n return (\n <div className=\"w-full flex justify-center\">\n <div className=\"rounded-lg overflow-hidden inline-flex items-center justify-center min-h-64\">\n {!imageError ? (\n <img\n src={screenshotImageUrl}\n alt={`${app.abbreviation || app.displayName} screenshot`}\n className=\"h-64 object-contain\"\n onError={() => {\n setImageError(true)\n setIsLoadingImage(false)\n }}\n onLoad={() => setIsLoadingImage(false)}\n />\n ) : null}\n {(imageError || isLoadingImage) && (\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n {isLoadingImage\n ? 'Loading screenshot...'\n : 'No screenshot available'}\n </div>\n )}\n </div>\n </div>\n )\n}\n\nfunction TiersAndSubResourcesPanel({ app }: { app: Resource }) {\n const { resources } = useAppCatalogContext()\n // #38 item C: the active catalog search term. When the user reached this app\n // by searching something that matched a child, seed the sub-resource filter\n // with it so the matched child is revealed.\n const { state: filterState } = useAppCatalogFilters()\n const appSubResources = React.useMemo(\n () => getChildResources(resources, app.slug),\n [resources, app.slug],\n )\n\n return (\n <>\n {app.tiers && app.tiers.length > 0 && (\n <div className=\"mt-6\">\n <TierVariantsSection tiers={app.tiers} />\n </div>\n )}\n {appSubResources.length > 0 && (\n <div className=\"mt-6\">\n <SubResourcesSection\n subResources={appSubResources}\n parentSlug={app.slug}\n initialSearch={filterState.searchValue}\n />\n </div>\n )}\n </>\n )\n}\n\nexport function AppDetails({\n app,\n onAppClick,\n onClosePanel,\n}: {\n app: Resource\n onAppClick?: (app: Resource) => void\n onClosePanel: () => void\n}) {\n const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)\n const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)\n const { approvalMethods, resources: allResources } = useAppCatalogContext()\n const { recordClick } = useAppClickHistory()\n const updateApp = useUpdateApp()\n const [draftSource, setDraftSource] = React.useState<string | null>(null)\n const user = useUser()\n const isAdmin = user?.isAdmin ?? false\n\n const sourceUrls: string[] =\n app.sources?.map((s) => (typeof s === 'string' ? s : s.url)) ?? []\n const displaySources =\n draftSource !== null ? [...sourceUrls, draftSource] : sourceUrls\n // The list renders bare URLs (they are editable as text), so the pulse has to\n // be looked up rather than carried alongside. Absent until the autoupdate loop\n // has seen the source — a freshly added one simply gets no mark.\n const pulseByUrl = React.useMemo(() => {\n const byUrl = new Map<string, NonNullable<SourceReference['pulse']>>()\n for (const source of app.sources ?? []) {\n if (typeof source !== 'string' && source.pulse) {\n byUrl.set(source.url, source.pulse)\n }\n }\n return byUrl\n }, [app.sources])\n\n // Enter: open screenshot gallery\n useHotkeys(\n 'enter',\n () => {\n const tag = document.activeElement?.tagName\n if (\n tag === 'BUTTON' ||\n tag === 'A' ||\n tag === 'INPUT' ||\n tag === 'SELECT' ||\n tag === 'TEXTAREA'\n )\n return\n\n if (app.screenshotIds && app.screenshotIds.length > 0) {\n setGalleryInitialIndex(0)\n setIsGalleryOpen(true)\n }\n },\n { enabled: !isGalleryOpen },\n [app, isGalleryOpen],\n )\n\n // Esc: close the details panel (only when gallery is NOT open)\n useHotkeys(\n 'escape',\n () => {\n onClosePanel()\n },\n { enabled: !isGalleryOpen },\n [isGalleryOpen, onClosePanel],\n )\n\n const handleScreenshotClick = (index: number) => {\n setGalleryInitialIndex(index)\n setIsGalleryOpen(true)\n }\n\n // Find replacement app if deprecated\n const replacementApp = app.deprecated?.replacementSlug\n ? allResources.find((a) => a.slug === app.deprecated?.replacementSlug)\n : null\n\n return (\n <>\n <div className=\"flex h-full flex-col p-6\">\n {/* Icon and Title — always first: a pinned Quick Jump takes order -1,\n so the header claims -2 rather than being pushed below it. */}\n <div className=\"-order-2 border-b pb-6\">\n <div className=\"flex items-center gap-3\">\n <AppIcon app={app} className=\"size-16\" />\n <div className=\"-mx-3 flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 px-3\">\n <div className=\"font-serif text-2xl font-semibold min-w-0\">\n {app.abbreviation\n ? `${app.displayName} (${app.abbreviation})`\n : app.displayName}\n </div>\n {app.deprecated && (\n <Badge\n variant={\n app.deprecated.type === 'discouraged'\n ? 'secondary'\n : 'destructive'\n }\n >\n {app.deprecated.type === 'discouraged'\n ? 'Discouraged'\n : 'Deprecated'}\n </Badge>\n )}\n </div>\n {isAdmin && (\n <div className=\"mt-1 px-3\">\n <span className=\"text-xs text-muted-foreground mr-2\">\n Slug:\n </span>\n <InlineEditableField\n value={app.slug}\n onSave={(slug) =>\n updateApp.mutate({ id: app.id, data: { slug } })\n }\n className=\"text-sm\"\n />\n </div>\n )}\n {/* Open button, then Quick Jump: the host you reach by pressing\n it, and the deep link you reach by pasting an id. */}\n <div className=\"mt-3 flex flex-wrap items-stretch gap-3 px-3\">\n {isAdmin ? (\n <InlineEditableField\n value={app.appUrl ?? ''}\n onSave={(appUrl) =>\n updateApp.mutate({ id: app.id, data: { appUrl } })\n }\n placeholder=\"App URL\"\n renderView={(url) =>\n url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n onClick={() => recordClick(app.slug)}\n className=\"inline-flex items-center gap-1 rounded-md py-1 text-sm text-blue-600 hover:bg-accent/30 hover:underline dark:text-blue-400 transition-all\"\n >\n {displayUrl(url)}\n <ExternalLink className=\"size-3.5 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n ) : app.appUrl ? (\n <a\n href={app.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n onClick={() => recordClick(app.slug)}\n className={cn(\n // The row's one inset and icon gap, and its height stated\n // rather than left to the taller neighbour.\n 'inline-flex min-h-[34px] items-center gap-1.5 rounded-md px-3 text-sm font-medium shadow-sm',\n // Only ever ONE solid brand fill in this row: it goes to\n // Quick Jump's submit when there is one, because that is\n // the control that acts on something the user typed.\n hasQuickJumps(app) ? QUIET_FILL : LEAVE_FILL,\n LEAP,\n )}\n title={displayUrl(app.appUrl)}\n aria-label={`Open ${app.displayName} in a new tab (${displayUrl(app.appUrl)})`}\n >\n {/* \"Go to <host>\": the verb makes it a thing you press and\n the host says where to. The glyph adds \"in a new tab\". */}\n <span className=\"max-w-[280px] truncate whitespace-nowrap\">\n Go to {displayUrl(app.appUrl)}\n </span>\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n <QuickJumpBar app={app} />\n </div>\n {/* Updated/Added metadata moved to consolidated section before Sources (#55) */}\n </div>\n </div>\n </div>\n\n {/* Deprecation/Discouraged Warning */}\n {app.deprecated &&\n (() => {\n const deprecationType = app.deprecated.type || 'deprecated'\n const isDiscouraged = deprecationType === 'discouraged'\n return (\n <div\n className={\n isDiscouraged\n ? 'mt-6 p-4 border border-yellow-500/50 rounded-lg bg-yellow-50 dark:bg-yellow-950/20'\n : 'mt-6 p-4 border border-destructive/50 rounded-lg bg-destructive/10'\n }\n >\n <h3\n className={\n isDiscouraged\n ? 'text-sm font-semibold text-yellow-700 dark:text-yellow-500 mb-2'\n : 'text-sm font-semibold text-destructive mb-2'\n }\n >\n {isDiscouraged\n ? 'Usage discouraged'\n : 'This application is deprecated'}\n </h3>\n <p className=\"text-sm text-muted-foreground mb-3\">\n {app.deprecated.comment}\n </p>\n {replacementApp && (\n <button\n type=\"button\"\n onClick={() => onAppClick?.(replacementApp)}\n className=\"text-sm font-medium text-primary hover:underline inline-flex items-center gap-1\"\n >\n View replacement: {replacementApp.displayName}\n <ExternalLink className=\"size-3\" />\n </button>\n )}\n </div>\n )\n })()}\n\n {/* Description */}\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Description</h3>\n {isAdmin ? (\n <InlineEditableField\n value={app.description ?? ''}\n onSave={(description) =>\n updateApp.mutate({ id: app.id, data: { description } })\n }\n multiline\n placeholder=\"Description\"\n className=\"min-h-[4rem] resize-y text-sm text-muted-foreground\"\n />\n ) : app.description ? (\n <MarkdownText className=\"prose prose-sm max-w-none text-sm text-muted-foreground [&_p]:m-0\">\n {app.description}\n </MarkdownText>\n ) : (\n <p className=\"text-sm text-muted-foreground\">—</p>\n )}\n </div>\n\n {/* Two-step access (#38 D): for a nested resource, show the parent-first\n prerequisite chain before this resource's own access instructions. */}\n <AccessPrerequisiteChain resource={app} onOpenParent={onAppClick} />\n\n {/* Access Request Section — what the app is comes first, then how to\n get into it. */}\n <AccessRequestSection app={app} approvalMethods={approvalMethods} />\n\n {/* Screenshots - Clickable preview */}\n {app.screenshotIds && app.screenshotIds.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">\n Screenshots ({app.screenshotIds.length})\n </h3>\n {/* A button, not a clickable div: the gallery was unreachable by\n keyboard, and a role + label gives it a stable handle. */}\n <button\n type=\"button\"\n aria-label={`View screenshots of ${app.displayName}`}\n className=\"block w-full cursor-pointer text-left hover:opacity-80 transition-opacity\"\n onClick={() => handleScreenshotClick(0)}\n >\n <AppScreenshot app={app} />\n {app.screenshotIds.length > 1 && (\n <p className=\"text-xs text-muted-foreground mt-2 text-center\">\n Click to view all {app.screenshotIds.length} screenshots\n </p>\n )}\n </button>\n </div>\n )}\n\n {/* Owner — who is responsible for this resource. Distinct from the\n access approver (who decides access requests); see domain model. */}\n {app.ownerPersonSlug && (\n <div className=\"mt-6\">\n <h3 className=\"mb-1 text-sm font-medium\">Owner</h3>\n <p className=\"mb-2 text-xs text-muted-foreground\">\n Who is responsible for this resource\n </p>\n <PersonBadge slug={app.ownerPersonSlug} />\n </div>\n )}\n\n {/* Tier Variants and Sub-Resources */}\n <TiersAndSubResourcesPanel app={app} />\n\n {/* Links */}\n {app.links && app.links.length > 0 && (\n <div className=\"mt-4\">\n <h3 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n Links\n </h3>\n <div className=\"space-y-0.5\">\n {app.links.map((link) => (\n <a\n key={link.url}\n href={link.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-1 text-xs text-muted-foreground hover:text-primary truncate\"\n >\n <ExternalLink className=\"size-3 shrink-0\" />\n {link.title || displayUrl(link.url)}\n </a>\n ))}\n </div>\n </div>\n )}\n\n {/* Tags */}\n {app.tags && app.tags.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Tags</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.tags.map((tag) => (\n <Badge key={tag} variant=\"secondary\" className=\"text-xs\">\n {tag}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Teams */}\n {app.teams && app.teams.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Teams</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.teams.map((team) => (\n <Badge key={team} variant=\"outline\" className=\"text-xs\">\n {team}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Technical information: AI-facing fields, de-emphasized/collapsed */}\n {(app.aiPrompt || app.aiMemory) && (\n <Accordion type=\"single\" collapsible className=\"mt-6\">\n <AccordionItem\n value=\"technical-info\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Technical information\n </AccordionTrigger>\n <AccordionContent className=\"pb-3 space-y-3\">\n {app.aiPrompt && (\n <div>\n <h4 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n AI Prompt\n </h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiPrompt}\n </p>\n </div>\n )}\n {app.aiMemory && (\n <div>\n <h4 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n AI Memory\n </h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiMemory}\n </p>\n </div>\n )}\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n )}\n\n {/* Metadata: Added / Updated */}\n {(app.createdAt || app.freshness?.lastCheckedAt) && (\n <div className=\"mt-6 flex flex-wrap gap-x-5 gap-y-1\">\n {app.createdAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={app.createdAt}\n >\n <CalendarPlus className=\"size-3\" />\n Added {formatRelativeTime(app.createdAt)}\n </span>\n )}\n {app.freshness?.lastCheckedAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={\n app.freshness.lastContentChangeAt\n ? `Content last changed ${app.freshness.lastContentChangeAt} · last checked ${app.freshness.lastCheckedAt}`\n : `Last checked ${app.freshness.lastCheckedAt}`\n }\n >\n <Clock className=\"size-3\" />\n {/* \"Updated\" means the content changed, not that a check ran -\n entries predating the field have none, so fall back. */}\n Updated{' '}\n {formatRelativeTime(\n app.freshness.lastContentChangeAt ??\n app.freshness.lastCheckedAt,\n )}\n {app.freshness.isStale && (\n <span className=\"italic opacity-75\">\n {' '}\n · may be out of date\n </span>\n )}\n </span>\n )}\n </div>\n )}\n\n {/* Sources */}\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Sources</h3>\n {isAdmin ? (\n <>\n <ul className=\"space-y-2\">\n {displaySources.map((url, index) => {\n const isDraft =\n draftSource !== null && index === sourceUrls.length\n return (\n <li\n key={isDraft ? 'draft' : `${index}-${url}`}\n className=\"group flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n <InlineEditableField\n value={url}\n initialEditMode={isDraft}\n onCancel={\n isDraft ? () => setDraftSource(null) : undefined\n }\n onSave={(newUrl) => {\n if (isDraft) {\n setDraftSource(null)\n if (newUrl) {\n updateApp.mutate({\n id: app.id,\n data: { sources: [...sourceUrls, newUrl] },\n })\n }\n } else {\n const next = [...sourceUrls]\n next[index] = newUrl\n updateApp.mutate({\n id: app.id,\n data: { sources: next.filter(Boolean) },\n })\n }\n }}\n placeholder=\"https://...\"\n viewClassName=\"flex-1 min-w-0\"\n renderView={(val) =>\n val ? (\n <a\n href={val}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex items-center gap-1 truncate\"\n >\n {displayUrl(val)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n {!isDraft && pulseByUrl.has(url) && (\n <SourcePulse\n pulse={pulseByUrl.get(url)!}\n className=\"ml-auto\"\n />\n )}\n {!isDraft && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label=\"Remove source\"\n className=\"shrink-0 text-muted-foreground hover:text-destructive\"\n onClick={() => {\n const next = sourceUrls.filter(\n (_, i) => i !== index,\n )\n updateApp.mutate({\n id: app.id,\n data: { sources: next },\n })\n }}\n >\n <Trash2 className=\"size-3.5\" />\n </Button>\n )}\n </li>\n )\n })}\n </ul>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"mt-2 gap-1 text-muted-foreground\"\n onClick={() => setDraftSource('')}\n >\n <Plus className=\"size-3.5\" />\n Add source\n </Button>\n </>\n ) : (\n <ul className=\"space-y-2\">\n {sourceUrls.map((url, index) => (\n // `group` so hovering anywhere on the row fades that source's\n // track in — the mark sits in a right-hand column, and row hover\n // is what ties the two ends of a wide row together.\n <li\n key={index}\n className=\"group flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n {url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex min-w-0 items-center gap-1 truncate\"\n >\n {displayUrl(url)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n {pulseByUrl.has(url) && (\n <SourcePulse\n pulse={pulseByUrl.get(url)!}\n className=\"ml-auto\"\n />\n )}\n </li>\n ))}\n </ul>\n )}\n </div>\n\n {/* Freshness now shown near the header (see \"Updated …\" above). */}\n\n <CommentsSection appSlug={app.slug} />\n </div>\n\n {/* Screenshot Gallery Dialog */}\n <ScreenshotGallery\n app={app}\n screenshotIds={app.screenshotIds || []}\n open={isGalleryOpen}\n onOpenChange={setIsGalleryOpen}\n initialIndex={galleryInitialIndex}\n title={`${app.abbreviation || app.displayName} - Screenshots`}\n />\n </>\n )\n}\n"],"names":["React","_a"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"AppDetails.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/AppDetails.tsx"],"sourcesContent":["import type {\n Resource,\n SourceReference,\n} from '@igstack/app-catalog-backend-core'\nimport {\n AppWindow,\n CalendarPlus,\n Clock,\n ExternalLink,\n Plus,\n Trash2,\n} from 'lucide-react'\nimport React from 'react'\nimport { useHotkeys } from 'react-hotkeys-hook'\nimport { cn } from '~/lib/utils'\nimport { Badge } from '~/ui/badge'\nimport { Button } from '~/ui/button'\nimport {\n Accordion,\n AccordionContent,\n AccordionItem,\n AccordionTrigger,\n} from '~/ui/accordion'\nimport { AccessRequestSection } from '../components/AccessRequestSection'\nimport { AccessPrerequisiteChain } from '../components/AccessPrerequisiteChain'\nimport {\n LEAP,\n LEAVE_FILL,\n QUIET_FILL,\n QuickJumpBar,\n hasQuickJumps,\n} from '../components/QuickJumpBar'\nimport { useAppCatalogFilters } from '../context/AppCatalogFiltersContext'\nimport { PersonBadge } from '../components/PersonBadge'\nimport { useUser } from '~/modules/auth'\nimport { InlineEditableField } from '../components/InlineEditableField'\nimport { MarkdownText } from '../components/MarkdownText'\nimport { ScreenshotGallery } from '../components/ScreenshotGallery'\nimport { SourcePulse } from '../components/SourcePulse'\nimport { useUpdateApp } from '../../hooks/useUpdateApp'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\nimport { TierVariantsSection } from '../components/TierVariantsSection'\nimport { SubResourcesSection } from '../components/SubResourcesSection'\nimport { getChildResources } from '../../utils/resolveHelpers'\nimport { CommentsSection } from './CommentsSection'\nimport { displayUrl } from '~/modules/appCatalog/utils/displayUrl'\n\nfunction getIconUrl(iconName: string): string {\n return `/api/icons/${iconName}`\n}\n\nfunction AppIcon({ app, className }: { app: Resource; className?: string }) {\n const [imageError, setImageError] = React.useState(false)\n\n // Use iconName from backend if available\n if (app.iconName && !imageError) {\n return (\n <div className={cn('size-12 shrink-0', className)}>\n <img\n src={getIconUrl(app.iconName)}\n alt={`${app.abbreviation || app.displayName} icon`}\n className=\"size-12 rounded-lg object-contain\"\n onError={() => setImageError(true)}\n />\n </div>\n )\n }\n\n // Fallback icon\n return (\n <div\n className={cn(\n 'flex items-center justify-center rounded-lg bg-primary/10 text-primary size-12 shrink-0',\n className,\n )}\n >\n <AppWindow className=\"size-6\" />\n </div>\n )\n}\n\nfunction AppScreenshot({ app }: { app: Resource }) {\n const [imageError, setImageError] = React.useState(false)\n const [isLoadingImage, setIsLoadingImage] = React.useState(true)\n\n // Check if app has screenshots\n const screenshotId = app.screenshotIds?.[0]\n if (!screenshotId) {\n return (\n <div className=\"w-full bg-muted/50 rounded-lg overflow-hidden flex items-center justify-center min-h-64\">\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n No screenshot available\n </div>\n </div>\n )\n }\n\n const screenshotImageUrl = `/api/screenshots/${screenshotId}?size=512`\n\n return (\n <div className=\"w-full flex justify-center\">\n <div className=\"rounded-lg overflow-hidden inline-flex items-center justify-center min-h-64\">\n {!imageError ? (\n <img\n src={screenshotImageUrl}\n alt={`${app.abbreviation || app.displayName} screenshot`}\n className=\"h-64 object-contain\"\n onError={() => {\n setImageError(true)\n setIsLoadingImage(false)\n }}\n onLoad={() => setIsLoadingImage(false)}\n />\n ) : null}\n {(imageError || isLoadingImage) && (\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n {isLoadingImage\n ? 'Loading screenshot...'\n : 'No screenshot available'}\n </div>\n )}\n </div>\n </div>\n )\n}\n\nfunction TiersAndSubResourcesPanel({ app }: { app: Resource }) {\n const { resources } = useAppCatalogContext()\n // #38 item C: the active catalog search term. When the user reached this app\n // by searching something that matched a child, seed the sub-resource filter\n // with it so the matched child is revealed.\n const { state: filterState } = useAppCatalogFilters()\n const appSubResources = React.useMemo(\n () => getChildResources(resources, app.slug),\n [resources, app.slug],\n )\n\n return (\n <>\n {app.tiers && app.tiers.length > 0 && (\n <div className=\"mt-6\">\n <TierVariantsSection tiers={app.tiers} />\n </div>\n )}\n {appSubResources.length > 0 && (\n <div className=\"mt-6\">\n <SubResourcesSection\n subResources={appSubResources}\n parentSlug={app.slug}\n initialSearch={filterState.searchValue}\n />\n </div>\n )}\n </>\n )\n}\n\nexport function AppDetails({\n app,\n onAppClick,\n onClosePanel,\n}: {\n app: Resource\n onAppClick?: (app: Resource) => void\n onClosePanel: () => void\n}) {\n const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)\n const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)\n const { approvalMethods, resources: allResources } = useAppCatalogContext()\n const { recordClick } = useAppClickHistory()\n const updateApp = useUpdateApp()\n const [draftSource, setDraftSource] = React.useState<string | null>(null)\n const user = useUser()\n const isAdmin = user?.isAdmin ?? false\n\n const displayTags = (app.tags ?? []).filter((tag) => !tag.includes(':'))\n const sourceUrls: string[] =\n app.sources?.map((s) => (typeof s === 'string' ? s : s.url)) ?? []\n const displaySources =\n draftSource !== null ? [...sourceUrls, draftSource] : sourceUrls\n // The list renders bare URLs (they are editable as text), so the pulse has to\n // be looked up rather than carried alongside. Absent until the autoupdate loop\n // has seen the source — a freshly added one simply gets no mark.\n const pulseByUrl = React.useMemo(() => {\n const byUrl = new Map<string, NonNullable<SourceReference['pulse']>>()\n for (const source of app.sources ?? []) {\n if (typeof source !== 'string' && source.pulse) {\n byUrl.set(source.url, source.pulse)\n }\n }\n return byUrl\n }, [app.sources])\n\n // Enter: open screenshot gallery\n useHotkeys(\n 'enter',\n () => {\n const tag = document.activeElement?.tagName\n if (\n tag === 'BUTTON' ||\n tag === 'A' ||\n tag === 'INPUT' ||\n tag === 'SELECT' ||\n tag === 'TEXTAREA'\n )\n return\n\n if (app.screenshotIds && app.screenshotIds.length > 0) {\n setGalleryInitialIndex(0)\n setIsGalleryOpen(true)\n }\n },\n { enabled: !isGalleryOpen },\n [app, isGalleryOpen],\n )\n\n // Esc: close the details panel (only when gallery is NOT open)\n useHotkeys(\n 'escape',\n () => {\n onClosePanel()\n },\n { enabled: !isGalleryOpen },\n [isGalleryOpen, onClosePanel],\n )\n\n const handleScreenshotClick = (index: number) => {\n setGalleryInitialIndex(index)\n setIsGalleryOpen(true)\n }\n\n // Find replacement app if deprecated\n const replacementApp = app.deprecated?.replacementSlug\n ? allResources.find((a) => a.slug === app.deprecated?.replacementSlug)\n : null\n\n return (\n <>\n <div className=\"flex h-full flex-col p-6\">\n {/* Icon and Title — always first: a pinned Quick Jump takes order -1,\n so the header claims -2 rather than being pushed below it. */}\n <div className=\"-order-2 border-b pb-6\">\n <div className=\"flex items-center gap-3\">\n <AppIcon app={app} className=\"size-16\" />\n <div className=\"-mx-3 flex-1 min-w-0\">\n <div className=\"flex items-center gap-2 px-3\">\n <div className=\"font-serif text-2xl font-semibold min-w-0\">\n {app.abbreviation\n ? `${app.displayName} (${app.abbreviation})`\n : app.displayName}\n </div>\n {app.deprecated && (\n <Badge\n variant={\n app.deprecated.type === 'discouraged'\n ? 'secondary'\n : 'destructive'\n }\n >\n {app.deprecated.type === 'discouraged'\n ? 'Discouraged'\n : 'Deprecated'}\n </Badge>\n )}\n </div>\n {isAdmin && (\n <div className=\"mt-1 px-3\">\n <span className=\"text-xs text-muted-foreground mr-2\">\n Slug:\n </span>\n <InlineEditableField\n value={app.slug}\n onSave={(slug) =>\n updateApp.mutate({ id: app.id, data: { slug } })\n }\n className=\"text-sm\"\n />\n </div>\n )}\n {/* Open button, then Quick Jump: the host you reach by pressing\n it, and the deep link you reach by pasting an id. */}\n <div className=\"mt-3 flex flex-wrap items-stretch gap-3 px-3\">\n {isAdmin ? (\n <InlineEditableField\n value={app.appUrl ?? ''}\n onSave={(appUrl) =>\n updateApp.mutate({ id: app.id, data: { appUrl } })\n }\n placeholder=\"App URL\"\n renderView={(url) =>\n url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n onClick={() => recordClick(app.slug)}\n className=\"inline-flex items-center gap-1 rounded-md py-1 text-sm text-blue-600 hover:bg-accent/30 hover:underline dark:text-blue-400 transition-all\"\n >\n {displayUrl(url)}\n <ExternalLink className=\"size-3.5 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n ) : app.appUrl ? (\n <a\n href={app.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n onClick={() => recordClick(app.slug)}\n className={cn(\n // The row's one inset and icon gap, and its height stated\n // rather than left to the taller neighbour.\n 'inline-flex min-h-[34px] items-center gap-1.5 rounded-md px-3 text-sm font-medium shadow-sm',\n // Only ever ONE solid brand fill in this row: it goes to\n // Quick Jump's submit when there is one, because that is\n // the control that acts on something the user typed.\n hasQuickJumps(app) ? QUIET_FILL : LEAVE_FILL,\n LEAP,\n )}\n title={displayUrl(app.appUrl)}\n aria-label={`Open ${app.displayName} in a new tab (${displayUrl(app.appUrl)})`}\n >\n {/* \"Go to <host>\": the verb makes it a thing you press and\n the host says where to. The glyph adds \"in a new tab\". */}\n <span className=\"max-w-[280px] truncate whitespace-nowrap\">\n Go to {displayUrl(app.appUrl)}\n </span>\n <ExternalLink className=\"size-3.5 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n <QuickJumpBar app={app} />\n </div>\n {/* Updated/Added metadata moved to consolidated section before Sources (#55) */}\n </div>\n </div>\n </div>\n\n {/* Deprecation/Discouraged Warning */}\n {app.deprecated &&\n (() => {\n const deprecationType = app.deprecated.type || 'deprecated'\n const isDiscouraged = deprecationType === 'discouraged'\n return (\n <div\n className={\n isDiscouraged\n ? 'mt-6 p-4 border border-yellow-500/50 rounded-lg bg-yellow-50 dark:bg-yellow-950/20'\n : 'mt-6 p-4 border border-destructive/50 rounded-lg bg-destructive/10'\n }\n >\n <h3\n className={\n isDiscouraged\n ? 'text-sm font-semibold text-yellow-700 dark:text-yellow-500 mb-2'\n : 'text-sm font-semibold text-destructive mb-2'\n }\n >\n {isDiscouraged\n ? 'Usage discouraged'\n : 'This application is deprecated'}\n </h3>\n <p className=\"text-sm text-muted-foreground mb-3\">\n {app.deprecated.comment}\n </p>\n {replacementApp && (\n <button\n type=\"button\"\n onClick={() => onAppClick?.(replacementApp)}\n className=\"text-sm font-medium text-primary hover:underline inline-flex items-center gap-1\"\n >\n View replacement: {replacementApp.displayName}\n <ExternalLink className=\"size-3\" />\n </button>\n )}\n </div>\n )\n })()}\n\n {/* Description */}\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Description</h3>\n {isAdmin ? (\n <InlineEditableField\n value={app.description ?? ''}\n onSave={(description) =>\n updateApp.mutate({ id: app.id, data: { description } })\n }\n multiline\n placeholder=\"Description\"\n className=\"min-h-[4rem] resize-y text-sm text-muted-foreground\"\n />\n ) : app.description ? (\n <MarkdownText className=\"prose prose-sm max-w-none text-sm text-muted-foreground [&_p]:m-0\">\n {app.description}\n </MarkdownText>\n ) : (\n <p className=\"text-sm text-muted-foreground\">—</p>\n )}\n </div>\n\n {/* Two-step access (#38 D): for a nested resource, show the parent-first\n prerequisite chain before this resource's own access instructions. */}\n <AccessPrerequisiteChain resource={app} onOpenParent={onAppClick} />\n\n {/* Access Request Section — what the app is comes first, then how to\n get into it. */}\n <AccessRequestSection app={app} approvalMethods={approvalMethods} />\n\n {/* Screenshots - Clickable preview */}\n {app.screenshotIds && app.screenshotIds.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">\n Screenshots ({app.screenshotIds.length})\n </h3>\n {/* A button, not a clickable div: the gallery was unreachable by\n keyboard, and a role + label gives it a stable handle. */}\n <button\n type=\"button\"\n aria-label={`View screenshots of ${app.displayName}`}\n className=\"block w-full cursor-pointer text-left hover:opacity-80 transition-opacity\"\n onClick={() => handleScreenshotClick(0)}\n >\n <AppScreenshot app={app} />\n {app.screenshotIds.length > 1 && (\n <p className=\"text-xs text-muted-foreground mt-2 text-center\">\n Click to view all {app.screenshotIds.length} screenshots\n </p>\n )}\n </button>\n </div>\n )}\n\n {/* Owner — who is responsible for this resource. Distinct from the\n access approver (who decides access requests); see domain model. */}\n {app.ownerPersonSlug && (\n <div className=\"mt-6\">\n <h3 className=\"mb-1 text-sm font-medium\">Owner</h3>\n <p className=\"mb-2 text-xs text-muted-foreground\">\n Who is responsible for this resource\n </p>\n <PersonBadge slug={app.ownerPersonSlug} />\n </div>\n )}\n\n {/* Tier Variants and Sub-Resources */}\n <TiersAndSubResourcesPanel app={app} />\n\n {/* Links */}\n {app.links && app.links.length > 0 && (\n <div className=\"mt-4\">\n <h3 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n Links\n </h3>\n <div className=\"space-y-0.5\">\n {app.links.map((link) => (\n <a\n key={link.url}\n href={link.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"flex items-center gap-1 text-xs text-muted-foreground hover:text-primary truncate\"\n >\n <ExternalLink className=\"size-3 shrink-0\" />\n {link.title || displayUrl(link.url)}\n </a>\n ))}\n </div>\n </div>\n )}\n\n {/* Tags, minus the indexing machinery: `namespace:value` tags drive\n grouping, faceting and placement, are about half of all tag\n references, and say nothing about what an app is for. The colon is\n the test. Search still matches them — only the display drops them. */}\n {displayTags.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Tags</h3>\n <div className=\"flex flex-wrap gap-2\">\n {displayTags.map((tag) => (\n <Badge key={tag} variant=\"secondary\" className=\"text-xs\">\n {tag}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Teams */}\n {app.teams && app.teams.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Teams</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.teams.map((team) => (\n <Badge key={team} variant=\"outline\" className=\"text-xs\">\n {team}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Technical information: AI-facing fields, de-emphasized/collapsed */}\n {(app.aiPrompt || app.aiMemory) && (\n <Accordion type=\"single\" collapsible className=\"mt-6\">\n <AccordionItem\n value=\"technical-info\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Technical information\n </AccordionTrigger>\n <AccordionContent className=\"pb-3 space-y-3\">\n {app.aiPrompt && (\n <div>\n <h4 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n AI Prompt\n </h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiPrompt}\n </p>\n </div>\n )}\n {app.aiMemory && (\n <div>\n <h4 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n AI Memory\n </h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiMemory}\n </p>\n </div>\n )}\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n )}\n\n {/* Metadata: Added / Updated */}\n {(app.createdAt || app.freshness?.lastCheckedAt) && (\n <div className=\"mt-6 flex flex-wrap gap-x-5 gap-y-1\">\n {app.createdAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={app.createdAt}\n >\n <CalendarPlus className=\"size-3\" />\n Added {formatRelativeTime(app.createdAt)}\n </span>\n )}\n {app.freshness?.lastCheckedAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={\n app.freshness.lastContentChangeAt\n ? `Content last changed ${app.freshness.lastContentChangeAt} · last checked ${app.freshness.lastCheckedAt}`\n : `Last checked ${app.freshness.lastCheckedAt}`\n }\n >\n <Clock className=\"size-3\" />\n {/* \"Updated\" means the content changed, not that a check ran -\n entries predating the field have none, so fall back. */}\n Updated{' '}\n {formatRelativeTime(\n app.freshness.lastContentChangeAt ??\n app.freshness.lastCheckedAt,\n )}\n {app.freshness.isStale && (\n <span className=\"italic opacity-75\">\n {' '}\n · may be out of date\n </span>\n )}\n </span>\n )}\n </div>\n )}\n\n {/* Sources */}\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Sources</h3>\n {isAdmin ? (\n <>\n <ul className=\"space-y-2\">\n {displaySources.map((url, index) => {\n const isDraft =\n draftSource !== null && index === sourceUrls.length\n return (\n <li\n key={isDraft ? 'draft' : `${index}-${url}`}\n className=\"group flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n <InlineEditableField\n value={url}\n initialEditMode={isDraft}\n onCancel={\n isDraft ? () => setDraftSource(null) : undefined\n }\n onSave={(newUrl) => {\n if (isDraft) {\n setDraftSource(null)\n if (newUrl) {\n updateApp.mutate({\n id: app.id,\n data: { sources: [...sourceUrls, newUrl] },\n })\n }\n } else {\n const next = [...sourceUrls]\n next[index] = newUrl\n updateApp.mutate({\n id: app.id,\n data: { sources: next.filter(Boolean) },\n })\n }\n }}\n placeholder=\"https://...\"\n viewClassName=\"flex-1 min-w-0\"\n renderView={(val) =>\n val ? (\n <a\n href={val}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex items-center gap-1 truncate\"\n >\n {displayUrl(val)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\n }\n />\n {!isDraft && pulseByUrl.has(url) && (\n <SourcePulse\n pulse={pulseByUrl.get(url)!}\n className=\"ml-auto\"\n />\n )}\n {!isDraft && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n aria-label=\"Remove source\"\n className=\"shrink-0 text-muted-foreground hover:text-destructive\"\n onClick={() => {\n const next = sourceUrls.filter(\n (_, i) => i !== index,\n )\n updateApp.mutate({\n id: app.id,\n data: { sources: next },\n })\n }}\n >\n <Trash2 className=\"size-3.5\" />\n </Button>\n )}\n </li>\n )\n })}\n </ul>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"mt-2 gap-1 text-muted-foreground\"\n onClick={() => setDraftSource('')}\n >\n <Plus className=\"size-3.5\" />\n Add source\n </Button>\n </>\n ) : (\n <ul className=\"space-y-2\">\n {sourceUrls.map((url, index) => (\n // `group` so hovering anywhere on the row fades that source's\n // track in — the mark sits in a right-hand column, and row hover\n // is what ties the two ends of a wide row together.\n <li\n key={index}\n className=\"group flex items-center gap-2 text-xs\"\n >\n <span className=\"text-muted-foreground shrink-0 tabular-nums\">\n {index + 1}.\n </span>\n {url ? (\n <a\n href={url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"hover:text-primary inline-flex min-w-0 items-center gap-1 truncate\"\n >\n {displayUrl(url)}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n {pulseByUrl.has(url) && (\n <SourcePulse\n pulse={pulseByUrl.get(url)!}\n className=\"ml-auto\"\n />\n )}\n </li>\n ))}\n </ul>\n )}\n </div>\n\n {/* Freshness now shown near the header (see \"Updated …\" above). */}\n\n <CommentsSection appSlug={app.slug} />\n </div>\n\n {/* Screenshot Gallery Dialog */}\n <ScreenshotGallery\n app={app}\n screenshotIds={app.screenshotIds || []}\n open={isGalleryOpen}\n onOpenChange={setIsGalleryOpen}\n initialIndex={galleryInitialIndex}\n title={`${app.abbreviation || app.displayName} - Screenshots`}\n />\n </>\n )\n}\n"],"names":["React","_a"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "4.0.0-alpha-
|
|
3
|
+
"version": "4.0.0-alpha-20260920161438",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -113,7 +113,7 @@
|
|
|
113
113
|
"tailwindcss": "^4.1.7",
|
|
114
114
|
"tiptap-markdown": "^0.8.0",
|
|
115
115
|
"tslib": "^2.3.0",
|
|
116
|
-
"@igstack/app-catalog-shared-core": "4.0.0-alpha-
|
|
116
|
+
"@igstack/app-catalog-shared-core": "4.0.0-alpha-20260920161438"
|
|
117
117
|
},
|
|
118
118
|
"devDependencies": {
|
|
119
119
|
"@tailwindcss/vite": "^4.0.5",
|
|
@@ -140,7 +140,7 @@
|
|
|
140
140
|
"vite-plugin-static-copy": "^3.1.4",
|
|
141
141
|
"vite-plugin-svgr": "^4.2.0",
|
|
142
142
|
"vitest": "^4.1.2",
|
|
143
|
-
"@igstack/app-catalog-backend-core": "4.0.0-alpha-
|
|
143
|
+
"@igstack/app-catalog-backend-core": "4.0.0-alpha-20260920161438"
|
|
144
144
|
},
|
|
145
145
|
"peerDependencies": {
|
|
146
146
|
"react": "19.1.2",
|
|
@@ -149,7 +149,7 @@
|
|
|
149
149
|
"vite": "^6.3.5",
|
|
150
150
|
"vite-plugin-svgr": "^4.2.0"
|
|
151
151
|
},
|
|
152
|
-
"gitHead": "
|
|
152
|
+
"gitHead": "1e86c9e0dbe819e81290d5b3c2f0ae7c576731e8",
|
|
153
153
|
"scripts": {
|
|
154
154
|
"build": "vite build",
|
|
155
155
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import '@testing-library/jest-dom/vitest'
|
|
5
|
+
|
|
6
|
+
// The chosen destination lives in the url; the router is not what this test is
|
|
7
|
+
// about, so the hook is reduced to local state.
|
|
8
|
+
vi.mock('~/modules/appCatalog/hooks/useUrlSyncedState', () => ({
|
|
9
|
+
useUrlSyncedState: vi.fn(() => ['', vi.fn()]),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
const { QuickJumpBar } =
|
|
13
|
+
await import('~/modules/appCatalog/ui/components/QuickJumpBar')
|
|
14
|
+
|
|
15
|
+
const app = (quickJumps: unknown[]) =>
|
|
16
|
+
({
|
|
17
|
+
id: 'tracker',
|
|
18
|
+
slug: 'tracker',
|
|
19
|
+
displayName: 'Tracker',
|
|
20
|
+
appUrl: 'https://tracker.example.com',
|
|
21
|
+
quickJumps,
|
|
22
|
+
}) as Resource
|
|
23
|
+
|
|
24
|
+
// Opening a card is a deliberate act, so the one thing in it you can act on
|
|
25
|
+
// takes the caret — you type the id without reaching for the mouse.
|
|
26
|
+
describe('QuickJumpBar — focus on open', () => {
|
|
27
|
+
it('focuses the identifier field', () => {
|
|
28
|
+
render(
|
|
29
|
+
<QuickJumpBar
|
|
30
|
+
app={app([
|
|
31
|
+
{
|
|
32
|
+
identity: 'case id',
|
|
33
|
+
title: 'Tracker — view case',
|
|
34
|
+
url: '{{baseHost}}/case/{{value}}',
|
|
35
|
+
},
|
|
36
|
+
])}
|
|
37
|
+
/>,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
expect(screen.getByLabelText('case id')).toHaveFocus()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('leaves focus alone on a coarse pointer', () => {
|
|
44
|
+
// Focusing there pops the virtual keyboard over half the panel.
|
|
45
|
+
vi.stubGlobal('matchMedia', (query: string) => ({
|
|
46
|
+
matches: query.includes('coarse'),
|
|
47
|
+
media: query,
|
|
48
|
+
addEventListener: vi.fn(),
|
|
49
|
+
removeEventListener: vi.fn(),
|
|
50
|
+
}))
|
|
51
|
+
|
|
52
|
+
render(
|
|
53
|
+
<QuickJumpBar
|
|
54
|
+
app={app([
|
|
55
|
+
{
|
|
56
|
+
identity: 'case id',
|
|
57
|
+
title: 'Tracker — view case',
|
|
58
|
+
url: '{{baseHost}}/case/{{value}}',
|
|
59
|
+
},
|
|
60
|
+
])}
|
|
61
|
+
/>,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
expect(screen.getByLabelText('case id')).not.toHaveFocus()
|
|
65
|
+
vi.unstubAllGlobals()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import '@testing-library/jest-dom/vitest'
|
|
5
|
+
|
|
6
|
+
// Same stubs as the section-order test: AppDetails drags the whole panel data
|
|
7
|
+
// layer in and none of it decides which tags are shown.
|
|
8
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
9
|
+
useSearch: vi.fn(() => ({})),
|
|
10
|
+
useNavigate: vi.fn(() => vi.fn()),
|
|
11
|
+
useRouter: vi.fn(() => ({
|
|
12
|
+
state: { location: { pathname: '/', search: {} } },
|
|
13
|
+
})),
|
|
14
|
+
Link: ({ children, ...rest }: { children?: React.ReactNode }) => (
|
|
15
|
+
<a {...rest}>{children}</a>
|
|
16
|
+
),
|
|
17
|
+
}))
|
|
18
|
+
vi.mock('~/modules/appCatalog/context/AppCatalogContext', () => ({
|
|
19
|
+
useAppCatalogContext: vi.fn(() => ({ approvalMethods: [], resources: [] })),
|
|
20
|
+
}))
|
|
21
|
+
vi.mock('~/modules/appCatalog/hooks/useAppClickHistory', () => ({
|
|
22
|
+
useAppClickHistory: vi.fn(() => ({ recordClick: vi.fn() })),
|
|
23
|
+
}))
|
|
24
|
+
vi.mock('~/modules/appCatalog/hooks/useUpdateApp', () => ({
|
|
25
|
+
useUpdateApp: vi.fn(() => ({ mutate: vi.fn() })),
|
|
26
|
+
}))
|
|
27
|
+
vi.mock('~/modules/appCatalog/ui/context/AppCatalogFiltersContext', () => ({
|
|
28
|
+
useAppCatalogFilters: vi.fn(() => ({ selectedTags: [], toggleTag: vi.fn() })),
|
|
29
|
+
}))
|
|
30
|
+
vi.mock('~/modules/auth', () => ({ useUser: vi.fn(() => null) }))
|
|
31
|
+
vi.mock('~/modules/appCatalog/ui/detail/CommentsSection', () => ({
|
|
32
|
+
CommentsSection: () => <div />,
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
const { AppDetails } = await import('~/modules/appCatalog/ui/detail/AppDetails')
|
|
36
|
+
|
|
37
|
+
const app = (tags: string[]) =>
|
|
38
|
+
({
|
|
39
|
+
id: 'alation',
|
|
40
|
+
slug: 'alation',
|
|
41
|
+
displayName: 'Alation',
|
|
42
|
+
description: 'Data catalog for analysts.',
|
|
43
|
+
tags,
|
|
44
|
+
}) as Resource
|
|
45
|
+
|
|
46
|
+
// `namespace:value` tags are indexing machinery — grouping, faceting, placement.
|
|
47
|
+
// They are roughly half of all tag references and say nothing about what an app
|
|
48
|
+
// is for, so the colon is the test for "not for the reader".
|
|
49
|
+
describe('AppDetails — tags', () => {
|
|
50
|
+
it('shows plain tags and hides namespaced ones', () => {
|
|
51
|
+
render(
|
|
52
|
+
<AppDetails
|
|
53
|
+
app={app([
|
|
54
|
+
'category:project-management',
|
|
55
|
+
'jira',
|
|
56
|
+
'placement:day-to-day',
|
|
57
|
+
])}
|
|
58
|
+
onClosePanel={vi.fn()}
|
|
59
|
+
/>,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
expect(screen.getByText('jira')).toBeInTheDocument()
|
|
63
|
+
expect(screen.queryByText('category:project-management')).toBeNull()
|
|
64
|
+
expect(screen.queryByText('placement:day-to-day')).toBeNull()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('drops the whole section when every tag is namespaced', () => {
|
|
68
|
+
render(
|
|
69
|
+
<AppDetails
|
|
70
|
+
app={app(['category:internal', 'team:platform'])}
|
|
71
|
+
onClosePanel={vi.fn()}
|
|
72
|
+
/>,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// An empty "Tags" heading is worse than no heading: it reads as missing data.
|
|
76
|
+
expect(screen.queryByRole('heading', { name: 'Tags' })).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -154,6 +154,19 @@ export function QuickJumpBar({ app }: { app: Resource }) {
|
|
|
154
154
|
// A typed id belongs to the app it was typed on.
|
|
155
155
|
React.useEffect(() => setValue(''), [jumps])
|
|
156
156
|
|
|
157
|
+
// Opening a card is a deliberate act, and this field is the one thing in the
|
|
158
|
+
// header you act on by typing — so it takes the caret, and you can open an app
|
|
159
|
+
// and enter an id without reaching for the mouse. Keyed on the app's jumps, so
|
|
160
|
+
// it re-applies when you move to another app inside the same mounted panel.
|
|
161
|
+
//
|
|
162
|
+
// `preventScroll`, or focusing an element inside the scrollable panel scrolls
|
|
163
|
+
// the panel to it. Not on a coarse pointer: there focus pops the virtual
|
|
164
|
+
// keyboard over half of what you just opened.
|
|
165
|
+
React.useEffect(() => {
|
|
166
|
+
if (window.matchMedia('(pointer: coarse)').matches) return
|
|
167
|
+
inputRef.current?.focus({ preventScroll: true })
|
|
168
|
+
}, [jumps])
|
|
169
|
+
|
|
157
170
|
// A `?qj=` naming another app's jump would silently resolve to this app's
|
|
158
171
|
// first one, leaving the url claiming something untrue.
|
|
159
172
|
React.useEffect(() => {
|
|
@@ -175,6 +175,7 @@ export function AppDetails({
|
|
|
175
175
|
const user = useUser()
|
|
176
176
|
const isAdmin = user?.isAdmin ?? false
|
|
177
177
|
|
|
178
|
+
const displayTags = (app.tags ?? []).filter((tag) => !tag.includes(':'))
|
|
178
179
|
const sourceUrls: string[] =
|
|
179
180
|
app.sources?.map((s) => (typeof s === 'string' ? s : s.url)) ?? []
|
|
180
181
|
const displaySources =
|
|
@@ -474,12 +475,15 @@ export function AppDetails({
|
|
|
474
475
|
</div>
|
|
475
476
|
)}
|
|
476
477
|
|
|
477
|
-
{/* Tags
|
|
478
|
-
|
|
478
|
+
{/* Tags, minus the indexing machinery: `namespace:value` tags drive
|
|
479
|
+
grouping, faceting and placement, are about half of all tag
|
|
480
|
+
references, and say nothing about what an app is for. The colon is
|
|
481
|
+
the test. Search still matches them — only the display drops them. */}
|
|
482
|
+
{displayTags.length > 0 && (
|
|
479
483
|
<div className="mt-6">
|
|
480
484
|
<h3 className="mb-2 text-sm font-medium">Tags</h3>
|
|
481
485
|
<div className="flex flex-wrap gap-2">
|
|
482
|
-
{
|
|
486
|
+
{displayTags.map((tag) => (
|
|
483
487
|
<Badge key={tag} variant="secondary" className="text-xs">
|
|
484
488
|
{tag}
|
|
485
489
|
</Badge>
|