@igstack/app-catalog-frontend-core 4.0.0-alpha-20260920161438 → 4.0.0-alpha-20260920174621
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/catalog/AppDetailPanel.focus.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/components/InlineEditableField.escape.test.d.ts +0 -0
- package/dist/esm/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.d.ts +0 -0
- package/dist/esm/__tests__/stylesheetFonts.test.d.ts +1 -0
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +2 -2
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/InlineEditableField.js +1 -0
- package/dist/esm/modules/appCatalog/ui/components/InlineEditableField.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/index.css +8 -3
- package/package.json +4 -4
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.focus.test.tsx +36 -0
- package/src/__tests__/modules/appCatalog/ui/components/InlineEditableField.escape.test.tsx +33 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +67 -0
- package/src/__tests__/stylesheetFonts.test.ts +49 -0
- package/src/index.css +8 -3
- package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +6 -1
- package/src/modules/appCatalog/ui/components/InlineEditableField.tsx +4 -0
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +6 -1
|
File without changes
|
package/dist/esm/__tests__/modules/appCatalog/ui/components/InlineEditableField.escape.test.d.ts
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -14,8 +14,8 @@ function AppDetailPanel({
|
|
|
14
14
|
const { approvalMethods } = useAppCatalogContext();
|
|
15
15
|
const dialogRef = useRef(null);
|
|
16
16
|
useEffect(() => {
|
|
17
|
-
|
|
18
|
-
(
|
|
17
|
+
const dialog = dialogRef.current;
|
|
18
|
+
if (dialog && !dialog.contains(document.activeElement)) dialog.focus();
|
|
19
19
|
}, []);
|
|
20
20
|
return /* @__PURE__ */ jsxs("div", { className: "fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-6 md:p-10", children: [
|
|
21
21
|
/* @__PURE__ */ jsx(
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppDetailPanel.js","sources":["../../../../../../src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { X } from 'lucide-react'\nimport { useEffect, useRef } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { SubResourceDetailPanel } from '../components/SubResourceDetailPanel'\nimport { AppDetails } from '../detail/AppDetails'\n\n/**\n * Centered detail card over the catalog (#38, item B). Renders the rich\n * AppDetails (access-hero, sub-resources, tiers) as a large centered modal card\n * over the catalog backdrop.\n *\n * When a sub-resource is selected (`?sub=<slug>` — e.g. the user clicked a\n * matched sub-resource row in the search results) the card shows that\n * sub-resource's own detail, with its two-step access chain and a back link to\n * the parent.\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 AppDetailPanel({\n app,\n subResource,\n onClose,\n onAppClick,\n onBackToParent,\n}: {\n app: Resource\n /** Selected child of `app`, when the URL carries `?sub=<slug>`. */\n subResource?: Resource | null\n onClose: () => void\n onAppClick?: (app: Resource) => void\n /** Clears `?sub=` and returns to the parent's detail. */\n onBackToParent?: () => void\n}) {\n const { approvalMethods } = useAppCatalogContext()\n const dialogRef = useRef<HTMLDivElement>(null)\n\n // Focus the dialog on mount so Esc hotkeys fire immediately, even when\n // the card was opened by a mouse click (which leaves focus on the grid row).\n useEffect(() => {\n dialogRef.current
|
|
1
|
+
{"version":3,"file":"AppDetailPanel.js","sources":["../../../../../../src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx"],"sourcesContent":["import type { Resource } from '@igstack/app-catalog-backend-core'\nimport { X } from 'lucide-react'\nimport { useEffect, useRef } from 'react'\nimport { useAppCatalogContext } from '../../context/AppCatalogContext'\nimport { SubResourceDetailPanel } from '../components/SubResourceDetailPanel'\nimport { AppDetails } from '../detail/AppDetails'\n\n/**\n * Centered detail card over the catalog (#38, item B). Renders the rich\n * AppDetails (access-hero, sub-resources, tiers) as a large centered modal card\n * over the catalog backdrop.\n *\n * When a sub-resource is selected (`?sub=<slug>` — e.g. the user clicked a\n * matched sub-resource row in the search results) the card shows that\n * sub-resource's own detail, with its two-step access chain and a back link to\n * the parent.\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 AppDetailPanel({\n app,\n subResource,\n onClose,\n onAppClick,\n onBackToParent,\n}: {\n app: Resource\n /** Selected child of `app`, when the URL carries `?sub=<slug>`. */\n subResource?: Resource | null\n onClose: () => void\n onAppClick?: (app: Resource) => void\n /** Clears `?sub=` and returns to the parent's detail. */\n onBackToParent?: () => void\n}) {\n const { approvalMethods } = useAppCatalogContext()\n const dialogRef = useRef<HTMLDivElement>(null)\n\n // Focus the dialog on mount so Esc hotkeys fire immediately, even when\n // the card was opened by a mouse click (which leaves focus on the grid row).\n //\n // Unless something inside already took the caret — the Quick Jump field does,\n // and it has the better claim: a child's effect runs before its parent's, so\n // without this guard the card would take focus straight back off it.\n useEffect(() => {\n const dialog = dialogRef.current\n if (dialog && !dialog.contains(document.activeElement)) dialog.focus()\n }, [])\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 ref={dialogRef}\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label={`${subResource?.displayName ?? app.displayName} details`}\n tabIndex={-1}\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 outline-none\"\n >\n <button\n type=\"button\"\n aria-label=\"Close\"\n onClick={onClose}\n className=\"absolute top-3 right-3 z-10 rounded-full p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors\"\n >\n <X className=\"size-4\" />\n </button>\n <div className=\"max-h-[85vh] overflow-y-auto px-6 py-5 sm:px-8 sm:py-7\">\n {subResource ? (\n <SubResourceDetailPanel\n subResource={subResource}\n parent={app}\n approvalMethods={approvalMethods}\n onBack={onBackToParent ?? onClose}\n />\n ) : (\n <AppDetails\n app={app}\n onAppClick={onAppClick}\n onClosePanel={onClose}\n />\n )}\n </div>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":";;;;;;AAsBO,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAQG;AACD,QAAM,EAAE,gBAAA,IAAoB,qBAAA;AAC5B,QAAM,YAAY,OAAuB,IAAI;AAQ7C,YAAU,MAAM;AACd,UAAM,SAAS,UAAU;AACzB,QAAI,UAAU,CAAC,OAAO,SAAS,SAAS,aAAa,UAAU,MAAA;AAAA,EACjE,GAAG,CAAA,CAAE;AAEL,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,KAAK;AAAA,QACL,MAAK;AAAA,QACL,cAAW;AAAA,QACX,cAAY,IAAG,2CAAa,gBAAe,IAAI,WAAW;AAAA,QAC1D,UAAU;AAAA,QACV,WAAU;AAAA,QAEV,UAAA;AAAA,UAAA;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAK;AAAA,cACL,cAAW;AAAA,cACX,SAAS;AAAA,cACT,WAAU;AAAA,cAEV,UAAA,oBAAC,GAAA,EAAE,WAAU,SAAA,CAAS;AAAA,YAAA;AAAA,UAAA;AAAA,UAExB,oBAAC,OAAA,EAAI,WAAU,0DACZ,UAAA,cACC;AAAA,YAAC;AAAA,YAAA;AAAA,cACC;AAAA,cACA,QAAQ;AAAA,cACR;AAAA,cACA,QAAQ,kBAAkB;AAAA,YAAA;AAAA,UAAA,IAG5B;AAAA,YAAC;AAAA,YAAA;AAAA,cACC;AAAA,cACA;AAAA,cACA,cAAc;AAAA,YAAA;AAAA,UAAA,EAChB,CAEJ;AAAA,QAAA;AAAA,MAAA;AAAA,IAAA;AAAA,EACF,GACF;AAEJ;"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"InlineEditableField.js","sources":["../../../../../../src/modules/appCatalog/ui/components/InlineEditableField.tsx"],"sourcesContent":["import { Pencil } from 'lucide-react'\nimport * as React from 'react'\n\nimport { cn } from '~/lib/utils'\nimport { Button } from '~/ui/button'\n\nexport interface InlineEditableFieldProps {\n value: string\n onSave: (value: string) => void\n /** Called when user cancels (Escape) without saving */\n onCancel?: () => void\n multiline?: boolean\n placeholder?: string\n className?: string\n /** Start in edit mode (e.g. for new list items) */\n initialEditMode?: boolean\n /** Optional label for view mode (e.g. render as link) */\n renderView?: (value: string) => React.ReactNode\n /** Optional class for the view container (e.g. for group hover) */\n viewClassName?: string\n}\n\nexport function InlineEditableField({\n value,\n onSave,\n multiline = false,\n placeholder = '',\n className,\n renderView,\n viewClassName,\n initialEditMode = false,\n onCancel,\n}: InlineEditableFieldProps) {\n const [isEditing, setIsEditing] = React.useState(initialEditMode)\n // Track local edit state separately from prop\n const [localEditValue, setLocalEditValue] = React.useState<string | null>(\n null,\n )\n const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(null)\n\n // Use localEditValue if set, otherwise use value prop\n const editValue = localEditValue ?? value\n\n React.useEffect(() => {\n if (isEditing) {\n inputRef.current?.focus()\n inputRef.current?.select()\n }\n }, [isEditing])\n\n const handleSave = () => {\n const trimmed = editValue.trim()\n if (trimmed !== value) {\n onSave(trimmed)\n } else if (trimmed === '' && onCancel) {\n onCancel()\n }\n setLocalEditValue(null)\n setIsEditing(false)\n }\n\n const handleCancel = () => {\n setLocalEditValue(null)\n setIsEditing(false)\n inputRef.current?.blur()\n onCancel?.()\n }\n\n const handleKeyDown = (\n e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,\n ) => {\n if (e.key === 'Enter' && !multiline) {\n e.preventDefault()\n handleSave()\n }\n if (e.key === 'Escape') {\n handleCancel()\n }\n }\n\n if (isEditing) {\n const inputProps = {\n ref: inputRef as React.RefObject<HTMLInputElement & HTMLTextAreaElement>,\n value: editValue,\n onChange: (\n e: React.ChangeEvent<HTMLInputElement & HTMLTextAreaElement>,\n ) => setLocalEditValue(e.target.value),\n onBlur: handleSave,\n onKeyDown: handleKeyDown,\n placeholder,\n className: cn(\n 'w-full rounded-md border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring',\n className,\n ),\n }\n if (multiline) {\n return (\n <textarea\n {...inputProps}\n ref={inputRef as React.RefObject<HTMLTextAreaElement>}\n rows={4}\n />\n )\n }\n return <input type=\"text\" {...inputProps} />\n }\n\n const display = value || placeholder || '—'\n return (\n <span className={cn('group inline-flex items-center gap-1', viewClassName)}>\n {renderView ? renderView(value) : <span>{display}</span>}\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"opacity-0 group-hover:opacity-100 transition-opacity shrink-0\"\n aria-label=\"Edit\"\n onClick={() => setIsEditing(true)}\n >\n <Pencil className=\"size-3.5\" />\n </Button>\n </span>\n )\n}\n"],"names":[],"mappings":";;;;;AAsBO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ,cAAc;AAAA,EACd;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB;AACF,GAA6B;AAC3B,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAS,eAAe;AAEhE,QAAM,CAAC,gBAAgB,iBAAiB,IAAI,MAAM;AAAA,IAChD;AAAA,EAAA;AAEF,QAAM,WAAW,MAAM,OAA+C,IAAI;AAG1E,QAAM,YAAY,kBAAkB;AAEpC,QAAM,UAAU,MAAM;;AACpB,QAAI,WAAW;AACb,qBAAS,YAAT,mBAAkB;AAClB,qBAAS,YAAT,mBAAkB;AAAA,IACpB;AAAA,EACF,GAAG,CAAC,SAAS,CAAC;AAEd,QAAM,aAAa,MAAM;AACvB,UAAM,UAAU,UAAU,KAAA;AAC1B,QAAI,YAAY,OAAO;AACrB,aAAO,OAAO;AAAA,IAChB,WAAW,YAAY,MAAM,UAAU;AACrC,eAAA;AAAA,IACF;AACA,sBAAkB,IAAI;AACtB,iBAAa,KAAK;AAAA,EACpB;AAEA,QAAM,eAAe,MAAM;;AACzB,sBAAkB,IAAI;AACtB,iBAAa,KAAK;AAClB,mBAAS,YAAT,mBAAkB;AAClB;AAAA,EACF;AAEA,QAAM,gBAAgB,CACpB,MACG;AACH,QAAI,EAAE,QAAQ,WAAW,CAAC,WAAW;AACnC,QAAE,eAAA;AACF,iBAAA;AAAA,IACF;AACA,QAAI,EAAE,QAAQ,UAAU;
|
|
1
|
+
{"version":3,"file":"InlineEditableField.js","sources":["../../../../../../src/modules/appCatalog/ui/components/InlineEditableField.tsx"],"sourcesContent":["import { Pencil } from 'lucide-react'\nimport * as React from 'react'\n\nimport { cn } from '~/lib/utils'\nimport { Button } from '~/ui/button'\n\nexport interface InlineEditableFieldProps {\n value: string\n onSave: (value: string) => void\n /** Called when user cancels (Escape) without saving */\n onCancel?: () => void\n multiline?: boolean\n placeholder?: string\n className?: string\n /** Start in edit mode (e.g. for new list items) */\n initialEditMode?: boolean\n /** Optional label for view mode (e.g. render as link) */\n renderView?: (value: string) => React.ReactNode\n /** Optional class for the view container (e.g. for group hover) */\n viewClassName?: string\n}\n\nexport function InlineEditableField({\n value,\n onSave,\n multiline = false,\n placeholder = '',\n className,\n renderView,\n viewClassName,\n initialEditMode = false,\n onCancel,\n}: InlineEditableFieldProps) {\n const [isEditing, setIsEditing] = React.useState(initialEditMode)\n // Track local edit state separately from prop\n const [localEditValue, setLocalEditValue] = React.useState<string | null>(\n null,\n )\n const inputRef = React.useRef<HTMLInputElement | HTMLTextAreaElement>(null)\n\n // Use localEditValue if set, otherwise use value prop\n const editValue = localEditValue ?? value\n\n React.useEffect(() => {\n if (isEditing) {\n inputRef.current?.focus()\n inputRef.current?.select()\n }\n }, [isEditing])\n\n const handleSave = () => {\n const trimmed = editValue.trim()\n if (trimmed !== value) {\n onSave(trimmed)\n } else if (trimmed === '' && onCancel) {\n onCancel()\n }\n setLocalEditValue(null)\n setIsEditing(false)\n }\n\n const handleCancel = () => {\n setLocalEditValue(null)\n setIsEditing(false)\n inputRef.current?.blur()\n onCancel?.()\n }\n\n const handleKeyDown = (\n e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>,\n ) => {\n if (e.key === 'Enter' && !multiline) {\n e.preventDefault()\n handleSave()\n }\n if (e.key === 'Escape') {\n // Esc belongs to the edit while one is open. Unstopped it would reach the\n // detail card's own Esc handler and close the whole panel behind the\n // cancelled edit.\n e.stopPropagation()\n handleCancel()\n }\n }\n\n if (isEditing) {\n const inputProps = {\n ref: inputRef as React.RefObject<HTMLInputElement & HTMLTextAreaElement>,\n value: editValue,\n onChange: (\n e: React.ChangeEvent<HTMLInputElement & HTMLTextAreaElement>,\n ) => setLocalEditValue(e.target.value),\n onBlur: handleSave,\n onKeyDown: handleKeyDown,\n placeholder,\n className: cn(\n 'w-full rounded-md border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring',\n className,\n ),\n }\n if (multiline) {\n return (\n <textarea\n {...inputProps}\n ref={inputRef as React.RefObject<HTMLTextAreaElement>}\n rows={4}\n />\n )\n }\n return <input type=\"text\" {...inputProps} />\n }\n\n const display = value || placeholder || '—'\n return (\n <span className={cn('group inline-flex items-center gap-1', viewClassName)}>\n {renderView ? renderView(value) : <span>{display}</span>}\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"opacity-0 group-hover:opacity-100 transition-opacity shrink-0\"\n aria-label=\"Edit\"\n onClick={() => setIsEditing(true)}\n >\n <Pencil className=\"size-3.5\" />\n </Button>\n </span>\n )\n}\n"],"names":[],"mappings":";;;;;AAsBO,SAAS,oBAAoB;AAAA,EAClC;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ,cAAc;AAAA,EACd;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB;AACF,GAA6B;AAC3B,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAS,eAAe;AAEhE,QAAM,CAAC,gBAAgB,iBAAiB,IAAI,MAAM;AAAA,IAChD;AAAA,EAAA;AAEF,QAAM,WAAW,MAAM,OAA+C,IAAI;AAG1E,QAAM,YAAY,kBAAkB;AAEpC,QAAM,UAAU,MAAM;;AACpB,QAAI,WAAW;AACb,qBAAS,YAAT,mBAAkB;AAClB,qBAAS,YAAT,mBAAkB;AAAA,IACpB;AAAA,EACF,GAAG,CAAC,SAAS,CAAC;AAEd,QAAM,aAAa,MAAM;AACvB,UAAM,UAAU,UAAU,KAAA;AAC1B,QAAI,YAAY,OAAO;AACrB,aAAO,OAAO;AAAA,IAChB,WAAW,YAAY,MAAM,UAAU;AACrC,eAAA;AAAA,IACF;AACA,sBAAkB,IAAI;AACtB,iBAAa,KAAK;AAAA,EACpB;AAEA,QAAM,eAAe,MAAM;;AACzB,sBAAkB,IAAI;AACtB,iBAAa,KAAK;AAClB,mBAAS,YAAT,mBAAkB;AAClB;AAAA,EACF;AAEA,QAAM,gBAAgB,CACpB,MACG;AACH,QAAI,EAAE,QAAQ,WAAW,CAAC,WAAW;AACnC,QAAE,eAAA;AACF,iBAAA;AAAA,IACF;AACA,QAAI,EAAE,QAAQ,UAAU;AAItB,QAAE,gBAAA;AACF,mBAAA;AAAA,IACF;AAAA,EACF;AAEA,MAAI,WAAW;AACb,UAAM,aAAa;AAAA,MACjB,KAAK;AAAA,MACL,OAAO;AAAA,MACP,UAAU,CACR,MACG,kBAAkB,EAAE,OAAO,KAAK;AAAA,MACrC,QAAQ;AAAA,MACR,WAAW;AAAA,MACX;AAAA,MACA,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MAAA;AAAA,IACF;AAEF,QAAI,WAAW;AACb,aACE;AAAA,QAAC;AAAA,QAAA;AAAA,UACE,GAAG;AAAA,UACJ,KAAK;AAAA,UACL,MAAM;AAAA,QAAA;AAAA,MAAA;AAAA,IAGZ;AACA,WAAO,oBAAC,SAAA,EAAM,MAAK,QAAQ,GAAG,YAAY;AAAA,EAC5C;AAEA,QAAM,UAAU,SAAS,eAAe;AACxC,8BACG,QAAA,EAAK,WAAW,GAAG,wCAAwC,aAAa,GACtE,UAAA;AAAA,IAAA,aAAa,WAAW,KAAK,IAAI,oBAAC,UAAM,UAAA,SAAQ;AAAA,IACjD;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,MAAK;AAAA,QACL,SAAQ;AAAA,QACR,MAAK;AAAA,QACL,WAAU;AAAA,QACV,cAAW;AAAA,QACX,SAAS,MAAM,aAAa,IAAI;AAAA,QAEhC,UAAA,oBAAC,QAAA,EAAO,WAAU,WAAA,CAAW;AAAA,MAAA;AAAA,IAAA;AAAA,EAC/B,GACF;AAEJ;"}
|
|
@@ -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 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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"}
|
|
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 //\n // From inside a text field too: the Quick Jump field holds the caret from the\n // moment the card opens, so the default \"ignore form tags\" left the card with\n // no keyboard way out. A field that wants Esc for itself stops the event —\n // InlineEditableField does, so Esc there cancels the edit and nothing more.\n useHotkeys(\n 'escape',\n () => {\n onClosePanel()\n },\n { enabled: !isGalleryOpen, enableOnFormTags: ['input'] },\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/dist/index.css
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
/* @import url(./modules/resourceJump/ui/JumpMainButton.css); */
|
|
2
|
-
@import 'tailwindcss';
|
|
3
1
|
/* Warm, crafted type to match the hand-drawn logo (see issue #31 redesign):
|
|
4
|
-
Fraunces = characterful display serif, Nunito Sans = friendly readable body.
|
|
2
|
+
Fraunces = characterful display serif, Nunito Sans = friendly readable body.
|
|
3
|
+
|
|
4
|
+
This has to come before every other at-rule: a remote @import that follows
|
|
5
|
+
any rule is invalid where it stands, so the minifier drops it — and the only
|
|
6
|
+
symptom is body text quietly falling back to a system face on machines that
|
|
7
|
+
don't happen to have the family installed. */
|
|
5
8
|
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Nunito+Sans:wght@400;500;600;700;800&display=swap');
|
|
9
|
+
/* @import url(./modules/resourceJump/ui/JumpMainButton.css); */
|
|
10
|
+
@import 'tailwindcss';
|
|
6
11
|
/* Other module imports this css, and we need to tell tailwind to scan our tsx files */
|
|
7
12
|
@source ".";
|
|
8
13
|
|
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-20260920174621",
|
|
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-20260920174621"
|
|
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-20260920174621"
|
|
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": "a297e008f756aa8b200884a84bcb43d999519546",
|
|
153
153
|
"scripts": {
|
|
154
154
|
"build": "vite build",
|
|
155
155
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import '@testing-library/jest-dom/vitest'
|
|
6
|
+
|
|
7
|
+
// The panel's own contents are not what this test is about — only whether the
|
|
8
|
+
// panel lets a child keep the caret. Stands in for the Quick Jump field.
|
|
9
|
+
vi.mock('~/modules/appCatalog/ui/detail/AppDetails', () => ({
|
|
10
|
+
AppDetails: () => {
|
|
11
|
+
const ref = React.useRef<HTMLInputElement>(null)
|
|
12
|
+
React.useEffect(() => ref.current?.focus(), [])
|
|
13
|
+
return <input ref={ref} aria-label="case id" />
|
|
14
|
+
},
|
|
15
|
+
}))
|
|
16
|
+
vi.mock('~/modules/appCatalog/ui/components/SubResourceDetailPanel', () => ({
|
|
17
|
+
SubResourceDetailPanel: () => null,
|
|
18
|
+
}))
|
|
19
|
+
vi.mock('~/modules/appCatalog/context/AppCatalogContext', () => ({
|
|
20
|
+
useAppCatalogContext: vi.fn(() => ({ approvalMethods: [] })),
|
|
21
|
+
}))
|
|
22
|
+
|
|
23
|
+
const { AppDetailPanel } =
|
|
24
|
+
await import('~/modules/appCatalog/ui/catalog/AppDetailPanel')
|
|
25
|
+
|
|
26
|
+
const app = { slug: 'tracker', displayName: 'Tracker' } as Resource
|
|
27
|
+
|
|
28
|
+
describe('AppDetailPanel — focus on open', () => {
|
|
29
|
+
// The card focuses itself so Esc works when it was opened by a mouse click.
|
|
30
|
+
// A child that already took the caret has the better claim on it.
|
|
31
|
+
it('leaves focus where a child put it', () => {
|
|
32
|
+
render(<AppDetailPanel app={app} onClose={vi.fn()} />)
|
|
33
|
+
|
|
34
|
+
expect(screen.getByLabelText('case id')).toHaveFocus()
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import '@testing-library/jest-dom/vitest'
|
|
4
|
+
|
|
5
|
+
import { InlineEditableField } from '~/modules/appCatalog/ui/components/InlineEditableField'
|
|
6
|
+
|
|
7
|
+
describe('InlineEditableField — Esc', () => {
|
|
8
|
+
const onDocumentEscape = vi.fn()
|
|
9
|
+
const listener = (e: KeyboardEvent) => {
|
|
10
|
+
if (e.key === 'Escape') onDocumentEscape()
|
|
11
|
+
}
|
|
12
|
+
document.addEventListener('keydown', listener)
|
|
13
|
+
afterEach(() => document.removeEventListener('keydown', listener))
|
|
14
|
+
|
|
15
|
+
// The detail card closes on a document-level Esc. While an edit is open, Esc
|
|
16
|
+
// is the edit's — cancelling one must not also close the panel around it.
|
|
17
|
+
it('cancels the edit without reaching the document', () => {
|
|
18
|
+
const onCancel = vi.fn()
|
|
19
|
+
render(
|
|
20
|
+
<InlineEditableField
|
|
21
|
+
value="Tracker"
|
|
22
|
+
onSave={vi.fn()}
|
|
23
|
+
onCancel={onCancel}
|
|
24
|
+
initialEditMode
|
|
25
|
+
/>,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
fireEvent.keyDown(screen.getByDisplayValue('Tracker'), { key: 'Escape' })
|
|
29
|
+
|
|
30
|
+
expect(onCancel).toHaveBeenCalled()
|
|
31
|
+
expect(onDocumentEscape).not.toHaveBeenCalled()
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { fireEvent, 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 tags test: AppDetails drags the whole panel data layer in
|
|
7
|
+
// and none of it decides what Esc does.
|
|
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 = {
|
|
38
|
+
id: 'tracker',
|
|
39
|
+
slug: 'tracker',
|
|
40
|
+
displayName: 'Tracker',
|
|
41
|
+
appUrl: 'https://tracker.example.com',
|
|
42
|
+
quickJumps: [
|
|
43
|
+
{
|
|
44
|
+
identity: 'case id',
|
|
45
|
+
title: 'Tracker — view case',
|
|
46
|
+
url: '{{baseHost}}/case/{{value}}',
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
} as Resource
|
|
50
|
+
|
|
51
|
+
// The card takes focus on open so Esc closes it. The Quick Jump field takes that
|
|
52
|
+
// focus instead — so Esc has to keep working from inside the field, or the
|
|
53
|
+
// feature costs the keyboard its way out.
|
|
54
|
+
describe('AppDetails — Esc from the quick jump field', () => {
|
|
55
|
+
it('closes the panel', () => {
|
|
56
|
+
const onClosePanel = vi.fn()
|
|
57
|
+
render(<AppDetails app={app} onClosePanel={onClosePanel} />)
|
|
58
|
+
|
|
59
|
+
// `code` as well as `key`: the hotkey library matches on the physical code.
|
|
60
|
+
fireEvent.keyDown(screen.getByLabelText('case id'), {
|
|
61
|
+
key: 'Escape',
|
|
62
|
+
code: 'Escape',
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
expect(onClosePanel).toHaveBeenCalled()
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A font family named in a theme token but never requested is invisible on any
|
|
6
|
+
* machine that happens to have it installed — which is every developer's, and
|
|
7
|
+
* no user's. So assert the wiring instead of the rendering.
|
|
8
|
+
*/
|
|
9
|
+
// The path is relative to the package root, which is where vitest runs.
|
|
10
|
+
const css = readFileSync('src/index.css', 'utf8')
|
|
11
|
+
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The first family of every non-aliased declaration of a token. The first one
|
|
15
|
+
* is the webfont; everything after it is a fallback by definition.
|
|
16
|
+
*/
|
|
17
|
+
function primaryFamilies(token: string): string[] {
|
|
18
|
+
const declarations = [
|
|
19
|
+
...css.matchAll(new RegExp(`--${token}:\\s*([^;]+);`, 'g')),
|
|
20
|
+
]
|
|
21
|
+
.map((match) => (match[1] ?? '').trim())
|
|
22
|
+
.filter((value) => !value.startsWith('var('))
|
|
23
|
+
expect(declarations.length).toBeGreaterThan(0)
|
|
24
|
+
return declarations.map((value) =>
|
|
25
|
+
(value.split(',')[0] ?? '').trim().replace(/^'|'$/g, ''),
|
|
26
|
+
)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('web fonts', () => {
|
|
30
|
+
it('requests its families before any other at-rule', () => {
|
|
31
|
+
// Anywhere later it is invalid where it stands, so the minifier drops it
|
|
32
|
+
// and the only symptom is a silent fallback to a system face.
|
|
33
|
+
const atRules = [...withoutComments.matchAll(/^@[\w-]+[^;{]*/gm)].map(
|
|
34
|
+
(match) => match[0].trim(),
|
|
35
|
+
)
|
|
36
|
+
expect(atRules[0]).toMatch(/^@import url\('https:\/\/fonts\./)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it.each(['font-sans', 'font-serif'])(
|
|
40
|
+
'--%s names a family the stylesheet actually requests',
|
|
41
|
+
(token) => {
|
|
42
|
+
for (const family of primaryFamilies(token)) {
|
|
43
|
+
// A system stack has nothing to download.
|
|
44
|
+
if (family.startsWith('ui-') || family === 'system-ui') continue
|
|
45
|
+
expect(withoutComments).toContain(`family=${family.replace(/ /g, '+')}`)
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
})
|
package/src/index.css
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
-
/* @import url(./modules/resourceJump/ui/JumpMainButton.css); */
|
|
2
|
-
@import 'tailwindcss';
|
|
3
1
|
/* Warm, crafted type to match the hand-drawn logo (see issue #31 redesign):
|
|
4
|
-
Fraunces = characterful display serif, Nunito Sans = friendly readable body.
|
|
2
|
+
Fraunces = characterful display serif, Nunito Sans = friendly readable body.
|
|
3
|
+
|
|
4
|
+
This has to come before every other at-rule: a remote @import that follows
|
|
5
|
+
any rule is invalid where it stands, so the minifier drops it — and the only
|
|
6
|
+
symptom is body text quietly falling back to a system face on machines that
|
|
7
|
+
don't happen to have the family installed. */
|
|
5
8
|
@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600;9..144,700&family=Nunito+Sans:wght@400;500;600;700;800&display=swap');
|
|
9
|
+
/* @import url(./modules/resourceJump/ui/JumpMainButton.css); */
|
|
10
|
+
@import 'tailwindcss';
|
|
6
11
|
/* Other module imports this css, and we need to tell tailwind to scan our tsx files */
|
|
7
12
|
@source ".";
|
|
8
13
|
|
|
@@ -40,8 +40,13 @@ export function AppDetailPanel({
|
|
|
40
40
|
|
|
41
41
|
// Focus the dialog on mount so Esc hotkeys fire immediately, even when
|
|
42
42
|
// the card was opened by a mouse click (which leaves focus on the grid row).
|
|
43
|
+
//
|
|
44
|
+
// Unless something inside already took the caret — the Quick Jump field does,
|
|
45
|
+
// and it has the better claim: a child's effect runs before its parent's, so
|
|
46
|
+
// without this guard the card would take focus straight back off it.
|
|
43
47
|
useEffect(() => {
|
|
44
|
-
dialogRef.current
|
|
48
|
+
const dialog = dialogRef.current
|
|
49
|
+
if (dialog && !dialog.contains(document.activeElement)) dialog.focus()
|
|
45
50
|
}, [])
|
|
46
51
|
|
|
47
52
|
return (
|
|
@@ -74,6 +74,10 @@ export function InlineEditableField({
|
|
|
74
74
|
handleSave()
|
|
75
75
|
}
|
|
76
76
|
if (e.key === 'Escape') {
|
|
77
|
+
// Esc belongs to the edit while one is open. Unstopped it would reach the
|
|
78
|
+
// detail card's own Esc handler and close the whole panel behind the
|
|
79
|
+
// cancelled edit.
|
|
80
|
+
e.stopPropagation()
|
|
77
81
|
handleCancel()
|
|
78
82
|
}
|
|
79
83
|
}
|
|
@@ -217,12 +217,17 @@ export function AppDetails({
|
|
|
217
217
|
)
|
|
218
218
|
|
|
219
219
|
// Esc: close the details panel (only when gallery is NOT open)
|
|
220
|
+
//
|
|
221
|
+
// From inside a text field too: the Quick Jump field holds the caret from the
|
|
222
|
+
// moment the card opens, so the default "ignore form tags" left the card with
|
|
223
|
+
// no keyboard way out. A field that wants Esc for itself stops the event —
|
|
224
|
+
// InlineEditableField does, so Esc there cancels the edit and nothing more.
|
|
220
225
|
useHotkeys(
|
|
221
226
|
'escape',
|
|
222
227
|
() => {
|
|
223
228
|
onClosePanel()
|
|
224
229
|
},
|
|
225
|
-
{ enabled: !isGalleryOpen },
|
|
230
|
+
{ enabled: !isGalleryOpen, enableOnFormTags: ['input'] },
|
|
226
231
|
[isGalleryOpen, onClosePanel],
|
|
227
232
|
)
|
|
228
233
|
|