@igstack/app-catalog-frontend-core 1.0.1 → 2.0.0-alpha-20260914220838

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.
@@ -43,6 +43,45 @@ declare const TRPCProvider: import('react').FunctionComponent<{
43
43
  meta: object;
44
44
  }>;
45
45
  }>>;
46
+ comments: import('@trpc/server').TRPCBuiltRouter<{
47
+ ctx: import('@igstack/app-catalog-backend-core').AcTrpcContext;
48
+ meta: object;
49
+ errorShape: import('@trpc/server').TRPCDefaultErrorShape;
50
+ transformer: false;
51
+ }, import('@trpc/server').TRPCDecorateCreateRouterOptions<{
52
+ list: import('@trpc/server').TRPCQueryProcedure<{
53
+ input: {
54
+ resourceSlug: string;
55
+ };
56
+ output: import('@igstack/app-catalog-backend-core').CommentView[];
57
+ meta: object;
58
+ }>;
59
+ add: import('@trpc/server').TRPCMutationProcedure<{
60
+ input: {
61
+ resourceSlug: string;
62
+ body: string;
63
+ };
64
+ output: import('@igstack/app-catalog-backend-core').CommentView;
65
+ meta: object;
66
+ }>;
67
+ edit: import('@trpc/server').TRPCMutationProcedure<{
68
+ input: {
69
+ id: string;
70
+ body: string;
71
+ };
72
+ output: import('@igstack/app-catalog-backend-core').CommentView;
73
+ meta: object;
74
+ }>;
75
+ remove: import('@trpc/server').TRPCMutationProcedure<{
76
+ input: {
77
+ id: string;
78
+ };
79
+ output: {
80
+ id: string;
81
+ };
82
+ meta: object;
83
+ }>;
84
+ }>>;
46
85
  auth: import('@trpc/server').TRPCBuiltRouter<{
47
86
  ctx: import('@igstack/app-catalog-backend-core').AcTrpcContext;
48
87
  meta: {};
@@ -120,6 +159,45 @@ declare const TRPCProvider: import('react').FunctionComponent<{
120
159
  meta: object;
121
160
  }>;
122
161
  }>>;
162
+ comments: import('@trpc/server').TRPCBuiltRouter<{
163
+ ctx: import('@igstack/app-catalog-backend-core').AcTrpcContext;
164
+ meta: object;
165
+ errorShape: import('@trpc/server').TRPCDefaultErrorShape;
166
+ transformer: false;
167
+ }, import('@trpc/server').TRPCDecorateCreateRouterOptions<{
168
+ list: import('@trpc/server').TRPCQueryProcedure<{
169
+ input: {
170
+ resourceSlug: string;
171
+ };
172
+ output: import('@igstack/app-catalog-backend-core').CommentView[];
173
+ meta: object;
174
+ }>;
175
+ add: import('@trpc/server').TRPCMutationProcedure<{
176
+ input: {
177
+ resourceSlug: string;
178
+ body: string;
179
+ };
180
+ output: import('@igstack/app-catalog-backend-core').CommentView;
181
+ meta: object;
182
+ }>;
183
+ edit: import('@trpc/server').TRPCMutationProcedure<{
184
+ input: {
185
+ id: string;
186
+ body: string;
187
+ };
188
+ output: import('@igstack/app-catalog-backend-core').CommentView;
189
+ meta: object;
190
+ }>;
191
+ remove: import('@trpc/server').TRPCMutationProcedure<{
192
+ input: {
193
+ id: string;
194
+ };
195
+ output: {
196
+ id: string;
197
+ };
198
+ meta: object;
199
+ }>;
200
+ }>>;
123
201
  auth: import('@trpc/server').TRPCBuiltRouter<{
124
202
  ctx: import('@igstack/app-catalog-backend-core').AcTrpcContext;
125
203
  meta: {};
@@ -196,6 +274,45 @@ declare const TRPCProvider: import('react').FunctionComponent<{
196
274
  meta: object;
197
275
  }>;
198
276
  }>>;
277
+ comments: import('@trpc/server').TRPCBuiltRouter<{
278
+ ctx: import('@igstack/app-catalog-backend-core').AcTrpcContext;
279
+ meta: object;
280
+ errorShape: import('@trpc/server').TRPCDefaultErrorShape;
281
+ transformer: false;
282
+ }, import('@trpc/server').TRPCDecorateCreateRouterOptions<{
283
+ list: import('@trpc/server').TRPCQueryProcedure<{
284
+ input: {
285
+ resourceSlug: string;
286
+ };
287
+ output: import('@igstack/app-catalog-backend-core').CommentView[];
288
+ meta: object;
289
+ }>;
290
+ add: import('@trpc/server').TRPCMutationProcedure<{
291
+ input: {
292
+ resourceSlug: string;
293
+ body: string;
294
+ };
295
+ output: import('@igstack/app-catalog-backend-core').CommentView;
296
+ meta: object;
297
+ }>;
298
+ edit: import('@trpc/server').TRPCMutationProcedure<{
299
+ input: {
300
+ id: string;
301
+ body: string;
302
+ };
303
+ output: import('@igstack/app-catalog-backend-core').CommentView;
304
+ meta: object;
305
+ }>;
306
+ remove: import('@trpc/server').TRPCMutationProcedure<{
307
+ input: {
308
+ id: string;
309
+ };
310
+ output: {
311
+ id: string;
312
+ };
313
+ meta: object;
314
+ }>;
315
+ }>>;
199
316
  auth: import('@trpc/server').TRPCBuiltRouter<{
200
317
  ctx: import('@igstack/app-catalog-backend-core').AcTrpcContext;
201
318
  meta: {};
@@ -21,6 +21,7 @@ import { formatRelativeTime } from "../../utils/formatRelativeTime.js";
21
21
  import { TierVariantsSection } from "../components/TierVariantsSection.js";
22
22
  import { SubResourcesSection } from "../components/SubResourcesSection.js";
23
23
  import { getChildResources } from "../../utils/resolveHelpers.js";
24
+ import { CommentsSection } from "./CommentsSection.js";
24
25
  function getIconUrl(iconName) {
25
26
  return `/api/icons/${iconName}`;
26
27
  }
@@ -473,7 +474,8 @@ function AppDetails({
473
474
  }
474
475
  ) : /* @__PURE__ */ jsx("span", { className: "text-muted-foreground", children: "—" })
475
476
  ] }, index)) })
476
- ] })
477
+ ] }),
478
+ /* @__PURE__ */ jsx(CommentsSection, { appSlug: app.slug })
477
479
  ] }),
478
480
  /* @__PURE__ */ jsx(
479
481
  ScreenshotGallery,
@@ -1 +1 @@
1
- {"version":3,"file":"AppDetails.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/AppDetails.tsx"],"sourcesContent":["import type { Resource } 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 { 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 { 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'\n\nfunction getIconUrl(iconName: string): string {\n return `/api/icons/${iconName}`\n}\n\nfunction AppIcon({ app, className }: { app: Resource; className?: string }) {\n const [imageError, setImageError] = React.useState(false)\n\n // Use iconName from backend if available\n if (app.iconName && !imageError) {\n return (\n <div className={cn('size-12 shrink-0', className)}>\n <img\n src={getIconUrl(app.iconName)}\n alt={`${app.abbreviation || app.displayName} icon`}\n className=\"size-12 rounded-lg object-contain\"\n onError={() => setImageError(true)}\n />\n </div>\n )\n }\n\n // Fallback icon\n return (\n <div\n className={cn(\n 'flex items-center justify-center rounded-lg bg-primary/10 text-primary size-12 shrink-0',\n className,\n )}\n >\n <AppWindow className=\"size-6\" />\n </div>\n )\n}\n\nfunction AppScreenshot({ app }: { app: Resource }) {\n const [imageError, setImageError] = React.useState(false)\n const [isLoadingImage, setIsLoadingImage] = React.useState(true)\n\n // Check if app has screenshots\n const screenshotId = app.screenshotIds?.[0]\n if (!screenshotId) {\n return (\n <div className=\"w-full bg-muted/50 rounded-lg overflow-hidden flex items-center justify-center min-h-64\">\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n No screenshot available\n </div>\n </div>\n )\n }\n\n const screenshotImageUrl = `/api/screenshots/${screenshotId}?size=512`\n\n return (\n <div className=\"w-full flex justify-center\">\n <div className=\"rounded-lg overflow-hidden inline-flex items-center justify-center min-h-64\">\n {!imageError ? (\n <img\n src={screenshotImageUrl}\n alt={`${app.abbreviation || app.displayName} screenshot`}\n className=\"h-64 object-contain\"\n onError={() => {\n setImageError(true)\n setIsLoadingImage(false)\n }}\n onLoad={() => setIsLoadingImage(false)}\n />\n ) : null}\n {(imageError || isLoadingImage) && (\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n {isLoadingImage\n ? 'Loading screenshot...'\n : 'No screenshot available'}\n </div>\n )}\n </div>\n </div>\n )\n}\n\nfunction TiersAndSubResourcesPanel({ app }: { app: Resource }) {\n const { resources } = useAppCatalogContext()\n // #38 item C: the active catalog search term. When the user reached this app\n // by searching something that matched a child, seed the sub-resource filter\n // with it so the matched child is revealed.\n const { state: filterState } = useAppCatalogFilters()\n const appSubResources = React.useMemo(\n () => getChildResources(resources, app.slug),\n [resources, app.slug],\n )\n\n return (\n <>\n {app.tiers && app.tiers.length > 0 && (\n <div className=\"mt-6\">\n <TierVariantsSection tiers={app.tiers} />\n </div>\n )}\n {appSubResources.length > 0 && (\n <div className=\"mt-6\">\n <SubResourcesSection\n subResources={appSubResources}\n parentSlug={app.slug}\n initialSearch={filterState.searchValue}\n />\n </div>\n )}\n </>\n )\n}\n\nexport function AppDetails({\n app,\n onAppClick,\n onClosePanel,\n}: {\n app: Resource\n onAppClick?: (app: Resource) => void\n onClosePanel: () => void\n}) {\n const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)\n const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)\n const { approvalMethods, resources: allResources } = useAppCatalogContext()\n const { recordClick } = useAppClickHistory()\n const updateApp = useUpdateApp()\n const [draftSource, setDraftSource] = React.useState<string | null>(null)\n const user = useUser()\n const isAdmin = user?.isAdmin ?? false\n\n const sourceUrls: string[] =\n app.sources?.map((s) => (typeof s === 'string' ? s : s.url)) ?? []\n const displaySources =\n draftSource !== null ? [...sourceUrls, draftSource] : sourceUrls\n\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 */}\n <div className=\"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 <div className=\"mt-1 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 {url.replace(/https?:\\/\\//g, '')}\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=\"inline-flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors\"\n aria-label={'Open ' + app.displayName}\n >\n <ExternalLink className=\"size-3.5 shrink-0\" />\n Open{' '}\n <span className=\"max-w-[240px] truncate text-primary-foreground/70 text-xs font-normal\">\n {app.appUrl.replace(/https?:\\/\\//g, '')}\n </span>\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\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 {/* 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 — hero of the detail, shown before description */}\n <AccessRequestSection app={app} approvalMethods={approvalMethods} />\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 {/* 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 <div\n className=\"cursor-pointer 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 </div>\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 || link.url.replace(/https?:\\/\\//g, '')}\n </a>\n ))}\n </div>\n </div>\n )}\n\n {/* Tags */}\n {app.tags && app.tags.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Tags</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.tags.map((tag) => (\n <Badge key={tag} variant=\"secondary\" className=\"text-xs\">\n {tag}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Teams */}\n {app.teams && app.teams.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Teams</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.teams.map((team) => (\n <Badge key={team} variant=\"outline\" className=\"text-xs\">\n {team}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Technical information: AI-facing fields, de-emphasized/collapsed */}\n {(app.aiPrompt || app.aiMemory) && (\n <Accordion type=\"single\" collapsible className=\"mt-6\">\n <AccordionItem\n value=\"technical-info\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Technical information\n </AccordionTrigger>\n <AccordionContent className=\"pb-3 space-y-3\">\n {app.aiPrompt && (\n <div>\n <h4 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n AI Prompt\n </h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiPrompt}\n </p>\n </div>\n )}\n {app.aiMemory && (\n <div>\n <h4 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n AI Memory\n </h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiMemory}\n </p>\n </div>\n )}\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n )}\n\n {/* Metadata: Added / Updated */}\n {(app.createdAt || app.freshness?.lastCheckedAt) && (\n <div className=\"mt-6 flex flex-wrap gap-x-5 gap-y-1\">\n {app.createdAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={app.createdAt}\n >\n <CalendarPlus className=\"size-3\" />\n Added {formatRelativeTime(app.createdAt)}\n </span>\n )}\n {app.freshness?.lastCheckedAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={\n app.freshness.lastContentChangeAt\n ? `Content last changed ${app.freshness.lastContentChangeAt} · last checked ${app.freshness.lastCheckedAt}`\n : `Last checked ${app.freshness.lastCheckedAt}`\n }\n >\n <Clock className=\"size-3\" />\n {/* \"Updated\" means the content changed, not that a check ran -\n entries predating the field have none, so fall back. */}\n Updated{' '}\n {formatRelativeTime(\n app.freshness.lastContentChangeAt ??\n app.freshness.lastCheckedAt,\n )}\n {app.freshness.isStale && (\n <span className=\"italic opacity-75\">\n {' '}\n · may be out of date\n </span>\n )}\n </span>\n )}\n </div>\n )}\n\n {/* Sources */}\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Sources</h3>\n {isAdmin ? (\n <>\n <ul className=\"space-y-2\">\n {displaySources.map((url, index) => {\n const isDraft =\n draftSource !== null && index === sourceUrls.length\n return (\n <li\n key={isDraft ? 'draft' : `${index}-${url}`}\n className=\"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 {val.replace(/https?:\\/\\//g, '')}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\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 <li key={index} className=\"flex items-center gap-2 text-xs\">\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 items-center gap-1 truncate\"\n >\n {url.replace(/https?:\\/\\//g, '')}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\n </li>\n ))}\n </ul>\n )}\n </div>\n\n {/* Freshness now shown near the header (see \"Updated …\" above). */}\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":";;;;;;;;;;;;;;;;;;;;;;;AAoCA,SAAS,WAAW,UAA0B;AAC5C,SAAO,cAAc,QAAQ;AAC/B;AAEA,SAAS,QAAQ,EAAE,KAAK,aAAoD;AAC1E,QAAM,CAAC,YAAY,aAAa,IAAIA,eAAM,SAAS,KAAK;AAGxD,MAAI,IAAI,YAAY,CAAC,YAAY;AAC/B,+BACG,OAAA,EAAI,WAAW,GAAG,oBAAoB,SAAS,GAC9C,UAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,KAAK,WAAW,IAAI,QAAQ;AAAA,QAC5B,KAAK,GAAG,IAAI,gBAAgB,IAAI,WAAW;AAAA,QAC3C,WAAU;AAAA,QACV,SAAS,MAAM,cAAc,IAAI;AAAA,MAAA;AAAA,IAAA,GAErC;AAAA,EAEJ;AAGA,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MAAA;AAAA,MAGF,UAAA,oBAAC,WAAA,EAAU,WAAU,SAAA,CAAS;AAAA,IAAA;AAAA,EAAA;AAGpC;AAEA,SAAS,cAAc,EAAE,OAA0B;;AACjD,QAAM,CAAC,YAAY,aAAa,IAAIA,eAAM,SAAS,KAAK;AACxD,QAAM,CAAC,gBAAgB,iBAAiB,IAAIA,eAAM,SAAS,IAAI;AAG/D,QAAM,gBAAe,SAAI,kBAAJ,mBAAoB;AACzC,MAAI,CAAC,cAAc;AACjB,WACE,oBAAC,SAAI,WAAU,2FACb,8BAAC,OAAA,EAAI,WAAU,0FAAyF,UAAA,0BAAA,CAExG,EAAA,CACF;AAAA,EAEJ;AAEA,QAAM,qBAAqB,oBAAoB,YAAY;AAE3D,6BACG,OAAA,EAAI,WAAU,8BACb,UAAA,qBAAC,OAAA,EAAI,WAAU,+EACZ,UAAA;AAAA,IAAA,CAAC,aACA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,KAAK;AAAA,QACL,KAAK,GAAG,IAAI,gBAAgB,IAAI,WAAW;AAAA,QAC3C,WAAU;AAAA,QACV,SAAS,MAAM;AACb,wBAAc,IAAI;AAClB,4BAAkB,KAAK;AAAA,QACzB;AAAA,QACA,QAAQ,MAAM,kBAAkB,KAAK;AAAA,MAAA;AAAA,IAAA,IAErC;AAAA,KACF,cAAc,mBACd,oBAAC,OAAA,EAAI,WAAU,0FACZ,UAAA,iBACG,0BACA,0BAAA,CACN;AAAA,EAAA,EAAA,CAEJ,EAAA,CACF;AAEJ;AAEA,SAAS,0BAA0B,EAAE,OAA0B;AAC7D,QAAM,EAAE,UAAA,IAAc,qBAAA;AAItB,QAAM,EAAE,OAAO,YAAA,IAAgB,qBAAA;AAC/B,QAAM,kBAAkBA,eAAM;AAAA,IAC5B,MAAM,kBAAkB,WAAW,IAAI,IAAI;AAAA,IAC3C,CAAC,WAAW,IAAI,IAAI;AAAA,EAAA;AAGtB,SACE,qBAAA,UAAA,EACG,UAAA;AAAA,IAAA,IAAI,SAAS,IAAI,MAAM,SAAS,KAC/B,oBAAC,OAAA,EAAI,WAAU,QACb,UAAA,oBAAC,qBAAA,EAAoB,OAAO,IAAI,OAAO,GACzC;AAAA,IAED,gBAAgB,SAAS,KACxB,oBAAC,OAAA,EAAI,WAAU,QACb,UAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,cAAc;AAAA,QACd,YAAY,IAAI;AAAA,QAChB,eAAe,YAAY;AAAA,MAAA;AAAA,IAAA,EAC7B,CACF;AAAA,EAAA,GAEJ;AAEJ;AAEO,SAAS,WAAW;AAAA,EACzB;AAAA,EACA;AAAA,EACA;AACF,GAIG;;AACD,QAAM,CAAC,eAAe,gBAAgB,IAAIA,eAAM,SAAS,KAAK;AAC9D,QAAM,CAAC,qBAAqB,sBAAsB,IAAIA,eAAM,SAAS,CAAC;AACtE,QAAM,EAAE,iBAAiB,WAAW,aAAA,IAAiB,qBAAA;AACrD,QAAM,EAAE,YAAA,IAAgB,mBAAA;AACxB,QAAM,YAAY,aAAA;AAClB,QAAM,CAAC,aAAa,cAAc,IAAIA,eAAM,SAAwB,IAAI;AACxE,QAAM,OAAO,QAAA;AACb,QAAM,WAAU,6BAAM,YAAW;AAEjC,QAAM,eACJ,SAAI,YAAJ,mBAAa,IAAI,CAAC,MAAO,OAAO,MAAM,WAAW,IAAI,EAAE,SAAS,CAAA;AAClE,QAAM,iBACJ,gBAAgB,OAAO,CAAC,GAAG,YAAY,WAAW,IAAI;AAGxD;AAAA,IACE;AAAA,IACA,MAAM;;AACJ,YAAM,OAAMC,MAAA,SAAS,kBAAT,gBAAAA,IAAwB;AACpC,UACE,QAAQ,YACR,QAAQ,OACR,QAAQ,WACR,QAAQ,YACR,QAAQ;AAER;AAEF,UAAI,IAAI,iBAAiB,IAAI,cAAc,SAAS,GAAG;AACrD,+BAAuB,CAAC;AACxB,yBAAiB,IAAI;AAAA,MACvB;AAAA,IACF;AAAA,IACA,EAAE,SAAS,CAAC,cAAA;AAAA,IACZ,CAAC,KAAK,aAAa;AAAA,EAAA;AAIrB;AAAA,IACE;AAAA,IACA,MAAM;AACJ,mBAAA;AAAA,IACF;AAAA,IACA,EAAE,SAAS,CAAC,cAAA;AAAA,IACZ,CAAC,eAAe,YAAY;AAAA,EAAA;AAG9B,QAAM,wBAAwB,CAAC,UAAkB;AAC/C,2BAAuB,KAAK;AAC5B,qBAAiB,IAAI;AAAA,EACvB;AAGA,QAAM,mBAAiB,SAAI,eAAJ,mBAAgB,mBACnC,aAAa,KAAK,CAAC,MAAA;;AAAM,aAAE,WAASA,MAAA,IAAI,eAAJ,gBAAAA,IAAgB;AAAA,GAAe,IACnE;AAEJ,SACE,qBAAA,UAAA,EACE,UAAA;AAAA,IAAA,qBAAC,OAAA,EAAI,WAAU,4BAEb,UAAA;AAAA,MAAA,oBAAC,SAAI,WAAU,iBACb,UAAA,qBAAC,OAAA,EAAI,WAAU,2BACb,UAAA;AAAA,QAAA,oBAAC,SAAA,EAAQ,KAAU,WAAU,UAAA,CAAU;AAAA,QACvC,qBAAC,OAAA,EAAI,WAAU,wBACb,UAAA;AAAA,UAAA,qBAAC,OAAA,EAAI,WAAU,gCACb,UAAA;AAAA,YAAA,oBAAC,OAAA,EAAI,WAAU,6CACZ,UAAA,IAAI,eACD,GAAG,IAAI,WAAW,KAAK,IAAI,YAAY,MACvC,IAAI,aACV;AAAA,YACC,IAAI,cACH;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,SACE,IAAI,WAAW,SAAS,gBACpB,cACA;AAAA,gBAGL,UAAA,IAAI,WAAW,SAAS,gBACrB,gBACA;AAAA,cAAA;AAAA,YAAA;AAAA,UACN,GAEJ;AAAA,UACC,WACC,qBAAC,OAAA,EAAI,WAAU,aACb,UAAA;AAAA,YAAA,oBAAC,QAAA,EAAK,WAAU,sCAAqC,UAAA,SAErD;AAAA,YACA;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,OAAO,IAAI;AAAA,gBACX,QAAQ,CAAC,SACP,UAAU,OAAO,EAAE,IAAI,IAAI,IAAI,MAAM,EAAE,KAAA,GAAQ;AAAA,gBAEjD,WAAU;AAAA,cAAA;AAAA,YAAA;AAAA,UACZ,GACF;AAAA,UAEF,oBAAC,OAAA,EAAI,WAAU,aACZ,UAAA,UACC;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,OAAO,IAAI,UAAU;AAAA,cACrB,QAAQ,CAAC,WACP,UAAU,OAAO,EAAE,IAAI,IAAI,IAAI,MAAM,EAAE,OAAA,GAAU;AAAA,cAEnD,aAAY;AAAA,cACZ,YAAY,CAAC,QACX,MACE;AAAA,gBAAC;AAAA,gBAAA;AAAA,kBACC,MAAM;AAAA,kBACN,QAAO;AAAA,kBACP,KAAI;AAAA,kBACJ,SAAS,MAAM,YAAY,IAAI,IAAI;AAAA,kBACnC,WAAU;AAAA,kBAET,UAAA;AAAA,oBAAA,IAAI,QAAQ,gBAAgB,EAAE;AAAA,oBAC/B,oBAAC,cAAA,EAAa,WAAU,0EAAA,CAA0E;AAAA,kBAAA;AAAA,gBAAA;AAAA,cAAA,IAGpG,oBAAC,QAAA,EAAK,WAAU,yBAAwB,UAAA,IAAA,CAAC;AAAA,YAAA;AAAA,UAAA,IAI7C,IAAI,SACN;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAM,IAAI;AAAA,cACV,QAAO;AAAA,cACP,KAAI;AAAA,cACJ,SAAS,MAAM,YAAY,IAAI,IAAI;AAAA,cACnC,WAAU;AAAA,cACV,cAAY,UAAU,IAAI;AAAA,cAE1B,UAAA;AAAA,gBAAA,oBAAC,cAAA,EAAa,WAAU,oBAAA,CAAoB;AAAA,gBAAE;AAAA,gBACzC;AAAA,gBACL,oBAAC,UAAK,WAAU,yEACb,cAAI,OAAO,QAAQ,gBAAgB,EAAE,EAAA,CACxC;AAAA,cAAA;AAAA,YAAA;AAAA,UAAA,IAGF,oBAAC,QAAA,EAAK,WAAU,yBAAwB,eAAC,EAAA,CAE7C;AAAA,QAAA,EAAA,CAEF;AAAA,MAAA,EAAA,CACF,EAAA,CACF;AAAA,MAGC,IAAI,eACF,MAAM;AACL,cAAM,kBAAkB,IAAI,WAAW,QAAQ;AAC/C,cAAM,gBAAgB,oBAAoB;AAC1C,eACE;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,WACE,gBACI,uFACA;AAAA,YAGN,UAAA;AAAA,cAAA;AAAA,gBAAC;AAAA,gBAAA;AAAA,kBACC,WACE,gBACI,oEACA;AAAA,kBAGL,0BACG,sBACA;AAAA,gBAAA;AAAA,cAAA;AAAA,kCAEL,KAAA,EAAE,WAAU,sCACV,UAAA,IAAI,WAAW,SAClB;AAAA,cACC,kBACC;AAAA,gBAAC;AAAA,gBAAA;AAAA,kBACC,MAAK;AAAA,kBACL,SAAS,MAAM,yCAAa;AAAA,kBAC5B,WAAU;AAAA,kBACX,UAAA;AAAA,oBAAA;AAAA,oBACoB,eAAe;AAAA,oBAClC,oBAAC,cAAA,EAAa,WAAU,SAAA,CAAS;AAAA,kBAAA;AAAA,gBAAA;AAAA,cAAA;AAAA,YACnC;AAAA,UAAA;AAAA,QAAA;AAAA,MAIR,GAAA;AAAA,MAIF,oBAAC,yBAAA,EAAwB,UAAU,KAAK,cAAc,YAAY;AAAA,MAGlE,oBAAC,sBAAA,EAAqB,KAAU,gBAAA,CAAkC;AAAA,MAGlE,qBAAC,OAAA,EAAI,WAAU,QACb,UAAA;AAAA,QAAA,oBAAC,MAAA,EAAG,WAAU,4BAA2B,UAAA,eAAW;AAAA,QACnD,UACC;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,OAAO,IAAI,eAAe;AAAA,YAC1B,QAAQ,CAAC,gBACP,UAAU,OAAO,EAAE,IAAI,IAAI,IAAI,MAAM,EAAE,YAAA,GAAe;AAAA,YAExD,WAAS;AAAA,YACT,aAAY;AAAA,YACZ,WAAU;AAAA,UAAA;AAAA,QAAA,IAEV,IAAI,cACN,oBAAC,gBAAa,WAAU,qEACrB,UAAA,IAAI,YAAA,CACP,IAEA,oBAAC,KAAA,EAAE,WAAU,iCAAgC,UAAA,IAAA,CAAC;AAAA,MAAA,GAElD;AAAA,MAGC,IAAI,iBAAiB,IAAI,cAAc,SAAS,KAC/C,qBAAC,OAAA,EAAI,WAAU,QACb,UAAA;AAAA,QAAA,qBAAC,MAAA,EAAG,WAAU,4BAA2B,UAAA;AAAA,UAAA;AAAA,UACzB,IAAI,cAAc;AAAA,UAAO;AAAA,QAAA,GACzC;AAAA,QACA;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,WAAU;AAAA,YACV,SAAS,MAAM,sBAAsB,CAAC;AAAA,YAEtC,UAAA;AAAA,cAAA,oBAAC,iBAAc,KAAU;AAAA,cACxB,IAAI,cAAc,SAAS,KAC1B,qBAAC,KAAA,EAAE,WAAU,kDAAiD,UAAA;AAAA,gBAAA;AAAA,gBACzC,IAAI,cAAc;AAAA,gBAAO;AAAA,cAAA,EAAA,CAC9C;AAAA,YAAA;AAAA,UAAA;AAAA,QAAA;AAAA,MAEJ,GACF;AAAA,MAKD,IAAI,mBACH,qBAAC,OAAA,EAAI,WAAU,QACb,UAAA;AAAA,QAAA,oBAAC,MAAA,EAAG,WAAU,4BAA2B,UAAA,SAAK;AAAA,QAC9C,oBAAC,KAAA,EAAE,WAAU,sCAAqC,UAAA,wCAElD;AAAA,QACA,oBAAC,aAAA,EAAY,MAAM,IAAI,gBAAA,CAAiB;AAAA,MAAA,GAC1C;AAAA,MAIF,oBAAC,6BAA0B,KAAU;AAAA,MAGpC,IAAI,SAAS,IAAI,MAAM,SAAS,KAC/B,qBAAC,OAAA,EAAI,WAAU,QACb,UAAA;AAAA,QAAA,oBAAC,MAAA,EAAG,WAAU,kDAAiD,UAAA,SAE/D;AAAA,QACA,oBAAC,SAAI,WAAU,eACZ,cAAI,MAAM,IAAI,CAAC,SACd;AAAA,UAAC;AAAA,UAAA;AAAA,YAEC,MAAM,KAAK;AAAA,YACX,QAAO;AAAA,YACP,KAAI;AAAA,YACJ,WAAU;AAAA,YAEV,UAAA;AAAA,cAAA,oBAAC,cAAA,EAAa,WAAU,kBAAA,CAAkB;AAAA,cACzC,KAAK,SAAS,KAAK,IAAI,QAAQ,gBAAgB,EAAE;AAAA,YAAA;AAAA,UAAA;AAAA,UAP7C,KAAK;AAAA,QAAA,CASb,EAAA,CACH;AAAA,MAAA,GACF;AAAA,MAID,IAAI,QAAQ,IAAI,KAAK,SAAS,KAC7B,qBAAC,OAAA,EAAI,WAAU,QACb,UAAA;AAAA,QAAA,oBAAC,MAAA,EAAG,WAAU,4BAA2B,UAAA,QAAI;AAAA,4BAC5C,OAAA,EAAI,WAAU,wBACZ,UAAA,IAAI,KAAK,IAAI,CAAC,QACb,oBAAC,OAAA,EAAgB,SAAQ,aAAY,WAAU,WAC5C,UAAA,IAAA,GADS,GAEZ,CACD,EAAA,CACH;AAAA,MAAA,GACF;AAAA,MAID,IAAI,SAAS,IAAI,MAAM,SAAS,KAC/B,qBAAC,OAAA,EAAI,WAAU,QACb,UAAA;AAAA,QAAA,oBAAC,MAAA,EAAG,WAAU,4BAA2B,UAAA,SAAK;AAAA,4BAC7C,OAAA,EAAI,WAAU,wBACZ,UAAA,IAAI,MAAM,IAAI,CAAC,SACd,oBAAC,OAAA,EAAiB,SAAQ,WAAU,WAAU,WAC3C,UAAA,KAAA,GADS,IAEZ,CACD,EAAA,CACH;AAAA,MAAA,GACF;AAAA,OAIA,IAAI,YAAY,IAAI,aACpB,oBAAC,WAAA,EAAU,MAAK,UAAS,aAAW,MAAC,WAAU,QAC7C,UAAA;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,OAAM;AAAA,UACN,WAAU;AAAA,UAEV,UAAA;AAAA,YAAA,oBAAC,kBAAA,EAAiB,WAAU,mCAAkC,UAAA,yBAE9D;AAAA,YACA,qBAAC,kBAAA,EAAiB,WAAU,kBACzB,UAAA;AAAA,cAAA,IAAI,iCACF,OAAA,EACC,UAAA;AAAA,gBAAA,oBAAC,MAAA,EAAG,WAAU,kDAAiD,UAAA,aAE/D;AAAA,gBACA,oBAAC,KAAA,EAAE,WAAU,+BACV,cAAI,SAAA,CACP;AAAA,cAAA,GACF;AAAA,cAED,IAAI,YACH,qBAAC,OAAA,EACC,UAAA;AAAA,gBAAA,oBAAC,MAAA,EAAG,WAAU,kDAAiD,UAAA,aAE/D;AAAA,gBACA,oBAAC,KAAA,EAAE,WAAU,+BACV,cAAI,SAAA,CACP;AAAA,cAAA,EAAA,CACF;AAAA,YAAA,EAAA,CAEJ;AAAA,UAAA;AAAA,QAAA;AAAA,MAAA,GAEJ;AAAA,OAIA,IAAI,eAAa,SAAI,cAAJ,mBAAe,mBAChC,qBAAC,OAAA,EAAI,WAAU,uCACZ,UAAA;AAAA,QAAA,IAAI,aACH;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,WAAU;AAAA,YACV,OAAO,IAAI;AAAA,YAEX,UAAA;AAAA,cAAA,oBAAC,cAAA,EAAa,WAAU,SAAA,CAAS;AAAA,cAAE;AAAA,cAC5B,mBAAmB,IAAI,SAAS;AAAA,YAAA;AAAA,UAAA;AAAA,QAAA;AAAA,UAG1C,SAAI,cAAJ,mBAAe,kBACd;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,WAAU;AAAA,YACV,OACE,IAAI,UAAU,sBACV,wBAAwB,IAAI,UAAU,mBAAmB,mBAAmB,IAAI,UAAU,aAAa,KACvG,gBAAgB,IAAI,UAAU,aAAa;AAAA,YAGjD,UAAA;AAAA,cAAA,oBAAC,OAAA,EAAM,WAAU,SAAA,CAAS;AAAA,cAEkC;AAAA,cACpD;AAAA,cACP;AAAA,gBACC,IAAI,UAAU,uBACZ,IAAI,UAAU;AAAA,cAAA;AAAA,cAEjB,IAAI,UAAU,WACb,qBAAC,QAAA,EAAK,WAAU,qBACb,UAAA;AAAA,gBAAA;AAAA,gBAAI;AAAA,cAAA,EAAA,CAEP;AAAA,YAAA;AAAA,UAAA;AAAA,QAAA;AAAA,MAEJ,GAEJ;AAAA,MAIF,qBAAC,OAAA,EAAI,WAAU,QACb,UAAA;AAAA,QAAA,oBAAC,MAAA,EAAG,WAAU,4BAA2B,UAAA,WAAO;AAAA,QAC/C,UACC,qBAAA,UAAA,EACE,UAAA;AAAA,UAAA,oBAAC,QAAG,WAAU,aACX,yBAAe,IAAI,CAAC,KAAK,UAAU;AAClC,kBAAM,UACJ,gBAAgB,QAAQ,UAAU,WAAW;AAC/C,mBACE;AAAA,cAAC;AAAA,cAAA;AAAA,gBAEC,WAAU;AAAA,gBAEV,UAAA;AAAA,kBAAA,qBAAC,QAAA,EAAK,WAAU,+CACb,UAAA;AAAA,oBAAA,QAAQ;AAAA,oBAAE;AAAA,kBAAA,GACb;AAAA,kBACA;AAAA,oBAAC;AAAA,oBAAA;AAAA,sBACC,OAAO;AAAA,sBACP,iBAAiB;AAAA,sBACjB,UACE,UAAU,MAAM,eAAe,IAAI,IAAI;AAAA,sBAEzC,QAAQ,CAAC,WAAW;AAClB,4BAAI,SAAS;AACX,yCAAe,IAAI;AACnB,8BAAI,QAAQ;AACV,sCAAU,OAAO;AAAA,8BACf,IAAI,IAAI;AAAA,8BACR,MAAM,EAAE,SAAS,CAAC,GAAG,YAAY,MAAM,EAAA;AAAA,4BAAE,CAC1C;AAAA,0BACH;AAAA,wBACF,OAAO;AACL,gCAAM,OAAO,CAAC,GAAG,UAAU;AAC3B,+BAAK,KAAK,IAAI;AACd,oCAAU,OAAO;AAAA,4BACf,IAAI,IAAI;AAAA,4BACR,MAAM,EAAE,SAAS,KAAK,OAAO,OAAO,EAAA;AAAA,0BAAE,CACvC;AAAA,wBACH;AAAA,sBACF;AAAA,sBACA,aAAY;AAAA,sBACZ,eAAc;AAAA,sBACd,YAAY,CAAC,QACX,MACE;AAAA,wBAAC;AAAA,wBAAA;AAAA,0BACC,MAAM;AAAA,0BACN,QAAO;AAAA,0BACP,KAAI;AAAA,0BACJ,WAAU;AAAA,0BAET,UAAA;AAAA,4BAAA,IAAI,QAAQ,gBAAgB,EAAE;AAAA,4BAC/B,oBAAC,cAAA,EAAa,WAAU,kBAAA,CAAkB;AAAA,0BAAA;AAAA,wBAAA;AAAA,sBAAA,IAG5C,oBAAC,QAAA,EAAK,WAAU,yBAAwB,UAAA,IAAA,CAAC;AAAA,oBAAA;AAAA,kBAAA;AAAA,kBAI9C,CAAC,WACA;AAAA,oBAAC;AAAA,oBAAA;AAAA,sBACC,MAAK;AAAA,sBACL,SAAQ;AAAA,sBACR,MAAK;AAAA,sBACL,cAAW;AAAA,sBACX,WAAU;AAAA,sBACV,SAAS,MAAM;AACb,8BAAM,OAAO,WAAW;AAAA,0BACtB,CAAC,GAAG,MAAM,MAAM;AAAA,wBAAA;AAElB,kCAAU,OAAO;AAAA,0BACf,IAAI,IAAI;AAAA,0BACR,MAAM,EAAE,SAAS,KAAA;AAAA,wBAAK,CACvB;AAAA,sBACH;AAAA,sBAEA,UAAA,oBAAC,QAAA,EAAO,WAAU,WAAA,CAAW;AAAA,oBAAA;AAAA,kBAAA;AAAA,gBAC/B;AAAA,cAAA;AAAA,cAlEG,UAAU,UAAU,GAAG,KAAK,IAAI,GAAG;AAAA,YAAA;AAAA,UAsE9C,CAAC,EAAA,CACH;AAAA,UACA;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAK;AAAA,cACL,SAAQ;AAAA,cACR,MAAK;AAAA,cACL,WAAU;AAAA,cACV,SAAS,MAAM,eAAe,EAAE;AAAA,cAEhC,UAAA;AAAA,gBAAA,oBAAC,MAAA,EAAK,WAAU,WAAA,CAAW;AAAA,gBAAE;AAAA,cAAA;AAAA,YAAA;AAAA,UAAA;AAAA,QAE/B,EAAA,CACF,IAEA,oBAAC,MAAA,EAAG,WAAU,aACX,UAAA,WAAW,IAAI,CAAC,KAAK,UACpB,qBAAC,MAAA,EAAe,WAAU,mCACxB,UAAA;AAAA,UAAA,qBAAC,QAAA,EAAK,WAAU,+CACb,UAAA;AAAA,YAAA,QAAQ;AAAA,YAAE;AAAA,UAAA,GACb;AAAA,UACC,MACC;AAAA,YAAC;AAAA,YAAA;AAAA,cACC,MAAM;AAAA,cACN,QAAO;AAAA,cACP,KAAI;AAAA,cACJ,WAAU;AAAA,cAET,UAAA;AAAA,gBAAA,IAAI,QAAQ,gBAAgB,EAAE;AAAA,gBAC/B,oBAAC,cAAA,EAAa,WAAU,kBAAA,CAAkB;AAAA,cAAA;AAAA,YAAA;AAAA,UAAA,IAG5C,oBAAC,QAAA,EAAK,WAAU,yBAAwB,UAAA,IAAA,CAAC;AAAA,QAAA,EAAA,GAfpC,KAiBT,CACD,EAAA,CACH;AAAA,MAAA,EAAA,CAEJ;AAAA,IAAA,GAGF;AAAA,IAGA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC;AAAA,QACA,eAAe,IAAI,iBAAiB,CAAA;AAAA,QACpC,MAAM;AAAA,QACN,cAAc;AAAA,QACd,cAAc;AAAA,QACd,OAAO,GAAG,IAAI,gBAAgB,IAAI,WAAW;AAAA,MAAA;AAAA,IAAA;AAAA,EAC/C,GACF;AAEJ;"}
1
+ {"version":3,"file":"AppDetails.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/AppDetails.tsx"],"sourcesContent":["import type { Resource } 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 { 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 { 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'\n\nfunction getIconUrl(iconName: string): string {\n return `/api/icons/${iconName}`\n}\n\nfunction AppIcon({ app, className }: { app: Resource; className?: string }) {\n const [imageError, setImageError] = React.useState(false)\n\n // Use iconName from backend if available\n if (app.iconName && !imageError) {\n return (\n <div className={cn('size-12 shrink-0', className)}>\n <img\n src={getIconUrl(app.iconName)}\n alt={`${app.abbreviation || app.displayName} icon`}\n className=\"size-12 rounded-lg object-contain\"\n onError={() => setImageError(true)}\n />\n </div>\n )\n }\n\n // Fallback icon\n return (\n <div\n className={cn(\n 'flex items-center justify-center rounded-lg bg-primary/10 text-primary size-12 shrink-0',\n className,\n )}\n >\n <AppWindow className=\"size-6\" />\n </div>\n )\n}\n\nfunction AppScreenshot({ app }: { app: Resource }) {\n const [imageError, setImageError] = React.useState(false)\n const [isLoadingImage, setIsLoadingImage] = React.useState(true)\n\n // Check if app has screenshots\n const screenshotId = app.screenshotIds?.[0]\n if (!screenshotId) {\n return (\n <div className=\"w-full bg-muted/50 rounded-lg overflow-hidden flex items-center justify-center min-h-64\">\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n No screenshot available\n </div>\n </div>\n )\n }\n\n const screenshotImageUrl = `/api/screenshots/${screenshotId}?size=512`\n\n return (\n <div className=\"w-full flex justify-center\">\n <div className=\"rounded-lg overflow-hidden inline-flex items-center justify-center min-h-64\">\n {!imageError ? (\n <img\n src={screenshotImageUrl}\n alt={`${app.abbreviation || app.displayName} screenshot`}\n className=\"h-64 object-contain\"\n onError={() => {\n setImageError(true)\n setIsLoadingImage(false)\n }}\n onLoad={() => setIsLoadingImage(false)}\n />\n ) : null}\n {(imageError || isLoadingImage) && (\n <div className=\"w-full h-64 bg-muted/30 flex items-center justify-center text-muted-foreground text-sm\">\n {isLoadingImage\n ? 'Loading screenshot...'\n : 'No screenshot available'}\n </div>\n )}\n </div>\n </div>\n )\n}\n\nfunction TiersAndSubResourcesPanel({ app }: { app: Resource }) {\n const { resources } = useAppCatalogContext()\n // #38 item C: the active catalog search term. When the user reached this app\n // by searching something that matched a child, seed the sub-resource filter\n // with it so the matched child is revealed.\n const { state: filterState } = useAppCatalogFilters()\n const appSubResources = React.useMemo(\n () => getChildResources(resources, app.slug),\n [resources, app.slug],\n )\n\n return (\n <>\n {app.tiers && app.tiers.length > 0 && (\n <div className=\"mt-6\">\n <TierVariantsSection tiers={app.tiers} />\n </div>\n )}\n {appSubResources.length > 0 && (\n <div className=\"mt-6\">\n <SubResourcesSection\n subResources={appSubResources}\n parentSlug={app.slug}\n initialSearch={filterState.searchValue}\n />\n </div>\n )}\n </>\n )\n}\n\nexport function AppDetails({\n app,\n onAppClick,\n onClosePanel,\n}: {\n app: Resource\n onAppClick?: (app: Resource) => void\n onClosePanel: () => void\n}) {\n const [isGalleryOpen, setIsGalleryOpen] = React.useState(false)\n const [galleryInitialIndex, setGalleryInitialIndex] = React.useState(0)\n const { approvalMethods, resources: allResources } = useAppCatalogContext()\n const { recordClick } = useAppClickHistory()\n const updateApp = useUpdateApp()\n const [draftSource, setDraftSource] = React.useState<string | null>(null)\n const user = useUser()\n const isAdmin = user?.isAdmin ?? false\n\n const sourceUrls: string[] =\n app.sources?.map((s) => (typeof s === 'string' ? s : s.url)) ?? []\n const displaySources =\n draftSource !== null ? [...sourceUrls, draftSource] : sourceUrls\n\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 */}\n <div className=\"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 <div className=\"mt-1 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 {url.replace(/https?:\\/\\//g, '')}\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=\"inline-flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90 transition-colors\"\n aria-label={'Open ' + app.displayName}\n >\n <ExternalLink className=\"size-3.5 shrink-0\" />\n Open{' '}\n <span className=\"max-w-[240px] truncate text-primary-foreground/70 text-xs font-normal\">\n {app.appUrl.replace(/https?:\\/\\//g, '')}\n </span>\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )}\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 {/* 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 — hero of the detail, shown before description */}\n <AccessRequestSection app={app} approvalMethods={approvalMethods} />\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 {/* 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 <div\n className=\"cursor-pointer 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 </div>\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 || link.url.replace(/https?:\\/\\//g, '')}\n </a>\n ))}\n </div>\n </div>\n )}\n\n {/* Tags */}\n {app.tags && app.tags.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Tags</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.tags.map((tag) => (\n <Badge key={tag} variant=\"secondary\" className=\"text-xs\">\n {tag}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Teams */}\n {app.teams && app.teams.length > 0 && (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Teams</h3>\n <div className=\"flex flex-wrap gap-2\">\n {app.teams.map((team) => (\n <Badge key={team} variant=\"outline\" className=\"text-xs\">\n {team}\n </Badge>\n ))}\n </div>\n </div>\n )}\n\n {/* Technical information: AI-facing fields, de-emphasized/collapsed */}\n {(app.aiPrompt || app.aiMemory) && (\n <Accordion type=\"single\" collapsible className=\"mt-6\">\n <AccordionItem\n value=\"technical-info\"\n className=\"border rounded-lg px-4\"\n >\n <AccordionTrigger className=\"text-sm hover:no-underline py-3\">\n Technical information\n </AccordionTrigger>\n <AccordionContent className=\"pb-3 space-y-3\">\n {app.aiPrompt && (\n <div>\n <h4 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n AI Prompt\n </h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiPrompt}\n </p>\n </div>\n )}\n {app.aiMemory && (\n <div>\n <h4 className=\"mb-1 text-xs font-medium text-muted-foreground\">\n AI Memory\n </h4>\n <p className=\"text-xs whitespace-pre-wrap\">\n {app.aiMemory}\n </p>\n </div>\n )}\n </AccordionContent>\n </AccordionItem>\n </Accordion>\n )}\n\n {/* Metadata: Added / Updated */}\n {(app.createdAt || app.freshness?.lastCheckedAt) && (\n <div className=\"mt-6 flex flex-wrap gap-x-5 gap-y-1\">\n {app.createdAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={app.createdAt}\n >\n <CalendarPlus className=\"size-3\" />\n Added {formatRelativeTime(app.createdAt)}\n </span>\n )}\n {app.freshness?.lastCheckedAt && (\n <span\n className=\"inline-flex items-center gap-1 text-xs text-muted-foreground\"\n title={\n app.freshness.lastContentChangeAt\n ? `Content last changed ${app.freshness.lastContentChangeAt} · last checked ${app.freshness.lastCheckedAt}`\n : `Last checked ${app.freshness.lastCheckedAt}`\n }\n >\n <Clock className=\"size-3\" />\n {/* \"Updated\" means the content changed, not that a check ran -\n entries predating the field have none, so fall back. */}\n Updated{' '}\n {formatRelativeTime(\n app.freshness.lastContentChangeAt ??\n app.freshness.lastCheckedAt,\n )}\n {app.freshness.isStale && (\n <span className=\"italic opacity-75\">\n {' '}\n · may be out of date\n </span>\n )}\n </span>\n )}\n </div>\n )}\n\n {/* Sources */}\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">Sources</h3>\n {isAdmin ? (\n <>\n <ul className=\"space-y-2\">\n {displaySources.map((url, index) => {\n const isDraft =\n draftSource !== null && index === sourceUrls.length\n return (\n <li\n key={isDraft ? 'draft' : `${index}-${url}`}\n className=\"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 {val.replace(/https?:\\/\\//g, '')}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\n )\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 <li key={index} className=\"flex items-center gap-2 text-xs\">\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 items-center gap-1 truncate\"\n >\n {url.replace(/https?:\\/\\//g, '')}\n <ExternalLink className=\"size-3 shrink-0\" />\n </a>\n ) : (\n <span className=\"text-muted-foreground\">—</span>\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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"}
@@ -0,0 +1,3 @@
1
+ export declare function CommentsSection({ appSlug }: {
2
+ appSlug: string;
3
+ }): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,171 @@
1
+ import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
+ import { useQueryClient, useQuery, useMutation } from "@tanstack/react-query";
3
+ import { useState } from "react";
4
+ import { useTRPC } from "../../../../api/infra/trpc.js";
5
+ import { Button } from "../../../../ui/button.js";
6
+ import { cn } from "../../../../lib/utils.js";
7
+ import { formatRelativeTime } from "../../utils/formatRelativeTime.js";
8
+ const FIELD_CLASSES = "placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50";
9
+ function errorMessage(error) {
10
+ return error instanceof Error ? error.message : "Something went wrong";
11
+ }
12
+ function isSubmitChord(event) {
13
+ return event.key === "Enter" && (event.metaKey || event.ctrlKey);
14
+ }
15
+ function CommentsSection({ appSlug }) {
16
+ const trpc = useTRPC();
17
+ const queryClient = useQueryClient();
18
+ const listOptions = trpc.comments.list.queryOptions({ resourceSlug: appSlug });
19
+ const { data: comments, isLoading, error } = useQuery(listOptions);
20
+ const [draft, setDraft] = useState("");
21
+ const [editingId, setEditingId] = useState(null);
22
+ const [editDraft, setEditDraft] = useState("");
23
+ const refresh = () => queryClient.invalidateQueries({ queryKey: listOptions.queryKey });
24
+ const add = useMutation(
25
+ trpc.comments.add.mutationOptions({
26
+ onSuccess: () => {
27
+ setDraft("");
28
+ void refresh();
29
+ }
30
+ })
31
+ );
32
+ const edit = useMutation(
33
+ trpc.comments.edit.mutationOptions({
34
+ onSuccess: () => {
35
+ setEditingId(null);
36
+ void refresh();
37
+ }
38
+ })
39
+ );
40
+ const remove = useMutation(
41
+ trpc.comments.remove.mutationOptions({ onSuccess: () => void refresh() })
42
+ );
43
+ const post = () => {
44
+ const body = draft.trim();
45
+ if (body) add.mutate({ resourceSlug: appSlug, body });
46
+ };
47
+ const saveEdit = (id) => {
48
+ const body = editDraft.trim();
49
+ if (body) edit.mutate({ id, body });
50
+ };
51
+ const failure = error ?? add.error ?? edit.error ?? remove.error;
52
+ return /* @__PURE__ */ jsxs("div", { className: "mt-6", children: [
53
+ /* @__PURE__ */ jsxs("h3", { className: "mb-2 text-sm font-medium", children: [
54
+ "Comments",
55
+ comments && comments.length > 0 && /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground ml-1 font-normal", children: [
56
+ "(",
57
+ comments.length,
58
+ ")"
59
+ ] })
60
+ ] }),
61
+ isLoading ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-xs", children: "Loading comments…" }) : comments && comments.length > 0 ? /* @__PURE__ */ jsx("ul", { className: "space-y-3", children: comments.map((comment) => {
62
+ const canEdit = comment.isMine && !!comment.canEditUntil && Date.parse(comment.canEditUntil) > Date.now();
63
+ return /* @__PURE__ */ jsxs("li", { className: "text-xs", children: [
64
+ /* @__PURE__ */ jsxs("div", { className: "text-muted-foreground flex items-center gap-2", children: [
65
+ /* @__PURE__ */ jsx("span", { className: "text-foreground font-medium", children: comment.authorName }),
66
+ /* @__PURE__ */ jsx("span", { children: formatRelativeTime(comment.createdAt) }),
67
+ comment.editedAt && /* @__PURE__ */ jsx("span", { children: "(edited)" }),
68
+ canEdit && editingId !== comment.id && /* @__PURE__ */ jsxs(Fragment, { children: [
69
+ /* @__PURE__ */ jsx(
70
+ "button",
71
+ {
72
+ type: "button",
73
+ className: "hover:text-foreground underline",
74
+ onClick: () => {
75
+ setEditingId(comment.id);
76
+ setEditDraft(comment.body);
77
+ },
78
+ children: "Edit"
79
+ }
80
+ ),
81
+ /* @__PURE__ */ jsx(
82
+ "button",
83
+ {
84
+ type: "button",
85
+ className: "hover:text-destructive underline",
86
+ onClick: () => remove.mutate({ id: comment.id }),
87
+ children: "Delete"
88
+ }
89
+ )
90
+ ] })
91
+ ] }),
92
+ editingId === comment.id ? /* @__PURE__ */ jsxs("div", { className: "mt-1 space-y-2", children: [
93
+ /* @__PURE__ */ jsx(
94
+ "textarea",
95
+ {
96
+ autoFocus: true,
97
+ rows: 3,
98
+ className: cn(FIELD_CLASSES),
99
+ value: editDraft,
100
+ onChange: (event) => setEditDraft(event.target.value),
101
+ onKeyDown: (event) => {
102
+ if (isSubmitChord(event)) {
103
+ event.preventDefault();
104
+ saveEdit(comment.id);
105
+ }
106
+ }
107
+ }
108
+ ),
109
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
110
+ /* @__PURE__ */ jsx(
111
+ Button,
112
+ {
113
+ type: "button",
114
+ size: "sm",
115
+ disabled: !editDraft.trim() || edit.isPending,
116
+ onClick: () => saveEdit(comment.id),
117
+ children: "Save"
118
+ }
119
+ ),
120
+ /* @__PURE__ */ jsx(
121
+ Button,
122
+ {
123
+ type: "button",
124
+ size: "sm",
125
+ variant: "ghost",
126
+ onClick: () => setEditingId(null),
127
+ children: "Cancel"
128
+ }
129
+ )
130
+ ] })
131
+ ] }) : /* @__PURE__ */ jsx("p", { className: "mt-0.5 whitespace-pre-wrap", children: comment.body })
132
+ ] }, comment.id);
133
+ }) }) : /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-xs", children: "No comments yet. Be the first." }),
134
+ /* @__PURE__ */ jsxs("div", { className: "mt-3 space-y-2", children: [
135
+ /* @__PURE__ */ jsx(
136
+ "textarea",
137
+ {
138
+ rows: 3,
139
+ className: cn(FIELD_CLASSES),
140
+ placeholder: "Leave a comment…",
141
+ value: draft,
142
+ onChange: (event) => setDraft(event.target.value),
143
+ onKeyDown: (event) => {
144
+ if (isSubmitChord(event)) {
145
+ event.preventDefault();
146
+ post();
147
+ }
148
+ }
149
+ }
150
+ ),
151
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
152
+ /* @__PURE__ */ jsx(
153
+ Button,
154
+ {
155
+ type: "button",
156
+ size: "sm",
157
+ disabled: !draft.trim() || add.isPending,
158
+ onClick: post,
159
+ children: add.isPending ? "Posting…" : "Post"
160
+ }
161
+ ),
162
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground text-xs", children: "Posted under a nickname. You can edit or delete it for one hour." })
163
+ ] }),
164
+ failure && /* @__PURE__ */ jsx("p", { className: "text-destructive text-xs", children: errorMessage(failure) })
165
+ ] })
166
+ ] });
167
+ }
168
+ export {
169
+ CommentsSection
170
+ };
171
+ //# sourceMappingURL=CommentsSection.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"CommentsSection.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/CommentsSection.tsx"],"sourcesContent":["import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useState } from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { Button } from '~/ui/button'\nimport { cn } from '~/lib/utils'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\n\n/**\n * Feedback on one app, from anyone browsing the catalog.\n *\n * Nobody has to log in to leave a comment, so the server attributes each one to a\n * pseudonym derived from an httpOnly cookie (\"Curious Ferret\"). That cookie is also\n * the only thing that authorises editing or deleting, and only for the first hour —\n * `canEditUntil` arrives from the server as an absolute instant so a skewed client\n * clock cannot show controls the server would refuse.\n */\n\n/** Same shape the input primitive uses, minus the fixed height. */\nconst FIELD_CLASSES =\n 'placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50'\n\nfunction errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : 'Something went wrong'\n}\n\n/** Cmd/Ctrl+Enter submits; plain Enter keeps making paragraphs. */\nfunction isSubmitChord(event: React.KeyboardEvent): boolean {\n return event.key === 'Enter' && (event.metaKey || event.ctrlKey)\n}\n\nexport function CommentsSection({ appSlug }: { appSlug: string }) {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n const listOptions = trpc.comments.list.queryOptions({ resourceSlug: appSlug })\n const { data: comments, isLoading, error } = useQuery(listOptions)\n\n const [draft, setDraft] = useState('')\n const [editingId, setEditingId] = useState<string | null>(null)\n const [editDraft, setEditDraft] = useState('')\n\n const refresh = () =>\n queryClient.invalidateQueries({ queryKey: listOptions.queryKey })\n\n const add = useMutation(\n trpc.comments.add.mutationOptions({\n onSuccess: () => {\n setDraft('')\n void refresh()\n },\n }),\n )\n const edit = useMutation(\n trpc.comments.edit.mutationOptions({\n onSuccess: () => {\n setEditingId(null)\n void refresh()\n },\n }),\n )\n const remove = useMutation(\n trpc.comments.remove.mutationOptions({ onSuccess: () => void refresh() }),\n )\n\n const post = () => {\n const body = draft.trim()\n if (body) add.mutate({ resourceSlug: appSlug, body })\n }\n\n const saveEdit = (id: string) => {\n const body = editDraft.trim()\n if (body) edit.mutate({ id, body })\n }\n\n // One line for every failed action: three mutations and one query, one place to look.\n const failure = error ?? add.error ?? edit.error ?? remove.error\n\n return (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">\n Comments\n {comments && comments.length > 0 && (\n <span className=\"text-muted-foreground ml-1 font-normal\">\n ({comments.length})\n </span>\n )}\n </h3>\n\n {isLoading ? (\n <p className=\"text-muted-foreground text-xs\">Loading comments…</p>\n ) : comments && comments.length > 0 ? (\n <ul className=\"space-y-3\">\n {comments.map((comment) => {\n const canEdit =\n comment.isMine &&\n !!comment.canEditUntil &&\n Date.parse(comment.canEditUntil) > Date.now()\n\n return (\n <li key={comment.id} className=\"text-xs\">\n <div className=\"text-muted-foreground flex items-center gap-2\">\n <span className=\"text-foreground font-medium\">\n {comment.authorName}\n </span>\n <span>{formatRelativeTime(comment.createdAt)}</span>\n {comment.editedAt && <span>(edited)</span>}\n {canEdit && editingId !== comment.id && (\n <>\n <button\n type=\"button\"\n className=\"hover:text-foreground underline\"\n onClick={() => {\n setEditingId(comment.id)\n setEditDraft(comment.body)\n }}\n >\n Edit\n </button>\n <button\n type=\"button\"\n className=\"hover:text-destructive underline\"\n onClick={() => remove.mutate({ id: comment.id })}\n >\n Delete\n </button>\n </>\n )}\n </div>\n\n {editingId === comment.id ? (\n <div className=\"mt-1 space-y-2\">\n <textarea\n autoFocus\n rows={3}\n className={cn(FIELD_CLASSES)}\n value={editDraft}\n onChange={(event) => setEditDraft(event.target.value)}\n onKeyDown={(event) => {\n if (isSubmitChord(event)) {\n event.preventDefault()\n saveEdit(comment.id)\n }\n }}\n />\n <div className=\"flex gap-2\">\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={!editDraft.trim() || edit.isPending}\n onClick={() => saveEdit(comment.id)}\n >\n Save\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => setEditingId(null)}\n >\n Cancel\n </Button>\n </div>\n </div>\n ) : (\n <p className=\"mt-0.5 whitespace-pre-wrap\">{comment.body}</p>\n )}\n </li>\n )\n })}\n </ul>\n ) : (\n <p className=\"text-muted-foreground text-xs\">\n No comments yet. Be the first.\n </p>\n )}\n\n <div className=\"mt-3 space-y-2\">\n <textarea\n rows={3}\n className={cn(FIELD_CLASSES)}\n placeholder=\"Leave a comment…\"\n value={draft}\n onChange={(event) => setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (isSubmitChord(event)) {\n event.preventDefault()\n post()\n }\n }}\n />\n <div className=\"flex items-center gap-3\">\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={!draft.trim() || add.isPending}\n onClick={post}\n >\n {add.isPending ? 'Posting…' : 'Post'}\n </Button>\n <span className=\"text-muted-foreground text-xs\">\n Posted under a nickname. You can edit or delete it for one hour.\n </span>\n </div>\n {failure && (\n <p className=\"text-destructive text-xs\">{errorMessage(failure)}</p>\n )}\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -11,7 +11,7 @@ function formatRelativeTime(iso, now = Date.now()) {
11
11
  const then = Date.parse(iso);
12
12
  if (Number.isNaN(then)) return "";
13
13
  const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
14
- let duration = (then - now) / 1e3;
14
+ let duration = Math.min(0, (then - now) / 1e3);
15
15
  for (const division of DIVISIONS) {
16
16
  if (Math.abs(duration) < division.amount) {
17
17
  return rtf.format(Math.round(duration), division.unit);
@@ -1 +1 @@
1
- {"version":3,"file":"formatRelativeTime.js","sources":["../../../../../src/modules/appCatalog/utils/formatRelativeTime.ts"],"sourcesContent":["/**\n * Format an ISO timestamp as a short, human relative phrase (\"2 days ago\").\n * Used for the muted \"Last checked …\" line on the app detail view.\n *\n * Kept tiny and dependency-free (Intl.RelativeTimeFormat) — the frontend has no\n * date library and this is the only place that needs relative time.\n */\nconst DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [\n { amount: 60, unit: 'second' },\n { amount: 60, unit: 'minute' },\n { amount: 24, unit: 'hour' },\n { amount: 7, unit: 'day' },\n { amount: 4.34524, unit: 'week' },\n { amount: 12, unit: 'month' },\n { amount: Number.POSITIVE_INFINITY, unit: 'year' },\n]\n\nexport function formatRelativeTime(\n iso: string,\n now: number = Date.now(),\n): string {\n const then = Date.parse(iso)\n if (Number.isNaN(then)) return ''\n const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })\n let duration = (then - now) / 1000 // seconds, negative for the past\n for (const division of DIVISIONS) {\n if (Math.abs(duration) < division.amount) {\n return rtf.format(Math.round(duration), division.unit)\n }\n duration /= division.amount\n }\n return rtf.format(Math.round(duration), 'year')\n}\n"],"names":[],"mappings":"AAOA,MAAM,YAAqE;AAAA,EACzE,EAAE,QAAQ,IAAI,MAAM,SAAA;AAAA,EACpB,EAAE,QAAQ,IAAI,MAAM,SAAA;AAAA,EACpB,EAAE,QAAQ,IAAI,MAAM,OAAA;AAAA,EACpB,EAAE,QAAQ,GAAG,MAAM,MAAA;AAAA,EACnB,EAAE,QAAQ,SAAS,MAAM,OAAA;AAAA,EACzB,EAAE,QAAQ,IAAI,MAAM,QAAA;AAAA,EACpB,EAAE,QAAQ,OAAO,mBAAmB,MAAM,OAAA;AAC5C;AAEO,SAAS,mBACd,KACA,MAAc,KAAK,OACX;AACR,QAAM,OAAO,KAAK,MAAM,GAAG;AAC3B,MAAI,OAAO,MAAM,IAAI,EAAG,QAAO;AAC/B,QAAM,MAAM,IAAI,KAAK,mBAAmB,MAAM,EAAE,SAAS,QAAQ;AACjE,MAAI,YAAY,OAAO,OAAO;AAC9B,aAAW,YAAY,WAAW;AAChC,QAAI,KAAK,IAAI,QAAQ,IAAI,SAAS,QAAQ;AACxC,aAAO,IAAI,OAAO,KAAK,MAAM,QAAQ,GAAG,SAAS,IAAI;AAAA,IACvD;AACA,gBAAY,SAAS;AAAA,EACvB;AACA,SAAO,IAAI,OAAO,KAAK,MAAM,QAAQ,GAAG,MAAM;AAChD;"}
1
+ {"version":3,"file":"formatRelativeTime.js","sources":["../../../../../src/modules/appCatalog/utils/formatRelativeTime.ts"],"sourcesContent":["/**\n * Format an ISO timestamp as a short, human relative phrase (\"2 days ago\").\n * Used for the muted \"Last checked …\" line on the app detail view.\n *\n * Kept tiny and dependency-free (Intl.RelativeTimeFormat) — the frontend has no\n * date library and this is the only place that needs relative time.\n */\nconst DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [\n { amount: 60, unit: 'second' },\n { amount: 60, unit: 'minute' },\n { amount: 24, unit: 'hour' },\n { amount: 7, unit: 'day' },\n { amount: 4.34524, unit: 'week' },\n { amount: 12, unit: 'month' },\n { amount: Number.POSITIVE_INFINITY, unit: 'year' },\n]\n\nexport function formatRelativeTime(\n iso: string,\n now: number = Date.now(),\n): string {\n const then = Date.parse(iso)\n if (Number.isNaN(then)) return ''\n const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })\n // Every timestamp we render already happened, so clamp the future away: a\n // server clock a few seconds ahead of the browser otherwise turns a comment\n // posted a moment ago into \"in 4 seconds\".\n let duration = Math.min(0, (then - now) / 1000) // seconds, negative for the past\n for (const division of DIVISIONS) {\n if (Math.abs(duration) < division.amount) {\n return rtf.format(Math.round(duration), division.unit)\n }\n duration /= division.amount\n }\n return rtf.format(Math.round(duration), 'year')\n}\n"],"names":[],"mappings":"AAOA,MAAM,YAAqE;AAAA,EACzE,EAAE,QAAQ,IAAI,MAAM,SAAA;AAAA,EACpB,EAAE,QAAQ,IAAI,MAAM,SAAA;AAAA,EACpB,EAAE,QAAQ,IAAI,MAAM,OAAA;AAAA,EACpB,EAAE,QAAQ,GAAG,MAAM,MAAA;AAAA,EACnB,EAAE,QAAQ,SAAS,MAAM,OAAA;AAAA,EACzB,EAAE,QAAQ,IAAI,MAAM,QAAA;AAAA,EACpB,EAAE,QAAQ,OAAO,mBAAmB,MAAM,OAAA;AAC5C;AAEO,SAAS,mBACd,KACA,MAAc,KAAK,OACX;AACR,QAAM,OAAO,KAAK,MAAM,GAAG;AAC3B,MAAI,OAAO,MAAM,IAAI,EAAG,QAAO;AAC/B,QAAM,MAAM,IAAI,KAAK,mBAAmB,MAAM,EAAE,SAAS,QAAQ;AAIjE,MAAI,WAAW,KAAK,IAAI,IAAI,OAAO,OAAO,GAAI;AAC9C,aAAW,YAAY,WAAW;AAChC,QAAI,KAAK,IAAI,QAAQ,IAAI,SAAS,QAAQ;AACxC,aAAO,IAAI,OAAO,KAAK,MAAM,QAAQ,GAAG,SAAS,IAAI;AAAA,IACvD;AACA,gBAAY,SAAS;AAAA,EACvB;AACA,SAAO,IAAI,OAAO,KAAK,MAAM,QAAQ,GAAG,MAAM;AAChD;"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "1.0.1",
3
+ "version": "2.0.0-alpha-20260914220838",
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": "1.0.1"
116
+ "@igstack/app-catalog-shared-core": "2.0.0-alpha-20260914220838"
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": "1.0.1"
143
+ "@igstack/app-catalog-backend-core": "2.0.0-alpha-20260914220838"
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": "8714433058e9d88d5033d7625c7a62c143d3cea2",
152
+ "gitHead": "ce99b1bc970f7d70497ce0eda46dbd41f5a9c248",
153
153
  "scripts": {
154
154
  "build": "vite build",
155
155
  "build:lenient": "vite build --mode lenient",
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { formatRelativeTime } from '../../../../modules/appCatalog/utils/formatRelativeTime'
3
+
4
+ describe('formatRelativeTime', () => {
5
+ const now = Date.parse('2026-09-14T12:00:00.000Z')
6
+
7
+ it('formats past instants', () => {
8
+ expect(formatRelativeTime('2026-09-14T11:59:38.000Z', now)).toBe(
9
+ '22 seconds ago',
10
+ )
11
+ expect(formatRelativeTime('2026-09-12T12:00:00.000Z', now)).toBe(
12
+ '2 days ago',
13
+ )
14
+ })
15
+
16
+ it('reads a future instant as "now"', () => {
17
+ // The server writes the timestamp and the browser renders it; a clock a few
18
+ // seconds apart must not produce "in 4 seconds" for something just posted.
19
+ expect(formatRelativeTime('2026-09-14T12:00:04.000Z', now)).toBe('now')
20
+ })
21
+
22
+ it('returns an empty string for an unparseable value', () => {
23
+ expect(formatRelativeTime('not a date', now)).toBe('')
24
+ })
25
+ })
@@ -33,6 +33,7 @@ import { formatRelativeTime } from '../../utils/formatRelativeTime'
33
33
  import { TierVariantsSection } from '../components/TierVariantsSection'
34
34
  import { SubResourcesSection } from '../components/SubResourcesSection'
35
35
  import { getChildResources } from '../../utils/resolveHelpers'
36
+ import { CommentsSection } from './CommentsSection'
36
37
 
37
38
  function getIconUrl(iconName: string): string {
38
39
  return `/api/icons/${iconName}`
@@ -654,6 +655,8 @@ export function AppDetails({
654
655
  </div>
655
656
 
656
657
  {/* Freshness now shown near the header (see "Updated …" above). */}
658
+
659
+ <CommentsSection appSlug={app.slug} />
657
660
  </div>
658
661
 
659
662
  {/* Screenshot Gallery Dialog */}
@@ -0,0 +1,209 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
2
+ import { useState } from 'react'
3
+ import { useTRPC } from '~/api/infra/trpc'
4
+ import { Button } from '~/ui/button'
5
+ import { cn } from '~/lib/utils'
6
+ import { formatRelativeTime } from '../../utils/formatRelativeTime'
7
+
8
+ /**
9
+ * Feedback on one app, from anyone browsing the catalog.
10
+ *
11
+ * Nobody has to log in to leave a comment, so the server attributes each one to a
12
+ * pseudonym derived from an httpOnly cookie ("Curious Ferret"). That cookie is also
13
+ * the only thing that authorises editing or deleting, and only for the first hour —
14
+ * `canEditUntil` arrives from the server as an absolute instant so a skewed client
15
+ * clock cannot show controls the server would refuse.
16
+ */
17
+
18
+ /** Same shape the input primitive uses, minus the fixed height. */
19
+ const FIELD_CLASSES =
20
+ 'placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50'
21
+
22
+ function errorMessage(error: unknown): string {
23
+ return error instanceof Error ? error.message : 'Something went wrong'
24
+ }
25
+
26
+ /** Cmd/Ctrl+Enter submits; plain Enter keeps making paragraphs. */
27
+ function isSubmitChord(event: React.KeyboardEvent): boolean {
28
+ return event.key === 'Enter' && (event.metaKey || event.ctrlKey)
29
+ }
30
+
31
+ export function CommentsSection({ appSlug }: { appSlug: string }) {
32
+ const trpc = useTRPC()
33
+ const queryClient = useQueryClient()
34
+ const listOptions = trpc.comments.list.queryOptions({ resourceSlug: appSlug })
35
+ const { data: comments, isLoading, error } = useQuery(listOptions)
36
+
37
+ const [draft, setDraft] = useState('')
38
+ const [editingId, setEditingId] = useState<string | null>(null)
39
+ const [editDraft, setEditDraft] = useState('')
40
+
41
+ const refresh = () =>
42
+ queryClient.invalidateQueries({ queryKey: listOptions.queryKey })
43
+
44
+ const add = useMutation(
45
+ trpc.comments.add.mutationOptions({
46
+ onSuccess: () => {
47
+ setDraft('')
48
+ void refresh()
49
+ },
50
+ }),
51
+ )
52
+ const edit = useMutation(
53
+ trpc.comments.edit.mutationOptions({
54
+ onSuccess: () => {
55
+ setEditingId(null)
56
+ void refresh()
57
+ },
58
+ }),
59
+ )
60
+ const remove = useMutation(
61
+ trpc.comments.remove.mutationOptions({ onSuccess: () => void refresh() }),
62
+ )
63
+
64
+ const post = () => {
65
+ const body = draft.trim()
66
+ if (body) add.mutate({ resourceSlug: appSlug, body })
67
+ }
68
+
69
+ const saveEdit = (id: string) => {
70
+ const body = editDraft.trim()
71
+ if (body) edit.mutate({ id, body })
72
+ }
73
+
74
+ // One line for every failed action: three mutations and one query, one place to look.
75
+ const failure = error ?? add.error ?? edit.error ?? remove.error
76
+
77
+ return (
78
+ <div className="mt-6">
79
+ <h3 className="mb-2 text-sm font-medium">
80
+ Comments
81
+ {comments && comments.length > 0 && (
82
+ <span className="text-muted-foreground ml-1 font-normal">
83
+ ({comments.length})
84
+ </span>
85
+ )}
86
+ </h3>
87
+
88
+ {isLoading ? (
89
+ <p className="text-muted-foreground text-xs">Loading comments…</p>
90
+ ) : comments && comments.length > 0 ? (
91
+ <ul className="space-y-3">
92
+ {comments.map((comment) => {
93
+ const canEdit =
94
+ comment.isMine &&
95
+ !!comment.canEditUntil &&
96
+ Date.parse(comment.canEditUntil) > Date.now()
97
+
98
+ return (
99
+ <li key={comment.id} className="text-xs">
100
+ <div className="text-muted-foreground flex items-center gap-2">
101
+ <span className="text-foreground font-medium">
102
+ {comment.authorName}
103
+ </span>
104
+ <span>{formatRelativeTime(comment.createdAt)}</span>
105
+ {comment.editedAt && <span>(edited)</span>}
106
+ {canEdit && editingId !== comment.id && (
107
+ <>
108
+ <button
109
+ type="button"
110
+ className="hover:text-foreground underline"
111
+ onClick={() => {
112
+ setEditingId(comment.id)
113
+ setEditDraft(comment.body)
114
+ }}
115
+ >
116
+ Edit
117
+ </button>
118
+ <button
119
+ type="button"
120
+ className="hover:text-destructive underline"
121
+ onClick={() => remove.mutate({ id: comment.id })}
122
+ >
123
+ Delete
124
+ </button>
125
+ </>
126
+ )}
127
+ </div>
128
+
129
+ {editingId === comment.id ? (
130
+ <div className="mt-1 space-y-2">
131
+ <textarea
132
+ autoFocus
133
+ rows={3}
134
+ className={cn(FIELD_CLASSES)}
135
+ value={editDraft}
136
+ onChange={(event) => setEditDraft(event.target.value)}
137
+ onKeyDown={(event) => {
138
+ if (isSubmitChord(event)) {
139
+ event.preventDefault()
140
+ saveEdit(comment.id)
141
+ }
142
+ }}
143
+ />
144
+ <div className="flex gap-2">
145
+ <Button
146
+ type="button"
147
+ size="sm"
148
+ disabled={!editDraft.trim() || edit.isPending}
149
+ onClick={() => saveEdit(comment.id)}
150
+ >
151
+ Save
152
+ </Button>
153
+ <Button
154
+ type="button"
155
+ size="sm"
156
+ variant="ghost"
157
+ onClick={() => setEditingId(null)}
158
+ >
159
+ Cancel
160
+ </Button>
161
+ </div>
162
+ </div>
163
+ ) : (
164
+ <p className="mt-0.5 whitespace-pre-wrap">{comment.body}</p>
165
+ )}
166
+ </li>
167
+ )
168
+ })}
169
+ </ul>
170
+ ) : (
171
+ <p className="text-muted-foreground text-xs">
172
+ No comments yet. Be the first.
173
+ </p>
174
+ )}
175
+
176
+ <div className="mt-3 space-y-2">
177
+ <textarea
178
+ rows={3}
179
+ className={cn(FIELD_CLASSES)}
180
+ placeholder="Leave a comment…"
181
+ value={draft}
182
+ onChange={(event) => setDraft(event.target.value)}
183
+ onKeyDown={(event) => {
184
+ if (isSubmitChord(event)) {
185
+ event.preventDefault()
186
+ post()
187
+ }
188
+ }}
189
+ />
190
+ <div className="flex items-center gap-3">
191
+ <Button
192
+ type="button"
193
+ size="sm"
194
+ disabled={!draft.trim() || add.isPending}
195
+ onClick={post}
196
+ >
197
+ {add.isPending ? 'Posting…' : 'Post'}
198
+ </Button>
199
+ <span className="text-muted-foreground text-xs">
200
+ Posted under a nickname. You can edit or delete it for one hour.
201
+ </span>
202
+ </div>
203
+ {failure && (
204
+ <p className="text-destructive text-xs">{errorMessage(failure)}</p>
205
+ )}
206
+ </div>
207
+ </div>
208
+ )
209
+ }
@@ -22,7 +22,10 @@ export function formatRelativeTime(
22
22
  const then = Date.parse(iso)
23
23
  if (Number.isNaN(then)) return ''
24
24
  const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' })
25
- let duration = (then - now) / 1000 // seconds, negative for the past
25
+ // Every timestamp we render already happened, so clamp the future away: a
26
+ // server clock a few seconds ahead of the browser otherwise turns a comment
27
+ // posted a moment ago into "in 4 seconds".
28
+ let duration = Math.min(0, (then - now) / 1000) // seconds, negative for the past
26
29
  for (const division of DIVISIONS) {
27
30
  if (Math.abs(duration) < division.amount) {
28
31
  return rtf.format(Math.round(duration), division.unit)