@igstack/app-catalog-frontend-core 0.4.0-alpha-20260807174540 → 0.4.0-alpha-20260807223013

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.
@@ -1,5 +1,11 @@
1
1
  interface PersonBadgeProps {
2
2
  slug: string;
3
3
  }
4
+ /**
5
+ * Person chip. Clicking it opens a small popover exposing BOTH the full name
6
+ * and the email, each with its own copy button — so the user picks what to copy
7
+ * (previously a single button copied only the email). Email also links to
8
+ * mailto:. Falls back to just the name (no popover) when no email is known.
9
+ */
4
10
  export declare function PersonBadge({ slug }: PersonBadgeProps): import("react/jsx-runtime").JSX.Element;
5
11
  export {};
@@ -1,7 +1,8 @@
1
1
  import { jsxs, jsx } from "react/jsx-runtime";
2
- import { User, Check, Copy } from "lucide-react";
2
+ import { User, Copy, Mail, Check } from "lucide-react";
3
3
  import { useState, useRef, useEffect, useCallback } from "react";
4
4
  import { Badge } from "../../../../ui/badge.js";
5
+ import { Popover, PopoverTrigger, PopoverContent } from "../../../../ui/popover.js";
5
6
  import { useAppCatalogContext } from "../../context/AppCatalogContext.js";
6
7
  import "../context/AppCatalogFiltersContext.js";
7
8
  import "@tanstack/react-router";
@@ -21,48 +22,111 @@ function PersonBadge({ slug }) {
21
22
  const person = getPersonBySlug(persons, slug);
22
23
  const displayName = person ? `${person.firstName} ${person.lastName}`.trim() || slug : slug;
23
24
  const email = person == null ? void 0 : person.email;
24
- return /* @__PURE__ */ jsxs(
25
- Badge,
26
- {
27
- variant: "outline",
28
- className: "font-normal inline-flex items-center gap-1",
29
- title: email ? `${displayName} (${email})` : displayName,
30
- children: [
31
- /* @__PURE__ */ jsx(User, { className: "size-3" }),
32
- displayName,
33
- email && /* @__PURE__ */ jsx(CopyEmailButton, { email })
34
- ]
35
- }
36
- );
25
+ if (!email) {
26
+ return /* @__PURE__ */ jsxs(
27
+ Badge,
28
+ {
29
+ variant: "outline",
30
+ className: "font-normal inline-flex items-center gap-1",
31
+ title: displayName,
32
+ children: [
33
+ /* @__PURE__ */ jsx(User, { className: "size-3" }),
34
+ displayName
35
+ ]
36
+ }
37
+ );
38
+ }
39
+ return /* @__PURE__ */ jsxs(Popover, { children: [
40
+ /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx(
41
+ "button",
42
+ {
43
+ type: "button",
44
+ className: "inline-flex",
45
+ title: `${displayName} · ${email}`,
46
+ children: /* @__PURE__ */ jsxs(
47
+ Badge,
48
+ {
49
+ variant: "outline",
50
+ className: "font-normal inline-flex items-center gap-1 cursor-pointer hover:border-primary transition-colors",
51
+ children: [
52
+ /* @__PURE__ */ jsx(User, { className: "size-3" }),
53
+ displayName,
54
+ /* @__PURE__ */ jsx(Copy, { className: "size-3 opacity-50" })
55
+ ]
56
+ }
57
+ )
58
+ }
59
+ ) }),
60
+ /* @__PURE__ */ jsxs(PopoverContent, { align: "start", className: "w-64 p-2", children: [
61
+ /* @__PURE__ */ jsx(
62
+ CopyRow,
63
+ {
64
+ icon: /* @__PURE__ */ jsx(User, { className: "size-3.5" }),
65
+ label: "Name",
66
+ value: displayName
67
+ }
68
+ ),
69
+ /* @__PURE__ */ jsx(
70
+ CopyRow,
71
+ {
72
+ icon: /* @__PURE__ */ jsx(Mail, { className: "size-3.5" }),
73
+ label: "Email",
74
+ value: email,
75
+ href: `mailto:${email}`
76
+ }
77
+ )
78
+ ] })
79
+ ] });
37
80
  }
38
- function CopyEmailButton({ email }) {
81
+ function CopyRow({
82
+ icon,
83
+ label,
84
+ value,
85
+ href
86
+ }) {
39
87
  const [copied, setCopied] = useState(false);
40
88
  const timeoutRef = useRef(null);
41
- useEffect(() => {
42
- return () => {
89
+ useEffect(
90
+ () => () => {
43
91
  if (timeoutRef.current) clearTimeout(timeoutRef.current);
44
- };
45
- }, []);
92
+ },
93
+ []
94
+ );
46
95
  const handleCopy = useCallback(
47
96
  (e) => {
48
97
  e.stopPropagation();
49
- navigator.clipboard.writeText(email);
98
+ void navigator.clipboard.writeText(value);
50
99
  setCopied(true);
51
100
  if (timeoutRef.current) clearTimeout(timeoutRef.current);
52
101
  timeoutRef.current = setTimeout(() => setCopied(false), 2e3);
53
102
  },
54
- [email]
55
- );
56
- return /* @__PURE__ */ jsx(
57
- "button",
58
- {
59
- onClick: handleCopy,
60
- className: "ml-0.5 hover:text-primary transition-colors",
61
- title: copied ? "Copied!" : `Copy ${email}`,
62
- type: "button",
63
- children: copied ? /* @__PURE__ */ jsx(Check, { className: "size-3 text-green-600" }) : /* @__PURE__ */ jsx(Copy, { className: "size-3" })
64
- }
103
+ [value]
65
104
  );
105
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted", children: [
106
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground shrink-0", children: icon }),
107
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
108
+ /* @__PURE__ */ jsx("span", { className: "block text-[10px] uppercase tracking-wide text-muted-foreground", children: label }),
109
+ href ? /* @__PURE__ */ jsx(
110
+ "a",
111
+ {
112
+ href,
113
+ className: "block truncate text-[13px] text-primary hover:underline",
114
+ children: value
115
+ }
116
+ ) : /* @__PURE__ */ jsx("span", { className: "block truncate text-[13px]", children: value })
117
+ ] }),
118
+ /* @__PURE__ */ jsx(
119
+ "button",
120
+ {
121
+ type: "button",
122
+ onClick: handleCopy,
123
+ className: "shrink-0 rounded p-1 text-muted-foreground hover:text-primary hover:bg-background transition-colors",
124
+ title: copied ? "Copied!" : `Copy ${label.toLowerCase()}`,
125
+ "aria-label": `Copy ${label.toLowerCase()}`,
126
+ children: copied ? /* @__PURE__ */ jsx(Check, { className: "size-3.5 text-green-600" }) : /* @__PURE__ */ jsx(Copy, { className: "size-3.5" })
127
+ }
128
+ )
129
+ ] });
66
130
  }
67
131
  export {
68
132
  PersonBadge
@@ -1 +1 @@
1
- {"version":3,"file":"PersonBadge.js","sources":["../../../../../../src/modules/appCatalog/ui/components/PersonBadge.tsx"],"sourcesContent":["import { Check, Copy, User } from 'lucide-react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { Badge } from '~/ui/badge'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { getPersonBySlug } from '~/modules/appCatalog/utils/resolveHelpers'\n\ninterface PersonBadgeProps {\n slug: string\n}\n\nexport function PersonBadge({ slug }: PersonBadgeProps) {\n const { persons } = useAppCatalogContext()\n const person = getPersonBySlug(persons, slug)\n\n const displayName = person\n ? `${person.firstName} ${person.lastName}`.trim() || slug\n : slug\n\n const email = person?.email\n\n return (\n <Badge\n variant=\"outline\"\n className=\"font-normal inline-flex items-center gap-1\"\n title={email ? `${displayName} (${email})` : displayName}\n >\n <User className=\"size-3\" />\n {displayName}\n {email && <CopyEmailButton email={email} />}\n </Badge>\n )\n}\n\nfunction CopyEmailButton({ email }: { email: string }) {\n const [copied, setCopied] = useState(false)\n const timeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n useEffect(() => {\n return () => {\n if (timeoutRef.current) clearTimeout(timeoutRef.current)\n }\n }, [])\n\n const handleCopy = useCallback(\n (e: React.MouseEvent) => {\n e.stopPropagation()\n navigator.clipboard.writeText(email)\n setCopied(true)\n if (timeoutRef.current) clearTimeout(timeoutRef.current)\n timeoutRef.current = setTimeout(() => setCopied(false), 2000)\n },\n [email],\n )\n\n return (\n <button\n onClick={handleCopy}\n className=\"ml-0.5 hover:text-primary transition-colors\"\n title={copied ? 'Copied!' : `Copy ${email}`}\n type=\"button\"\n >\n {copied ? (\n <Check className=\"size-3 text-green-600\" />\n ) : (\n <Copy className=\"size-3\" />\n )}\n </button>\n )\n}\n"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;AAUO,SAAS,YAAY,EAAE,QAA0B;AACtD,QAAM,EAAE,QAAA,IAAY,qBAAA;AACpB,QAAM,SAAS,gBAAgB,SAAS,IAAI;AAE5C,QAAM,cAAc,SAChB,GAAG,OAAO,SAAS,IAAI,OAAO,QAAQ,GAAG,KAAA,KAAU,OACnD;AAEJ,QAAM,QAAQ,iCAAQ;AAEtB,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,SAAQ;AAAA,MACR,WAAU;AAAA,MACV,OAAO,QAAQ,GAAG,WAAW,KAAK,KAAK,MAAM;AAAA,MAE7C,UAAA;AAAA,QAAA,oBAAC,MAAA,EAAK,WAAU,SAAA,CAAS;AAAA,QACxB;AAAA,QACA,SAAS,oBAAC,iBAAA,EAAgB,MAAA,CAAc;AAAA,MAAA;AAAA,IAAA;AAAA,EAAA;AAG/C;AAEA,SAAS,gBAAgB,EAAE,SAA4B;AACrD,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,KAAK;AAC1C,QAAM,aAAa,OAA8B,IAAI;AAErD,YAAU,MAAM;AACd,WAAO,MAAM;AACX,UAAI,WAAW,QAAS,cAAa,WAAW,OAAO;AAAA,IACzD;AAAA,EACF,GAAG,CAAA,CAAE;AAEL,QAAM,aAAa;AAAA,IACjB,CAAC,MAAwB;AACvB,QAAE,gBAAA;AACF,gBAAU,UAAU,UAAU,KAAK;AACnC,gBAAU,IAAI;AACd,UAAI,WAAW,QAAS,cAAa,WAAW,OAAO;AACvD,iBAAW,UAAU,WAAW,MAAM,UAAU,KAAK,GAAG,GAAI;AAAA,IAC9D;AAAA,IACA,CAAC,KAAK;AAAA,EAAA;AAGR,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,SAAS;AAAA,MACT,WAAU;AAAA,MACV,OAAO,SAAS,YAAY,QAAQ,KAAK;AAAA,MACzC,MAAK;AAAA,MAEJ,UAAA,6BACE,OAAA,EAAM,WAAU,yBAAwB,IAEzC,oBAAC,MAAA,EAAK,WAAU,SAAA,CAAS;AAAA,IAAA;AAAA,EAAA;AAIjC;"}
1
+ {"version":3,"file":"PersonBadge.js","sources":["../../../../../../src/modules/appCatalog/ui/components/PersonBadge.tsx"],"sourcesContent":["import { Check, Copy, Mail, User } from 'lucide-react'\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport { Badge } from '~/ui/badge'\nimport { Popover, PopoverContent, PopoverTrigger } from '~/ui/popover'\nimport { useAppCatalogContext } from '~/modules/appCatalog'\nimport { getPersonBySlug } from '~/modules/appCatalog/utils/resolveHelpers'\n\ninterface PersonBadgeProps {\n slug: string\n}\n\n/**\n * Person chip. Clicking it opens a small popover exposing BOTH the full name\n * and the email, each with its own copy button — so the user picks what to copy\n * (previously a single button copied only the email). Email also links to\n * mailto:. Falls back to just the name (no popover) when no email is known.\n */\nexport function PersonBadge({ slug }: PersonBadgeProps) {\n const { persons } = useAppCatalogContext()\n const person = getPersonBySlug(persons, slug)\n\n const displayName = person\n ? `${person.firstName} ${person.lastName}`.trim() || slug\n : slug\n const email = person?.email\n\n // No email → nothing to pick between; render a plain, non-interactive chip.\n if (!email) {\n return (\n <Badge\n variant=\"outline\"\n className=\"font-normal inline-flex items-center gap-1\"\n title={displayName}\n >\n <User className=\"size-3\" />\n {displayName}\n </Badge>\n )\n }\n\n return (\n <Popover>\n <PopoverTrigger asChild>\n <button\n type=\"button\"\n className=\"inline-flex\"\n title={`${displayName} · ${email}`}\n >\n <Badge\n variant=\"outline\"\n className=\"font-normal inline-flex items-center gap-1 cursor-pointer hover:border-primary transition-colors\"\n >\n <User className=\"size-3\" />\n {displayName}\n <Copy className=\"size-3 opacity-50\" />\n </Badge>\n </button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className=\"w-64 p-2\">\n <CopyRow\n icon={<User className=\"size-3.5\" />}\n label=\"Name\"\n value={displayName}\n />\n <CopyRow\n icon={<Mail className=\"size-3.5\" />}\n label=\"Email\"\n value={email}\n href={`mailto:${email}`}\n />\n </PopoverContent>\n </Popover>\n )\n}\n\n/** One copyable field (label + value + copy button), value optionally a link. */\nfunction CopyRow({\n icon,\n label,\n value,\n href,\n}: {\n icon: React.ReactNode\n label: string\n value: string\n href?: string\n}) {\n const [copied, setCopied] = useState(false)\n const timeoutRef = useRef<NodeJS.Timeout | null>(null)\n\n useEffect(\n () => () => {\n if (timeoutRef.current) clearTimeout(timeoutRef.current)\n },\n [],\n )\n\n const handleCopy = useCallback(\n (e: React.MouseEvent) => {\n e.stopPropagation()\n void navigator.clipboard.writeText(value)\n setCopied(true)\n if (timeoutRef.current) clearTimeout(timeoutRef.current)\n timeoutRef.current = setTimeout(() => setCopied(false), 2000)\n },\n [value],\n )\n\n return (\n <div className=\"flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted\">\n <span className=\"text-muted-foreground shrink-0\">{icon}</span>\n <span className=\"min-w-0 flex-1\">\n <span className=\"block text-[10px] uppercase tracking-wide text-muted-foreground\">\n {label}\n </span>\n {href ? (\n <a\n href={href}\n className=\"block truncate text-[13px] text-primary hover:underline\"\n >\n {value}\n </a>\n ) : (\n <span className=\"block truncate text-[13px]\">{value}</span>\n )}\n </span>\n <button\n type=\"button\"\n onClick={handleCopy}\n className=\"shrink-0 rounded p-1 text-muted-foreground hover:text-primary hover:bg-background transition-colors\"\n title={copied ? 'Copied!' : `Copy ${label.toLowerCase()}`}\n aria-label={`Copy ${label.toLowerCase()}`}\n >\n {copied ? (\n <Check className=\"size-3.5 text-green-600\" />\n ) : (\n <Copy className=\"size-3.5\" />\n )}\n </button>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Subtle attribution line for the home view (author + repo links). Content is
3
+ * supplied by the consuming app via `UiSettings.attribution` — the OSS core
4
+ * hard-codes nothing company-specific. Renders nothing when unset.
5
+ *
6
+ * `kind: 'oss'` links get a GitHub mark; everything else gets a generic
7
+ * external-link icon (proprietary repos, etc.).
8
+ */
9
+ export declare function AttributionFooter(): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,31 @@
1
+ import { jsxs, jsx } from "react/jsx-runtime";
2
+ import { Github, ExternalLink } from "lucide-react";
3
+ import { useUiSettings } from "../../../../context/UiSettingsContext.js";
4
+ function AttributionFooter() {
5
+ var _a;
6
+ const { attribution } = useUiSettings();
7
+ if (!attribution || !attribution.madeBy && !((_a = attribution.links) == null ? void 0 : _a.length)) {
8
+ return null;
9
+ }
10
+ return /* @__PURE__ */ jsxs("footer", { className: "mt-16 border-t border-border/60 pt-5 text-center text-[12.5px] text-muted-foreground", children: [
11
+ attribution.madeBy && /* @__PURE__ */ jsx("span", { children: attribution.madeBy }),
12
+ attribution.links && attribution.links.length > 0 && /* @__PURE__ */ jsx("div", { className: "mt-1.5 flex flex-wrap items-center justify-center gap-x-4 gap-y-1", children: attribution.links.map((link) => /* @__PURE__ */ jsxs(
13
+ "a",
14
+ {
15
+ href: link.url,
16
+ target: "_blank",
17
+ rel: "noopener noreferrer",
18
+ className: "inline-flex items-center gap-1 hover:text-primary transition-colors",
19
+ children: [
20
+ link.kind === "oss" ? /* @__PURE__ */ jsx(Github, { className: "size-3.5" }) : /* @__PURE__ */ jsx(ExternalLink, { className: "size-3.5" }),
21
+ link.label
22
+ ]
23
+ },
24
+ link.url
25
+ )) })
26
+ ] });
27
+ }
28
+ export {
29
+ AttributionFooter
30
+ };
31
+ //# sourceMappingURL=AttributionFooter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AttributionFooter.js","sources":["../../../../../../src/modules/appCatalog/ui/launcher/AttributionFooter.tsx"],"sourcesContent":["import { ExternalLink, Github } from 'lucide-react'\nimport { useUiSettings } from '~/context/UiSettingsContext'\n\n/**\n * Subtle attribution line for the home view (author + repo links). Content is\n * supplied by the consuming app via `UiSettings.attribution` — the OSS core\n * hard-codes nothing company-specific. Renders nothing when unset.\n *\n * `kind: 'oss'` links get a GitHub mark; everything else gets a generic\n * external-link icon (proprietary repos, etc.).\n */\nexport function AttributionFooter() {\n const { attribution } = useUiSettings()\n if (!attribution || (!attribution.madeBy && !attribution.links?.length)) {\n return null\n }\n\n return (\n <footer className=\"mt-16 border-t border-border/60 pt-5 text-center text-[12.5px] text-muted-foreground\">\n {attribution.madeBy && <span>{attribution.madeBy}</span>}\n {attribution.links && attribution.links.length > 0 && (\n <div className=\"mt-1.5 flex flex-wrap items-center justify-center gap-x-4 gap-y-1\">\n {attribution.links.map((link) => (\n <a\n key={link.url}\n href={link.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n className=\"inline-flex items-center gap-1 hover:text-primary transition-colors\"\n >\n {link.kind === 'oss' ? (\n <Github className=\"size-3.5\" />\n ) : (\n <ExternalLink className=\"size-3.5\" />\n )}\n {link.label}\n </a>\n ))}\n </div>\n )}\n </footer>\n )\n}\n"],"names":[],"mappings":";;;AAWO,SAAS,oBAAoB;;AAClC,QAAM,EAAE,YAAA,IAAgB,cAAA;AACxB,MAAI,CAAC,eAAgB,CAAC,YAAY,UAAU,GAAC,iBAAY,UAAZ,mBAAmB,SAAS;AACvE,WAAO;AAAA,EACT;AAEA,SACE,qBAAC,UAAA,EAAO,WAAU,wFACf,UAAA;AAAA,IAAA,YAAY,UAAU,oBAAC,QAAA,EAAM,UAAA,YAAY,QAAO;AAAA,IAChD,YAAY,SAAS,YAAY,MAAM,SAAS,KAC/C,oBAAC,OAAA,EAAI,WAAU,qEACZ,UAAA,YAAY,MAAM,IAAI,CAAC,SACtB;AAAA,MAAC;AAAA,MAAA;AAAA,QAEC,MAAM,KAAK;AAAA,QACX,QAAO;AAAA,QACP,KAAI;AAAA,QACJ,WAAU;AAAA,QAET,UAAA;AAAA,UAAA,KAAK,SAAS,QACb,oBAAC,QAAA,EAAO,WAAU,YAAW,IAE7B,oBAAC,cAAA,EAAa,WAAU,WAAA,CAAW;AAAA,UAEpC,KAAK;AAAA,QAAA;AAAA,MAAA;AAAA,MAXD,KAAK;AAAA,IAAA,CAab,EAAA,CACH;AAAA,EAAA,GAEJ;AAEJ;"}
@@ -21,7 +21,7 @@ function LauncherDetailPanel({
21
21
  role: "dialog",
22
22
  "aria-modal": "true",
23
23
  "aria-label": `${app.displayName} details`,
24
- className: "relative w-full max-w-[760px] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200",
24
+ className: "relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200",
25
25
  children: /* @__PURE__ */ jsx("div", { className: "max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7", children: /* @__PURE__ */ jsx(
26
26
  AppDetails,
27
27
  {
@@ -1 +1 @@
1
- {"version":3,"file":"LauncherDetailPanel.js","sources":["../../../../../../src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { AppDetails } from '../grid/AppCatalogGrid'\n\n/**\n * Centered detail card for the launcher (#38, item B). Renders the rich\n * AppDetails (access-hero, sub-resources, tiers) as a large centered modal card\n * over the launcher backdrop, instead of the old grid + resizable split-pane.\n *\n * Escape handling: this component does NOT globally bind Escape. Escape-to-close\n * is owned by AppDetails' own key handling (which first closes an open\n * screenshot gallery, then closes the card), so pressing Esc inside the gallery\n * returns to the card — not all the way to the home page (#38 Esc-stacking bug).\n */\nexport function LauncherDetailPanel({\n app,\n onClose,\n onAppClick,\n}: {\n app: Resource\n onClose: () => void\n onAppClick?: (app: Resource) => void\n}) {\n return (\n <div className=\"fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10\">\n {/* scrim */}\n <button\n type=\"button\"\n aria-label=\"Close details panel\"\n onClick={onClose}\n className=\"fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in\"\n />\n {/* centered card */}\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={`${app.displayName} details`}\n className=\"relative w-full max-w-[760px] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200\"\n >\n <div className=\"max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7\">\n <AppDetails\n app={app}\n onAppClick={onAppClick}\n onClosePanel={onClose}\n />\n </div>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":";;AAaO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,SACE,qBAAC,OAAA,EAAI,WAAU,yFAEb,UAAA;AAAA,IAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAW;AAAA,QACX,SAAS;AAAA,QACT,WAAU;AAAA,MAAA;AAAA,IAAA;AAAA,IAGZ;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAW;AAAA,QACX,cAAY,GAAG,IAAI,WAAW;AAAA,QAC9B,WAAU;AAAA,QAEV,UAAA,oBAAC,OAAA,EAAI,WAAU,0DACb,UAAA;AAAA,UAAC;AAAA,UAAA;AAAA,YACC;AAAA,YACA;AAAA,YACA,cAAc;AAAA,UAAA;AAAA,QAAA,EAChB,CACF;AAAA,MAAA;AAAA,IAAA;AAAA,EACF,GACF;AAEJ;"}
1
+ {"version":3,"file":"LauncherDetailPanel.js","sources":["../../../../../../src/modules/appCatalog/ui/launcher/LauncherDetailPanel.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { AppDetails } from '../grid/AppCatalogGrid'\n\n/**\n * Centered detail card for the launcher (#38, item B). Renders the rich\n * AppDetails (access-hero, sub-resources, tiers) as a large centered modal card\n * over the launcher backdrop, instead of the old grid + resizable split-pane.\n *\n * Escape handling: this component does NOT globally bind Escape. Escape-to-close\n * is owned by AppDetails' own key handling (which first closes an open\n * screenshot gallery, then closes the card), so pressing Esc inside the gallery\n * returns to the card — not all the way to the home page (#38 Esc-stacking bug).\n */\nexport function LauncherDetailPanel({\n app,\n onClose,\n onAppClick,\n}: {\n app: Resource\n onClose: () => void\n onAppClick?: (app: Resource) => void\n}) {\n return (\n <div className=\"fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10\">\n {/* scrim */}\n <button\n type=\"button\"\n aria-label=\"Close details panel\"\n onClick={onClose}\n className=\"fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in\"\n />\n {/* centered card — wide enough for data tables (sub-resources have 5\n columns incl. \"Access Contacts\"/\"AWS Account\" that wrapped at 760px);\n prose inside AppDetails is width-capped separately so it stays\n readable. Caps at 94vw so it never touches the edges. */}\n <div\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={`${app.displayName} details`}\n className=\"relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200\"\n >\n <div className=\"max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7\">\n <AppDetails\n app={app}\n onAppClick={onAppClick}\n onClosePanel={onClose}\n />\n </div>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":";;AAaO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA;AACF,GAIG;AACD,SACE,qBAAC,OAAA,EAAI,WAAU,yFAEb,UAAA;AAAA,IAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAW;AAAA,QACX,SAAS;AAAA,QACT,WAAU;AAAA,MAAA;AAAA,IAAA;AAAA,IAMZ;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,cAAW;AAAA,QACX,cAAY,GAAG,IAAI,WAAW;AAAA,QAC9B,WAAU;AAAA,QAEV,UAAA,oBAAC,OAAA,EAAI,WAAU,0DACb,UAAA;AAAA,UAAC;AAAA,UAAA;AAAA,YACC;AAAA,YACA;AAAA,YACA,cAAc;AAAA,UAAA;AAAA,QAAA,EAChB,CACF;AAAA,MAAA;AAAA,IAAA;AAAA,EACF,GACF;AAEJ;"}
@@ -4,6 +4,7 @@ import { useState, useEffect, useMemo, useRef } from "react";
4
4
  import { cn } from "../../../../lib/utils.js";
5
5
  import { useAppClickHistory } from "../../hooks/useAppClickHistory.js";
6
6
  import { markdownToPlainText } from "../../utils/markdownToPlainText.js";
7
+ import { AttributionFooter } from "./AttributionFooter.js";
7
8
  import { ResourceIcon } from "./ResourceIcon.js";
8
9
  import { searchResources } from "../../utils/searchApps.js";
9
10
  const typeLabel = (t) => {
@@ -377,7 +378,8 @@ function LauncherHome({
377
378
  app.slug
378
379
  )) })
379
380
  ] })
380
- ] })
381
+ ] }),
382
+ /* @__PURE__ */ jsx(AttributionFooter, {})
381
383
  ] })
382
384
  ] });
383
385
  }
@@ -1 +1 @@
1
- {"version":3,"file":"LauncherHome.js","sources":["../../../../../../src/modules/appCatalog/ui/launcher/LauncherHome.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ArrowUpRight, Search } from 'lucide-react'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { cn } from '~/lib/utils'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { markdownToPlainText } from '../../utils/markdownToPlainText'\nimport { ResourceIcon } from './ResourceIcon'\nimport { searchResources } from '../../utils/searchApps'\n\n/**\n * Adaptive-home discovery spine (issue #38, increment 1) — matches the\n * option-a-launcher prototype:\n * Your apps (frequent) → New this week → Browse all\n * Rendered by AppCatalogPage when there is no active search and no open detail.\n * The hero search box drives the same `searchValue` used elsewhere; typing hands\n * off to the results view (increment 2 refines the morph).\n */\n\n// Type pill text. Plain \"application\" resources are the common case and don't\n// need a badge (the user asked not to show a bare \"App\" badge); only surface a\n// pill for distinctive types (database, cloud, service, …).\nconst typeLabel = (t?: string): string | null => {\n if (!t || t === 'application') return null\n return t.charAt(0).toUpperCase() + t.slice(1)\n}\n\nexport interface LauncherHomeProps {\n /** All root (non-child) resources, already filtered for deprecated per settings. */\n apps: Resource[]\n /**\n * All resources INCLUDING children/sub-resources. Passed to search so a query\n * matching a sub-resource (e.g. an AWS account, an alias/account ID) surfaces\n * its parent — preserving the existing cross-sub-resource search behavior.\n * Falls back to `apps` when omitted.\n */\n allResources?: Resource[]\n searchValue: string\n onSearchChange: (v: string) => void\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n /** Total resource count for the \"Browse all\" label. */\n totalCount: number\n}\n\nfunction LaunchButton({\n app,\n onLaunch,\n className,\n}: {\n app: Resource\n onLaunch: (app: Resource) => void\n className?: string\n}) {\n if (!app.appUrl) return null\n // Show the destination URL on hover (user ask) — the native title tooltip\n // reveals where the ↗ jumps to, e.g. \"Open → console.aws.amazon.com\".\n const prettyUrl = app.appUrl.replace(/^https?:\\/\\//, '')\n return (\n <a\n href={app.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n title={`Open → ${prettyUrl}`}\n aria-label={`Open ${app.displayName} in a new tab (${prettyUrl})`}\n onClick={(e) => {\n e.stopPropagation()\n onLaunch(app)\n }}\n className={cn(\n 'grid place-items-center rounded-full border border-border bg-card text-muted-foreground',\n 'hover:bg-primary hover:text-primary-foreground hover:border-primary transition-colors',\n className,\n )}\n >\n <ArrowUpRight className=\"size-3.5\" />\n </a>\n )\n}\n\nfunction SectionHead({\n title,\n count,\n action,\n}: {\n title: string\n count?: string\n action?: React.ReactNode\n}) {\n return (\n <div className=\"flex items-center gap-2.5 mb-4\">\n <h2 className=\"font-serif font-semibold text-[15px] tracking-tight m-0\">\n {title}\n </h2>\n {count && (\n <span className=\"text-[13px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5\">\n {count}\n </span>\n )}\n {action && <div className=\"ml-auto\">{action}</div>}\n </div>\n )\n}\n\n/** Small row used by \"New this week\" and \"Browse all\". */\nfunction ResourceRow({\n app,\n onAppClick,\n onLaunch,\n showNew = false,\n}: {\n app: Resource\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n showNew?: boolean\n}) {\n return (\n <button\n type=\"button\"\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border border-border rounded-[var(--radius)] px-3.5 py-3',\n 'hover:border-ring hover:shadow-sm transition-all',\n )}\n >\n <ResourceIcon app={app} size={38} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14.5px] font-semibold truncate\">\n {app.displayName}\n </span>\n {app.description && (\n <span className=\"block text-[13px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n </span>\n {showNew && (\n <span className=\"text-[11px] font-bold text-[#65a443] bg-[#65a443]/12 rounded-full px-2.5 py-0.5\">\n New\n </span>\n )}\n {typeLabel(app.type) && (\n <span className=\"text-[12px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton app={app} onLaunch={onLaunch} className=\"size-8\" />\n </button>\n )\n}\n\n/**\n * Keyboard-navigable search results list — increment 2 of the launcher redesign.\n * Appears below the hero search box when the user types, replacing the\n * discovery spine. Pressing ↑↓ navigates, ↵ opens detail, Esc clears.\n */\nfunction SearchResultsList({\n apps,\n searchValue,\n onAppClick,\n onLaunch,\n onClear,\n}: {\n apps: Resource[]\n searchValue: string\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n onClear: () => void\n}) {\n const [focusedIndex, setFocusedIndex] = useState(-1)\n\n const results = useMemo(\n () => searchResources(apps, searchValue),\n [apps, searchValue],\n )\n\n // For each result, the child/sub-resources of it that themselves match the\n // query — so a parent surfaced only via its children (e.g. AWS Console via a\n // \"biom\" account) tells the user WHICH sub-resources matched, instead of an\n // unexplained parent. Keyed by parent slug.\n const matchedChildrenByParent = useMemo(() => {\n const q = searchValue.trim().toLowerCase()\n const map = new Map<string, Resource[]>()\n if (!q) return map\n const resultSlugs = new Set(results.map((r) => r.slug))\n for (const r of apps) {\n if (!r.parentSlug || !resultSlugs.has(r.parentSlug)) continue\n const hay = `${r.displayName} ${r.abbreviation ?? ''} ${(\n r.aliases ?? []\n ).join(' ')} ${r.description ?? ''}`.toLowerCase()\n if (!hay.includes(q)) continue\n const list = map.get(r.parentSlug) ?? []\n list.push(r)\n map.set(r.parentSlug, list)\n }\n return map\n }, [apps, results, searchValue])\n\n // #11 deprecated fallback: when EVERY match is a deprecated app (i.e. there\n // are no active matches for the query, only deprecated ones), surface a\n // notice so the user understands why they're seeing archived tools. The\n // deprecated apps are already in `results` because the launcher feeds search\n // the full resource set; this just labels the situation.\n const didDeprecatedFallback = useMemo(\n () => results.length > 0 && results.every((r) => Boolean(r.deprecated)),\n [results],\n )\n\n // Reset focus when results change\n useEffect(() => {\n setFocusedIndex(-1)\n }, [searchValue])\n\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (results.length === 0) return\n if (e.key === 'ArrowDown') {\n e.preventDefault()\n setFocusedIndex((i) => Math.min(i + 1, results.length - 1))\n } else if (e.key === 'ArrowUp') {\n e.preventDefault()\n setFocusedIndex((i) => Math.max(i - 1, 0))\n } else if (e.key === 'Enter') {\n if (focusedIndex >= 0 && results[focusedIndex]) {\n onAppClick(results[focusedIndex])\n }\n } else if (e.key === 'Escape') {\n onClear()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [results, focusedIndex, onAppClick, onClear])\n\n return (\n <div className=\"max-w-[620px] mx-auto mt-3\">\n {/* Meta line */}\n <div className=\"text-[13px] text-muted-foreground mb-2 px-1\">\n {results.length === 0\n ? `No results for \"${searchValue}\"`\n : `${results.length} result${results.length === 1 ? '' : 's'}`}\n </div>\n\n {/* #11: deprecated-only fallback notice */}\n {didDeprecatedFallback && (\n <div\n role=\"status\"\n className=\"text-[13px] text-muted-foreground mb-2 px-1\"\n >\n No active apps for &quot;{searchValue}&quot; — showing deprecated\n matches.\n </div>\n )}\n\n {/* Results */}\n <div\n className=\"flex flex-col gap-1.5\"\n role=\"listbox\"\n aria-label=\"Search results\"\n >\n {results.map((app, i) => (\n <button\n key={app.slug}\n type=\"button\"\n role=\"option\"\n title={`View ${app.displayName}`}\n aria-selected={focusedIndex === i}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => onAppClick(app)}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border rounded-[var(--radius)] px-3.5 py-2.5',\n 'transition-all',\n focusedIndex === i\n ? 'border-ring shadow-sm bg-muted/30'\n : 'border-border hover:border-ring hover:shadow-sm',\n )}\n >\n <ResourceIcon app={app} size={40} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14px] font-semibold truncate\">\n {app.displayName}\n {app.abbreviation && app.abbreviation !== app.displayName && (\n <span className=\"ml-1.5 text-[12px] font-normal text-muted-foreground\">\n ({app.abbreviation})\n </span>\n )}\n </span>\n {app.description && (\n <span className=\"block text-[12.5px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n {(() => {\n const kids = matchedChildrenByParent.get(app.slug)\n if (!kids || kids.length === 0) return null\n const names = kids.map((k) => k.displayName)\n const shown = names.slice(0, 3).join(', ')\n const extra =\n names.length > 3 ? ` +${names.length - 3} more` : ''\n return (\n <span className=\"mt-0.5 block text-[12px] text-primary truncate\">\n Matched {kids.length} sub-resource\n {kids.length === 1 ? '' : 's'}: {shown}\n {extra}\n </span>\n )\n })()}\n </span>\n {typeLabel(app.type) && (\n <span className=\"text-[11.5px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap shrink-0\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"size-7 shrink-0 opacity-0 group-hover:opacity-100\"\n />\n </button>\n ))}\n </div>\n\n {/* Keyboard hint */}\n {results.length > 0 && (\n <p className=\"text-[11.5px] text-muted-foreground/60 text-center mt-3\">\n ↑↓ navigate · ↵ view access · esc clear\n </p>\n )}\n </div>\n )\n}\n\nexport function LauncherHome({\n apps,\n allResources,\n searchValue,\n onSearchChange,\n onAppClick,\n onLaunch,\n totalCount,\n}: LauncherHomeProps) {\n const { getTopApps } = useAppClickHistory()\n const [topSlugs, setTopSlugs] = useState<string[]>([])\n\n useEffect(() => {\n void getTopApps(5).then(setTopSlugs)\n }, [getTopApps])\n\n const bySlug = useMemo(() => {\n const m = new Map<string, Resource>()\n for (const a of apps) m.set(a.slug, a)\n return m\n }, [apps])\n\n // Your apps: frequently-clicked (personalized). Empty on a cold new machine.\n const frequent = useMemo(\n () =>\n topSlugs\n .map((s) => bySlug.get(s))\n .filter((a): a is Resource => Boolean(a))\n .slice(0, 5),\n [topSlugs, bySlug],\n )\n\n // New this week: apps whose sources were checked in the last 7 days, as a\n // proxy for \"recently added/updated\" (frontend Resource has no createdAt yet;\n // see #38). Falls back to empty (section hidden) when no freshness data.\n const fresh = useMemo(() => {\n const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000\n return apps\n .filter((a) => {\n const t = a.freshness?.lastCheckedAt\n return t ? new Date(t).getTime() >= weekAgo : false\n })\n .sort((a, b) => {\n const ta = new Date(a.freshness?.lastCheckedAt ?? 0).getTime()\n const tb = new Date(b.freshness?.lastCheckedAt ?? 0).getTime()\n return tb - ta\n })\n .slice(0, 6)\n }, [apps])\n\n // Browse all: everything not already promoted to \"Your apps\", alpha by name.\n const browse = useMemo(() => {\n const promoted = new Set(frequent.map((a) => a.slug))\n return [...apps]\n .filter((a) => !promoted.has(a.slug))\n .sort((a, b) => a.displayName.localeCompare(b.displayName))\n }, [apps, frequent])\n\n const browseLeft = browse.filter((_, i) => i % 2 === 0)\n const browseRight = browse.filter((_, i) => i % 2 === 1)\n\n const isSearching = searchValue.trim() !== ''\n const searchRef = useRef<HTMLInputElement>(null)\n\n // ⌘K / Ctrl+K focuses the hero search box from anywhere on the page.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault()\n searchRef.current?.focus()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [])\n\n return (\n <div className=\"mx-auto w-full max-w-[1000px] pb-24\">\n {/* hero */}\n <div className=\"text-center pt-10 pb-1.5\">\n <h1 className=\"font-serif font-semibold text-[30px] tracking-tight m-0 mb-1.5\">\n What do you need to get into?\n </h1>\n <p className=\"text-muted-foreground m-0 mb-6 text-[15px]\">\n Find any tool, see how to request access, or jump straight in.\n </p>\n <div className=\"max-w-[620px] mx-auto\">\n <div className=\"flex items-center gap-3 bg-card border-[1.5px] border-input rounded-full px-5 h-14 shadow-sm focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/15 transition-colors\">\n <Search className=\"size-5 shrink-0 text-muted-foreground\" />\n <input\n type=\"text\"\n value={searchValue}\n onChange={(e) => onSearchChange(e.target.value)}\n placeholder=\"Search apps, databases, cloud accounts, account IDs…\"\n autoComplete=\"off\"\n autoFocus\n ref={searchRef}\n aria-label=\"Search apps\"\n className=\"flex-1 bg-transparent border-0 outline-none text-[16px] text-foreground placeholder:text-muted-foreground\"\n />\n {!isSearching && (\n <kbd className=\"hidden sm:inline-flex items-center gap-0.5 text-[11px] text-muted-foreground/60 border border-muted-foreground/20 rounded px-1.5 py-0.5 font-mono shrink-0\">\n {typeof navigator !== 'undefined' &&\n navigator.platform.includes('Mac')\n ? '⌘K'\n : 'Ctrl+K'}\n </kbd>\n )}\n </div>\n </div>\n </div>\n\n {/* Search morph: when typing, show compact results list instead of the discovery spine */}\n {isSearching && (\n <SearchResultsList\n apps={allResources ?? apps}\n searchValue={searchValue}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n onClear={() => onSearchChange('')}\n />\n )}\n\n {/* Discovery spine: only shown when not searching */}\n {!isSearching && (\n <>\n {/* Your apps */}\n {frequent.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead title=\"Your apps\" count=\"frequent\" />\n <div className=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3.5\">\n {frequent.map((app) => (\n <button\n type=\"button\"\n key={app.slug}\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className=\"group relative bg-card border border-border rounded-[var(--radius)] px-3.5 pt-[18px] pb-4 text-center shadow-sm hover:-translate-y-0.5 hover:shadow-md transition-all\"\n >\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"absolute top-2.5 right-2.5 size-[26px] opacity-0 group-hover:opacity-100\"\n />\n <ResourceIcon\n app={app}\n size={46}\n className=\"mx-auto mb-3\"\n />\n <span className=\"block text-[13.5px] font-bold truncate\">\n {app.displayName}\n </span>\n </button>\n ))}\n </div>\n </section>\n )}\n\n {/* New this week */}\n {fresh.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead\n title=\"New this week\"\n count={`${fresh.length} added`}\n />\n <div className=\"flex flex-col gap-2\">\n {fresh.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n showNew\n />\n ))}\n </div>\n </section>\n )}\n\n {/* Browse all */}\n <section className=\"mt-9\">\n <SectionHead title=\"Browse all\" count={`${totalCount} resources`} />\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-2\">\n <div className=\"flex flex-col gap-2\">\n {browseLeft.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </div>\n <div className=\"flex flex-col gap-2\">\n {browseRight.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </div>\n </div>\n </section>\n </>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"LauncherHome.js","sources":["../../../../../../src/modules/appCatalog/ui/launcher/LauncherHome.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { ArrowUpRight, Search } from 'lucide-react'\nimport { useEffect, useMemo, useRef, useState } from 'react'\nimport { cn } from '~/lib/utils'\nimport { useAppClickHistory } from '../../hooks/useAppClickHistory'\nimport { markdownToPlainText } from '../../utils/markdownToPlainText'\nimport { AttributionFooter } from './AttributionFooter'\nimport { ResourceIcon } from './ResourceIcon'\nimport { searchResources } from '../../utils/searchApps'\n\n/**\n * Adaptive-home discovery spine (issue #38, increment 1) — matches the\n * option-a-launcher prototype:\n * Your apps (frequent) → New this week → Browse all\n * Rendered by AppCatalogPage when there is no active search and no open detail.\n * The hero search box drives the same `searchValue` used elsewhere; typing hands\n * off to the results view (increment 2 refines the morph).\n */\n\n// Type pill text. Plain \"application\" resources are the common case and don't\n// need a badge (the user asked not to show a bare \"App\" badge); only surface a\n// pill for distinctive types (database, cloud, service, …).\nconst typeLabel = (t?: string): string | null => {\n if (!t || t === 'application') return null\n return t.charAt(0).toUpperCase() + t.slice(1)\n}\n\nexport interface LauncherHomeProps {\n /** All root (non-child) resources, already filtered for deprecated per settings. */\n apps: Resource[]\n /**\n * All resources INCLUDING children/sub-resources. Passed to search so a query\n * matching a sub-resource (e.g. an AWS account, an alias/account ID) surfaces\n * its parent — preserving the existing cross-sub-resource search behavior.\n * Falls back to `apps` when omitted.\n */\n allResources?: Resource[]\n searchValue: string\n onSearchChange: (v: string) => void\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n /** Total resource count for the \"Browse all\" label. */\n totalCount: number\n}\n\nfunction LaunchButton({\n app,\n onLaunch,\n className,\n}: {\n app: Resource\n onLaunch: (app: Resource) => void\n className?: string\n}) {\n if (!app.appUrl) return null\n // Show the destination URL on hover (user ask) — the native title tooltip\n // reveals where the ↗ jumps to, e.g. \"Open → console.aws.amazon.com\".\n const prettyUrl = app.appUrl.replace(/^https?:\\/\\//, '')\n return (\n <a\n href={app.appUrl}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n title={`Open → ${prettyUrl}`}\n aria-label={`Open ${app.displayName} in a new tab (${prettyUrl})`}\n onClick={(e) => {\n e.stopPropagation()\n onLaunch(app)\n }}\n className={cn(\n 'grid place-items-center rounded-full border border-border bg-card text-muted-foreground',\n 'hover:bg-primary hover:text-primary-foreground hover:border-primary transition-colors',\n className,\n )}\n >\n <ArrowUpRight className=\"size-3.5\" />\n </a>\n )\n}\n\nfunction SectionHead({\n title,\n count,\n action,\n}: {\n title: string\n count?: string\n action?: React.ReactNode\n}) {\n return (\n <div className=\"flex items-center gap-2.5 mb-4\">\n <h2 className=\"font-serif font-semibold text-[15px] tracking-tight m-0\">\n {title}\n </h2>\n {count && (\n <span className=\"text-[13px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5\">\n {count}\n </span>\n )}\n {action && <div className=\"ml-auto\">{action}</div>}\n </div>\n )\n}\n\n/** Small row used by \"New this week\" and \"Browse all\". */\nfunction ResourceRow({\n app,\n onAppClick,\n onLaunch,\n showNew = false,\n}: {\n app: Resource\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n showNew?: boolean\n}) {\n return (\n <button\n type=\"button\"\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border border-border rounded-[var(--radius)] px-3.5 py-3',\n 'hover:border-ring hover:shadow-sm transition-all',\n )}\n >\n <ResourceIcon app={app} size={38} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14.5px] font-semibold truncate\">\n {app.displayName}\n </span>\n {app.description && (\n <span className=\"block text-[13px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n </span>\n {showNew && (\n <span className=\"text-[11px] font-bold text-[#65a443] bg-[#65a443]/12 rounded-full px-2.5 py-0.5\">\n New\n </span>\n )}\n {typeLabel(app.type) && (\n <span className=\"text-[12px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton app={app} onLaunch={onLaunch} className=\"size-8\" />\n </button>\n )\n}\n\n/**\n * Keyboard-navigable search results list — increment 2 of the launcher redesign.\n * Appears below the hero search box when the user types, replacing the\n * discovery spine. Pressing ↑↓ navigates, ↵ opens detail, Esc clears.\n */\nfunction SearchResultsList({\n apps,\n searchValue,\n onAppClick,\n onLaunch,\n onClear,\n}: {\n apps: Resource[]\n searchValue: string\n onAppClick: (app: Resource) => void\n onLaunch: (app: Resource) => void\n onClear: () => void\n}) {\n const [focusedIndex, setFocusedIndex] = useState(-1)\n\n const results = useMemo(\n () => searchResources(apps, searchValue),\n [apps, searchValue],\n )\n\n // For each result, the child/sub-resources of it that themselves match the\n // query — so a parent surfaced only via its children (e.g. AWS Console via a\n // \"biom\" account) tells the user WHICH sub-resources matched, instead of an\n // unexplained parent. Keyed by parent slug.\n const matchedChildrenByParent = useMemo(() => {\n const q = searchValue.trim().toLowerCase()\n const map = new Map<string, Resource[]>()\n if (!q) return map\n const resultSlugs = new Set(results.map((r) => r.slug))\n for (const r of apps) {\n if (!r.parentSlug || !resultSlugs.has(r.parentSlug)) continue\n const hay = `${r.displayName} ${r.abbreviation ?? ''} ${(\n r.aliases ?? []\n ).join(' ')} ${r.description ?? ''}`.toLowerCase()\n if (!hay.includes(q)) continue\n const list = map.get(r.parentSlug) ?? []\n list.push(r)\n map.set(r.parentSlug, list)\n }\n return map\n }, [apps, results, searchValue])\n\n // #11 deprecated fallback: when EVERY match is a deprecated app (i.e. there\n // are no active matches for the query, only deprecated ones), surface a\n // notice so the user understands why they're seeing archived tools. The\n // deprecated apps are already in `results` because the launcher feeds search\n // the full resource set; this just labels the situation.\n const didDeprecatedFallback = useMemo(\n () => results.length > 0 && results.every((r) => Boolean(r.deprecated)),\n [results],\n )\n\n // Reset focus when results change\n useEffect(() => {\n setFocusedIndex(-1)\n }, [searchValue])\n\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if (results.length === 0) return\n if (e.key === 'ArrowDown') {\n e.preventDefault()\n setFocusedIndex((i) => Math.min(i + 1, results.length - 1))\n } else if (e.key === 'ArrowUp') {\n e.preventDefault()\n setFocusedIndex((i) => Math.max(i - 1, 0))\n } else if (e.key === 'Enter') {\n if (focusedIndex >= 0 && results[focusedIndex]) {\n onAppClick(results[focusedIndex])\n }\n } else if (e.key === 'Escape') {\n onClear()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [results, focusedIndex, onAppClick, onClear])\n\n return (\n <div className=\"max-w-[620px] mx-auto mt-3\">\n {/* Meta line */}\n <div className=\"text-[13px] text-muted-foreground mb-2 px-1\">\n {results.length === 0\n ? `No results for \"${searchValue}\"`\n : `${results.length} result${results.length === 1 ? '' : 's'}`}\n </div>\n\n {/* #11: deprecated-only fallback notice */}\n {didDeprecatedFallback && (\n <div\n role=\"status\"\n className=\"text-[13px] text-muted-foreground mb-2 px-1\"\n >\n No active apps for &quot;{searchValue}&quot; — showing deprecated\n matches.\n </div>\n )}\n\n {/* Results */}\n <div\n className=\"flex flex-col gap-1.5\"\n role=\"listbox\"\n aria-label=\"Search results\"\n >\n {results.map((app, i) => (\n <button\n key={app.slug}\n type=\"button\"\n role=\"option\"\n title={`View ${app.displayName}`}\n aria-selected={focusedIndex === i}\n onMouseEnter={() => setFocusedIndex(i)}\n onClick={() => onAppClick(app)}\n className={cn(\n 'group flex items-center gap-3.5 w-full text-left',\n 'bg-card border rounded-[var(--radius)] px-3.5 py-2.5',\n 'transition-all',\n focusedIndex === i\n ? 'border-ring shadow-sm bg-muted/30'\n : 'border-border hover:border-ring hover:shadow-sm',\n )}\n >\n <ResourceIcon app={app} size={40} />\n <span className=\"flex-1 min-w-0\">\n <span className=\"block text-[14px] font-semibold truncate\">\n {app.displayName}\n {app.abbreviation && app.abbreviation !== app.displayName && (\n <span className=\"ml-1.5 text-[12px] font-normal text-muted-foreground\">\n ({app.abbreviation})\n </span>\n )}\n </span>\n {app.description && (\n <span className=\"block text-[12.5px] text-muted-foreground truncate\">\n {markdownToPlainText(app.description)}\n </span>\n )}\n {(() => {\n const kids = matchedChildrenByParent.get(app.slug)\n if (!kids || kids.length === 0) return null\n const names = kids.map((k) => k.displayName)\n const shown = names.slice(0, 3).join(', ')\n const extra =\n names.length > 3 ? ` +${names.length - 3} more` : ''\n return (\n <span className=\"mt-0.5 block text-[12px] text-primary truncate\">\n Matched {kids.length} sub-resource\n {kids.length === 1 ? '' : 's'}: {shown}\n {extra}\n </span>\n )\n })()}\n </span>\n {typeLabel(app.type) && (\n <span className=\"text-[11.5px] text-muted-foreground bg-muted rounded-full px-2.5 py-0.5 whitespace-nowrap shrink-0\">\n {typeLabel(app.type)}\n </span>\n )}\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"size-7 shrink-0 opacity-0 group-hover:opacity-100\"\n />\n </button>\n ))}\n </div>\n\n {/* Keyboard hint */}\n {results.length > 0 && (\n <p className=\"text-[11.5px] text-muted-foreground/60 text-center mt-3\">\n ↑↓ navigate · ↵ view access · esc clear\n </p>\n )}\n </div>\n )\n}\n\nexport function LauncherHome({\n apps,\n allResources,\n searchValue,\n onSearchChange,\n onAppClick,\n onLaunch,\n totalCount,\n}: LauncherHomeProps) {\n const { getTopApps } = useAppClickHistory()\n const [topSlugs, setTopSlugs] = useState<string[]>([])\n\n useEffect(() => {\n void getTopApps(5).then(setTopSlugs)\n }, [getTopApps])\n\n const bySlug = useMemo(() => {\n const m = new Map<string, Resource>()\n for (const a of apps) m.set(a.slug, a)\n return m\n }, [apps])\n\n // Your apps: frequently-clicked (personalized). Empty on a cold new machine.\n const frequent = useMemo(\n () =>\n topSlugs\n .map((s) => bySlug.get(s))\n .filter((a): a is Resource => Boolean(a))\n .slice(0, 5),\n [topSlugs, bySlug],\n )\n\n // New this week: apps whose sources were checked in the last 7 days, as a\n // proxy for \"recently added/updated\" (frontend Resource has no createdAt yet;\n // see #38). Falls back to empty (section hidden) when no freshness data.\n const fresh = useMemo(() => {\n const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000\n return apps\n .filter((a) => {\n const t = a.freshness?.lastCheckedAt\n return t ? new Date(t).getTime() >= weekAgo : false\n })\n .sort((a, b) => {\n const ta = new Date(a.freshness?.lastCheckedAt ?? 0).getTime()\n const tb = new Date(b.freshness?.lastCheckedAt ?? 0).getTime()\n return tb - ta\n })\n .slice(0, 6)\n }, [apps])\n\n // Browse all: everything not already promoted to \"Your apps\", alpha by name.\n const browse = useMemo(() => {\n const promoted = new Set(frequent.map((a) => a.slug))\n return [...apps]\n .filter((a) => !promoted.has(a.slug))\n .sort((a, b) => a.displayName.localeCompare(b.displayName))\n }, [apps, frequent])\n\n const browseLeft = browse.filter((_, i) => i % 2 === 0)\n const browseRight = browse.filter((_, i) => i % 2 === 1)\n\n const isSearching = searchValue.trim() !== ''\n const searchRef = useRef<HTMLInputElement>(null)\n\n // ⌘K / Ctrl+K focuses the hero search box from anywhere on the page.\n useEffect(() => {\n const onKey = (e: KeyboardEvent) => {\n if ((e.metaKey || e.ctrlKey) && e.key === 'k') {\n e.preventDefault()\n searchRef.current?.focus()\n }\n }\n document.addEventListener('keydown', onKey)\n return () => document.removeEventListener('keydown', onKey)\n }, [])\n\n return (\n <div className=\"mx-auto w-full max-w-[1000px] pb-24\">\n {/* hero */}\n <div className=\"text-center pt-10 pb-1.5\">\n <h1 className=\"font-serif font-semibold text-[30px] tracking-tight m-0 mb-1.5\">\n What do you need to get into?\n </h1>\n <p className=\"text-muted-foreground m-0 mb-6 text-[15px]\">\n Find any tool, see how to request access, or jump straight in.\n </p>\n <div className=\"max-w-[620px] mx-auto\">\n <div className=\"flex items-center gap-3 bg-card border-[1.5px] border-input rounded-full px-5 h-14 shadow-sm focus-within:border-primary focus-within:ring-4 focus-within:ring-primary/15 transition-colors\">\n <Search className=\"size-5 shrink-0 text-muted-foreground\" />\n <input\n type=\"text\"\n value={searchValue}\n onChange={(e) => onSearchChange(e.target.value)}\n placeholder=\"Search apps, databases, cloud accounts, account IDs…\"\n autoComplete=\"off\"\n autoFocus\n ref={searchRef}\n aria-label=\"Search apps\"\n className=\"flex-1 bg-transparent border-0 outline-none text-[16px] text-foreground placeholder:text-muted-foreground\"\n />\n {!isSearching && (\n <kbd className=\"hidden sm:inline-flex items-center gap-0.5 text-[11px] text-muted-foreground/60 border border-muted-foreground/20 rounded px-1.5 py-0.5 font-mono shrink-0\">\n {typeof navigator !== 'undefined' &&\n navigator.platform.includes('Mac')\n ? '⌘K'\n : 'Ctrl+K'}\n </kbd>\n )}\n </div>\n </div>\n </div>\n\n {/* Search morph: when typing, show compact results list instead of the discovery spine */}\n {isSearching && (\n <SearchResultsList\n apps={allResources ?? apps}\n searchValue={searchValue}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n onClear={() => onSearchChange('')}\n />\n )}\n\n {/* Discovery spine: only shown when not searching */}\n {!isSearching && (\n <>\n {/* Your apps */}\n {frequent.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead title=\"Your apps\" count=\"frequent\" />\n <div className=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3.5\">\n {frequent.map((app) => (\n <button\n type=\"button\"\n key={app.slug}\n onClick={() => onAppClick(app)}\n title={`View ${app.displayName}`}\n className=\"group relative bg-card border border-border rounded-[var(--radius)] px-3.5 pt-[18px] pb-4 text-center shadow-sm hover:-translate-y-0.5 hover:shadow-md transition-all\"\n >\n <LaunchButton\n app={app}\n onLaunch={onLaunch}\n className=\"absolute top-2.5 right-2.5 size-[26px] opacity-0 group-hover:opacity-100\"\n />\n <ResourceIcon\n app={app}\n size={46}\n className=\"mx-auto mb-3\"\n />\n <span className=\"block text-[13.5px] font-bold truncate\">\n {app.displayName}\n </span>\n </button>\n ))}\n </div>\n </section>\n )}\n\n {/* New this week */}\n {fresh.length > 0 && (\n <section className=\"mt-9\">\n <SectionHead\n title=\"New this week\"\n count={`${fresh.length} added`}\n />\n <div className=\"flex flex-col gap-2\">\n {fresh.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n showNew\n />\n ))}\n </div>\n </section>\n )}\n\n {/* Browse all */}\n <section className=\"mt-9\">\n <SectionHead title=\"Browse all\" count={`${totalCount} resources`} />\n <div className=\"grid grid-cols-1 md:grid-cols-2 gap-2\">\n <div className=\"flex flex-col gap-2\">\n {browseLeft.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </div>\n <div className=\"flex flex-col gap-2\">\n {browseRight.map((app) => (\n <ResourceRow\n key={app.slug}\n app={app}\n onAppClick={onAppClick}\n onLaunch={onLaunch}\n />\n ))}\n </div>\n </div>\n </section>\n\n <AttributionFooter />\n </>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -12,4 +12,19 @@ export interface UiSettings {
12
12
  frontendBuildId?: string;
13
13
  /** PWA auto-update configuration (idle timeout, check interval, debug) */
14
14
  pwaAutoUpdate?: PwaAutoUpdateOptions;
15
+ /**
16
+ * Optional attribution shown as a subtle footer line on the home view.
17
+ * Kept generic here (no hard-coded names/links) so the OSS core stays
18
+ * vendor-neutral; the consuming app supplies its own author + repo links.
19
+ */
20
+ attribution?: {
21
+ /** e.g. "Made by <name>" — rendered verbatim. */
22
+ madeBy?: string;
23
+ /** Labeled links; `kind` lets the UI hint open-source vs proprietary. */
24
+ links?: {
25
+ label: string;
26
+ url: string;
27
+ kind?: 'oss' | 'proprietary';
28
+ }[];
29
+ };
15
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "0.4.0-alpha-20260807174540",
3
+ "version": "0.4.0-alpha-20260807223013",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -134,8 +134,8 @@
134
134
  "vite-plugin-static-copy": "^3.1.4",
135
135
  "vite-plugin-svgr": "^4.2.0",
136
136
  "vitest": "^4.1.2",
137
- "@igstack/app-catalog-backend-core": "0.4.0-alpha-20260807174540",
138
- "@igstack/app-catalog-shared-core": "0.4.0-alpha-20260807174540"
137
+ "@igstack/app-catalog-shared-core": "0.4.0-alpha-20260807223013",
138
+ "@igstack/app-catalog-backend-core": "0.4.0-alpha-20260807223013"
139
139
  },
140
140
  "peerDependencies": {
141
141
  "react": "19.1.2",
@@ -144,7 +144,7 @@
144
144
  "vite": "^6.3.5",
145
145
  "vite-plugin-svgr": "^4.2.0"
146
146
  },
147
- "gitHead": "0a22262337b8737dc77c7d298a093f6e8ab11f22",
147
+ "gitHead": "2af7cb2f143e89d53af1e070345c6743cde6f719",
148
148
  "scripts": {
149
149
  "build": "vite build",
150
150
  "build:lenient": "vite build --mode lenient",
@@ -1,6 +1,7 @@
1
- import { Check, Copy, User } from 'lucide-react'
1
+ import { Check, Copy, Mail, User } from 'lucide-react'
2
2
  import { useCallback, useEffect, useRef, useState } from 'react'
3
3
  import { Badge } from '~/ui/badge'
4
+ import { Popover, PopoverContent, PopoverTrigger } from '~/ui/popover'
4
5
  import { useAppCatalogContext } from '~/modules/appCatalog'
5
6
  import { getPersonBySlug } from '~/modules/appCatalog/utils/resolveHelpers'
6
7
 
@@ -8,6 +9,12 @@ interface PersonBadgeProps {
8
9
  slug: string
9
10
  }
10
11
 
12
+ /**
13
+ * Person chip. Clicking it opens a small popover exposing BOTH the full name
14
+ * and the email, each with its own copy button — so the user picks what to copy
15
+ * (previously a single button copied only the email). Email also links to
16
+ * mailto:. Falls back to just the name (no popover) when no email is known.
17
+ */
11
18
  export function PersonBadge({ slug }: PersonBadgeProps) {
12
19
  const { persons } = useAppCatalogContext()
13
20
  const person = getPersonBySlug(persons, slug)
@@ -15,55 +22,121 @@ export function PersonBadge({ slug }: PersonBadgeProps) {
15
22
  const displayName = person
16
23
  ? `${person.firstName} ${person.lastName}`.trim() || slug
17
24
  : slug
18
-
19
25
  const email = person?.email
20
26
 
27
+ // No email → nothing to pick between; render a plain, non-interactive chip.
28
+ if (!email) {
29
+ return (
30
+ <Badge
31
+ variant="outline"
32
+ className="font-normal inline-flex items-center gap-1"
33
+ title={displayName}
34
+ >
35
+ <User className="size-3" />
36
+ {displayName}
37
+ </Badge>
38
+ )
39
+ }
40
+
21
41
  return (
22
- <Badge
23
- variant="outline"
24
- className="font-normal inline-flex items-center gap-1"
25
- title={email ? `${displayName} (${email})` : displayName}
26
- >
27
- <User className="size-3" />
28
- {displayName}
29
- {email && <CopyEmailButton email={email} />}
30
- </Badge>
42
+ <Popover>
43
+ <PopoverTrigger asChild>
44
+ <button
45
+ type="button"
46
+ className="inline-flex"
47
+ title={`${displayName} · ${email}`}
48
+ >
49
+ <Badge
50
+ variant="outline"
51
+ className="font-normal inline-flex items-center gap-1 cursor-pointer hover:border-primary transition-colors"
52
+ >
53
+ <User className="size-3" />
54
+ {displayName}
55
+ <Copy className="size-3 opacity-50" />
56
+ </Badge>
57
+ </button>
58
+ </PopoverTrigger>
59
+ <PopoverContent align="start" className="w-64 p-2">
60
+ <CopyRow
61
+ icon={<User className="size-3.5" />}
62
+ label="Name"
63
+ value={displayName}
64
+ />
65
+ <CopyRow
66
+ icon={<Mail className="size-3.5" />}
67
+ label="Email"
68
+ value={email}
69
+ href={`mailto:${email}`}
70
+ />
71
+ </PopoverContent>
72
+ </Popover>
31
73
  )
32
74
  }
33
75
 
34
- function CopyEmailButton({ email }: { email: string }) {
76
+ /** One copyable field (label + value + copy button), value optionally a link. */
77
+ function CopyRow({
78
+ icon,
79
+ label,
80
+ value,
81
+ href,
82
+ }: {
83
+ icon: React.ReactNode
84
+ label: string
85
+ value: string
86
+ href?: string
87
+ }) {
35
88
  const [copied, setCopied] = useState(false)
36
89
  const timeoutRef = useRef<NodeJS.Timeout | null>(null)
37
90
 
38
- useEffect(() => {
39
- return () => {
91
+ useEffect(
92
+ () => () => {
40
93
  if (timeoutRef.current) clearTimeout(timeoutRef.current)
41
- }
42
- }, [])
94
+ },
95
+ [],
96
+ )
43
97
 
44
98
  const handleCopy = useCallback(
45
99
  (e: React.MouseEvent) => {
46
100
  e.stopPropagation()
47
- navigator.clipboard.writeText(email)
101
+ void navigator.clipboard.writeText(value)
48
102
  setCopied(true)
49
103
  if (timeoutRef.current) clearTimeout(timeoutRef.current)
50
104
  timeoutRef.current = setTimeout(() => setCopied(false), 2000)
51
105
  },
52
- [email],
106
+ [value],
53
107
  )
54
108
 
55
109
  return (
56
- <button
57
- onClick={handleCopy}
58
- className="ml-0.5 hover:text-primary transition-colors"
59
- title={copied ? 'Copied!' : `Copy ${email}`}
60
- type="button"
61
- >
62
- {copied ? (
63
- <Check className="size-3 text-green-600" />
64
- ) : (
65
- <Copy className="size-3" />
66
- )}
67
- </button>
110
+ <div className="flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-muted">
111
+ <span className="text-muted-foreground shrink-0">{icon}</span>
112
+ <span className="min-w-0 flex-1">
113
+ <span className="block text-[10px] uppercase tracking-wide text-muted-foreground">
114
+ {label}
115
+ </span>
116
+ {href ? (
117
+ <a
118
+ href={href}
119
+ className="block truncate text-[13px] text-primary hover:underline"
120
+ >
121
+ {value}
122
+ </a>
123
+ ) : (
124
+ <span className="block truncate text-[13px]">{value}</span>
125
+ )}
126
+ </span>
127
+ <button
128
+ type="button"
129
+ onClick={handleCopy}
130
+ className="shrink-0 rounded p-1 text-muted-foreground hover:text-primary hover:bg-background transition-colors"
131
+ title={copied ? 'Copied!' : `Copy ${label.toLowerCase()}`}
132
+ aria-label={`Copy ${label.toLowerCase()}`}
133
+ >
134
+ {copied ? (
135
+ <Check className="size-3.5 text-green-600" />
136
+ ) : (
137
+ <Copy className="size-3.5" />
138
+ )}
139
+ </button>
140
+ </div>
68
141
  )
69
142
  }
@@ -0,0 +1,43 @@
1
+ import { ExternalLink, Github } from 'lucide-react'
2
+ import { useUiSettings } from '~/context/UiSettingsContext'
3
+
4
+ /**
5
+ * Subtle attribution line for the home view (author + repo links). Content is
6
+ * supplied by the consuming app via `UiSettings.attribution` — the OSS core
7
+ * hard-codes nothing company-specific. Renders nothing when unset.
8
+ *
9
+ * `kind: 'oss'` links get a GitHub mark; everything else gets a generic
10
+ * external-link icon (proprietary repos, etc.).
11
+ */
12
+ export function AttributionFooter() {
13
+ const { attribution } = useUiSettings()
14
+ if (!attribution || (!attribution.madeBy && !attribution.links?.length)) {
15
+ return null
16
+ }
17
+
18
+ return (
19
+ <footer className="mt-16 border-t border-border/60 pt-5 text-center text-[12.5px] text-muted-foreground">
20
+ {attribution.madeBy && <span>{attribution.madeBy}</span>}
21
+ {attribution.links && attribution.links.length > 0 && (
22
+ <div className="mt-1.5 flex flex-wrap items-center justify-center gap-x-4 gap-y-1">
23
+ {attribution.links.map((link) => (
24
+ <a
25
+ key={link.url}
26
+ href={link.url}
27
+ target="_blank"
28
+ rel="noopener noreferrer"
29
+ className="inline-flex items-center gap-1 hover:text-primary transition-colors"
30
+ >
31
+ {link.kind === 'oss' ? (
32
+ <Github className="size-3.5" />
33
+ ) : (
34
+ <ExternalLink className="size-3.5" />
35
+ )}
36
+ {link.label}
37
+ </a>
38
+ ))}
39
+ </div>
40
+ )}
41
+ </footer>
42
+ )
43
+ }
@@ -29,12 +29,15 @@ export function LauncherDetailPanel({
29
29
  onClick={onClose}
30
30
  className="fixed inset-0 -z-10 bg-black/40 backdrop-blur-[2px] animate-in fade-in"
31
31
  />
32
- {/* centered card */}
32
+ {/* centered card — wide enough for data tables (sub-resources have 5
33
+ columns incl. "Access Contacts"/"AWS Account" that wrapped at 760px);
34
+ prose inside AppDetails is width-capped separately so it stays
35
+ readable. Caps at 94vw so it never touches the edges. */}
33
36
  <div
34
37
  role="dialog"
35
38
  aria-modal="true"
36
39
  aria-label={`${app.displayName} details`}
37
- className="relative w-full max-w-[760px] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200"
40
+ className="relative w-full max-w-[min(1120px,94vw)] my-auto rounded-[var(--radius)] border border-border bg-background shadow-2xl animate-in fade-in zoom-in-95 duration-200"
38
41
  >
39
42
  <div className="max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7">
40
43
  <AppDetails
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
4
4
  import { cn } from '~/lib/utils'
5
5
  import { useAppClickHistory } from '../../hooks/useAppClickHistory'
6
6
  import { markdownToPlainText } from '../../utils/markdownToPlainText'
7
+ import { AttributionFooter } from './AttributionFooter'
7
8
  import { ResourceIcon } from './ResourceIcon'
8
9
  import { searchResources } from '../../utils/searchApps'
9
10
 
@@ -537,6 +538,8 @@ export function LauncherHome({
537
538
  </div>
538
539
  </div>
539
540
  </section>
541
+
542
+ <AttributionFooter />
540
543
  </>
541
544
  )}
542
545
  </div>
@@ -13,4 +13,19 @@ export interface UiSettings {
13
13
  frontendBuildId?: string
14
14
  /** PWA auto-update configuration (idle timeout, check interval, debug) */
15
15
  pwaAutoUpdate?: PwaAutoUpdateOptions
16
+ /**
17
+ * Optional attribution shown as a subtle footer line on the home view.
18
+ * Kept generic here (no hard-coded names/links) so the OSS core stays
19
+ * vendor-neutral; the consuming app supplies its own author + repo links.
20
+ */
21
+ attribution?: {
22
+ /** e.g. "Made by <name>" — rendered verbatim. */
23
+ madeBy?: string
24
+ /** Labeled links; `kind` lets the UI hint open-source vs proprietary. */
25
+ links?: {
26
+ label: string
27
+ url: string
28
+ kind?: 'oss' | 'proprietary'
29
+ }[]
30
+ }
16
31
  }