@igstack/app-catalog-frontend-core 6.11.0 → 6.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"FeedRow.js","sources":["../../../../../src/modules/communityTools/ui/FeedRow.tsx"],"sourcesContent":["import type {\n CommunityTool,\n FeedSort,\n MatchField,\n} from '@igstack/app-catalog-shared-core'\nimport { Link } from '@tanstack/react-router'\nimport {\n CornerDownLeft,\n ExternalLink,\n Eye,\n Globe,\n PanelRight,\n} from 'lucide-react'\nimport { cn } from '~/lib/utils'\nimport { highlightTokens } from '~/util/highlightMatches'\nimport { timeAgo } from '../timeAgo'\nimport { LifecycleBadge, UnreviewedBadge } from './LifecycleBadge'\nimport { LikeButton } from './LikeButton'\nimport { PinButton } from './PinButton'\n\nexport function screenshotUrl(id: string, size: number): string {\n return `/api/screenshots/${id}?size=${size}`\n}\n\n/** The tool's screenshot, cropped to its top-left, or a globe when there is none. */\nexport function ToolThumb({\n tool,\n size,\n className,\n}: {\n tool: CommunityTool\n size: number\n className?: string\n}) {\n return (\n <div\n className={cn(\n 'flex shrink-0 items-center justify-center overflow-hidden border border-border bg-muted text-muted-foreground',\n className,\n )}\n >\n {tool.screenshotId ? (\n <img\n src={screenshotUrl(tool.screenshotId, size)}\n alt=\"\"\n loading=\"lazy\"\n className=\"size-full object-cover object-left-top\"\n />\n ) : (\n <Globe aria-hidden=\"true\" className=\"size-1/4 min-w-4\" />\n )}\n </div>\n )\n}\n\nconst AVATAR_TONES = [\n 'bg-orange-200',\n 'bg-sky-200',\n 'bg-lime-200',\n 'bg-amber-200',\n 'bg-rose-200',\n 'bg-violet-200',\n]\n\nexport function PersonAvatar({ name }: { name: string }) {\n const initials = name\n .split(/\\s+/)\n .slice(0, 2)\n .map((w) => w.charAt(0).toUpperCase())\n .join('')\n let hash = 0\n for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) | 0\n return (\n <span\n aria-hidden=\"true\"\n className={cn(\n 'inline-flex size-[22px] shrink-0 items-center justify-center rounded-full text-[9.5px] font-extrabold text-stone-700',\n AVATAR_TONES[Math.abs(hash) % AVATAR_TONES.length],\n )}\n >\n {initials}\n </span>\n )\n}\n\nconst FIELD_LABELS: Record<MatchField, string> = {\n name: 'name',\n summary: 'summary',\n description: 'how to use',\n owner: 'author',\n address: 'address',\n tag: 'tag',\n}\n\nexport interface FeedRowProps {\n tool: CommunityTool\n /** Shown in the ranked feed; search results have no rank. */\n rank?: number\n /** A date sort shows its date on the row. */\n sort: FeedSort\n tokens: string[]\n /** Set while searching: which fields the query hit. */\n matched?: MatchField[]\n selected?: boolean\n /**\n * Set while searching: the row is a row of the results grid, and this is the\n * id of its main cell, the one the search box points at.\n */\n optionId?: string\n pinned: boolean\n /** Its pin is being saved. */\n pinBusy?: boolean\n onTogglePin: () => void\n onToggleLike: () => void\n}\n\n/** The feed row's thumbnail: big enough to read the page title in its top left. */\nexport const ROW_THUMB = 'h-[150px] w-[240px] rounded-[10px]'\n\n/** The Details panel's width, reserved at rest so nothing moves when it shows. */\nconst PANEL = 'w-[120px]'\n\n/**\n * One tool in the feed or the search results. Everything left of the end\n * panel (rank, picture, name, summary, meta) is one link that opens the site\n * in a new tab; the 120 px panel opens the card. The star sits in the meta\n * line as its own target, above that link. The bottom strip shows the address\n * the big click goes to, on hover or focus only.\n */\nexport function FeedRow({\n tool,\n rank,\n sort,\n tokens,\n matched,\n selected = false,\n optionId,\n pinned,\n pinBusy,\n onTogglePin,\n onToggleLike,\n}: FeedRowProps) {\n const archived = tool.lifecycle.state === 'archived'\n const cell = optionId ? 'gridcell' : undefined\n const owners = tool.owners.map((o) => o.name).join(', ')\n const dated =\n sort === 'newest'\n ? `Added ${timeAgo(tool.createdAt)}`\n : sort === 'updated' && tool.siteUpdatedAt\n ? `Updated ${timeAgo(tool.siteUpdatedAt)}`\n : null\n const address = tool.url.replace(/^https?:\\/\\//, '').replace(/\\/$/, '')\n\n return (\n <div\n data-testid=\"feed-row\"\n data-slug={tool.slug}\n data-selected={selected || undefined}\n role={optionId ? 'row' : undefined}\n className={cn(\n 'group/row relative flex min-h-[176px] items-center overflow-hidden rounded-[14px] border bg-card transition-[border-color,box-shadow]',\n selected\n ? 'border-primary ring-[3px] ring-primary/20'\n : 'border-border hover:border-primary/35 hover:shadow-sm has-[:focus-visible]:border-primary/35',\n archived && 'opacity-55',\n )}\n >\n {/* Two cells while searching: the site, and the card. A grid rather\n than a listbox, because an option's content is presentational and\n would hide the link and the star. */}\n <div\n id={optionId}\n role={cell}\n aria-selected={optionId ? selected : undefined}\n className=\"flex min-w-0 flex-1 items-center gap-4 self-stretch py-3 pl-3\"\n >\n {rank !== undefined && (\n <span className=\"w-[26px] shrink-0 text-center font-serif text-[22px] font-semibold text-muted-foreground/70\">\n {rank}\n </span>\n )}\n <ToolThumb tool={tool} size={512} className={ROW_THUMB} />\n <div className=\"min-w-0 flex-1 self-start pt-5 pr-6 pb-[52px]\">\n <div className=\"flex items-center gap-2.5 text-[15.5px] font-extrabold\">\n <a\n href={tool.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n data-site\n data-testid=\"feed-row-name\"\n aria-label={`${tool.name}, opens ${tool.host} in a new tab`}\n className={cn(\n 'truncate decoration-1 underline-offset-[3px] after:absolute after:inset-y-0 after:left-0 after:right-[120px] after:z-[1] after:rounded-l-[13px] focus-visible:outline-none focus-visible:after:outline-2 focus-visible:after:-outline-offset-[3px] focus-visible:after:outline-ring',\n 'group-hover/row:underline group-has-[[data-details]:hover]/row:no-underline',\n archived && 'text-muted-foreground',\n )}\n >\n {highlightTokens(tool.name, tokens)}\n </a>\n {selected && (\n <span className=\"inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11.5px] font-bold text-primary\">\n <CornerDownLeft aria-hidden=\"true\" className=\"size-3\" />\n Enter opens site\n </span>\n )}\n <LifecycleBadge lifecycle={tool.lifecycle} />\n {!tool.reviewed && <UnreviewedBadge />}\n </div>\n {tool.summary && (\n <div className=\"mt-1 line-clamp-2 text-foreground/75\">\n {highlightTokens(tool.summary, tokens)}\n </div>\n )}\n {matched && matched.length > 0 && (\n <div\n data-testid=\"match-reason\"\n className=\"mt-1 text-xs font-semibold text-muted-foreground\"\n >\n Matched: {matched.map((f) => FIELD_LABELS[f]).join(', ')}\n </div>\n )}\n </div>\n {/* Meta on the left, the address on the right, flush to the panel.\n The address keeps 150 px; a long author list gives way first.\n Clicks fall through to the row link, except on star and like. */}\n <div\n className={cn(\n 'pointer-events-none absolute right-[120px] bottom-0 z-[2] flex h-12 items-center gap-4',\n rank !== undefined ? 'left-[310px]' : 'left-[268px]',\n )}\n >\n <div className=\"flex min-w-0 items-center gap-1.5 text-[13px] font-semibold whitespace-nowrap text-foreground/75 [&>*]:shrink-0\">\n {tool.owners[0] && <PersonAvatar name={tool.owners[0].name} />}\n <span\n data-testid=\"feed-row-owner\"\n className=\"mr-1 min-w-0 !shrink truncate\"\n >\n {highlightTokens(owners, tokens)}\n </span>\n <span className=\"pointer-events-auto -mr-1 flex items-center\">\n <PinButton\n small\n pinned={pinned}\n busy={pinBusy}\n name={tool.name}\n onToggle={onTogglePin}\n />\n <LikeButton tool={tool} onToggle={onToggleLike} />\n </span>\n <span\n data-testid=\"feed-row-views\"\n className=\"inline-flex items-center gap-1 text-muted-foreground\"\n >\n <Eye aria-hidden=\"true\" className=\"size-[15px]\" />\n {tool.views30d}\n <span className=\"sr-only\"> views this month</span>\n </span>\n {dated && (\n <>\n <span aria-hidden=\"true\" className=\"text-muted-foreground\">\n ·\n </span>\n <span\n data-testid=\"feed-row-date\"\n className=\"text-muted-foreground\"\n >\n {dated}\n </span>\n </>\n )}\n </div>\n <div\n aria-hidden=\"true\"\n data-testid=\"feed-row-address\"\n className={cn(\n 'flex h-full min-w-[150px] flex-1 items-center justify-end gap-1.5 px-4 font-mono text-[12.5px] text-muted-foreground opacity-0 transition-opacity',\n 'group-hover/row:text-foreground group-hover/row:opacity-100 group-has-[[data-site]:focus-visible]/row:opacity-100 group-has-[[data-details]:hover]/row:opacity-0 [@media(hover:none)]:opacity-100',\n selected && 'opacity-100',\n )}\n >\n <span className=\"min-w-0 truncate\">{address}</span>\n <ExternalLink aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n </div>\n </div>\n </div>\n <div role={cell} className=\"flex self-stretch\">\n <Link\n to=\"/community/$slug\"\n params={{ slug: tool.slug }}\n search={(prev) => prev}\n data-details\n aria-label={`Details for ${tool.name}`}\n className={cn(\n PANEL,\n 'relative z-[2] flex flex-none flex-col items-center justify-center gap-1.5 border-l border-border/55 text-[13.5px] font-extrabold text-muted-foreground transition-colors',\n 'group-hover/row:border-border group-hover/row:bg-primary/[0.025] group-has-[:focus-visible]/row:bg-primary/[0.025]',\n 'hover:!border-primary/30 hover:!bg-primary/12 hover:text-foreground',\n 'focus-visible:rounded-r-[13px] focus-visible:!bg-primary/12 focus-visible:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-[3px] focus-visible:outline-ring',\n )}\n >\n {/* Shown on hover or focus; always where nothing hovers. */}\n <span className=\"flex flex-col items-center gap-1.5 opacity-0 transition-opacity group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 [@media(hover:none)]:opacity-100\">\n <PanelRight aria-hidden=\"true\" className=\"size-[18px]\" />\n <span>Details</span>\n </span>\n </Link>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"FeedRow.js","sources":["../../../../../src/modules/communityTools/ui/FeedRow.tsx"],"sourcesContent":["import type {\n CommunityTool,\n FeedSort,\n MatchField,\n} from '@igstack/app-catalog-shared-core'\nimport { Link } from '@tanstack/react-router'\nimport {\n CornerDownLeft,\n Eye,\n Globe,\n MessageSquare,\n PanelRight,\n} from 'lucide-react'\nimport { cn } from '~/lib/utils'\nimport { highlightTokens } from '~/util/highlightMatches'\nimport { timeAgo } from '../timeAgo'\nimport { LifecycleBadge, UnreviewedBadge } from './LifecycleBadge'\nimport { LikeButton } from './LikeButton'\nimport { PinButton } from './PinButton'\n\nexport function screenshotUrl(id: string, size: number): string {\n return `/api/screenshots/${id}?size=${size}`\n}\n\n/** The tool's screenshot, cropped to its top-left, or a globe when there is none. */\nexport function ToolThumb({\n tool,\n size,\n className,\n}: {\n tool: CommunityTool\n size: number\n className?: string\n}) {\n return (\n <div\n className={cn(\n 'flex shrink-0 items-center justify-center overflow-hidden border border-border bg-muted text-muted-foreground',\n className,\n )}\n >\n {tool.screenshotId ? (\n <img\n src={screenshotUrl(tool.screenshotId, size)}\n alt=\"\"\n loading=\"lazy\"\n className=\"size-full object-cover object-left-top\"\n />\n ) : (\n <Globe aria-hidden=\"true\" className=\"size-1/4 min-w-4\" />\n )}\n </div>\n )\n}\n\nconst AVATAR_TONES = [\n 'bg-orange-200',\n 'bg-sky-200',\n 'bg-lime-200',\n 'bg-amber-200',\n 'bg-rose-200',\n 'bg-violet-200',\n]\n\nexport function PersonAvatar({ name }: { name: string }) {\n const initials = name\n .split(/\\s+/)\n .slice(0, 2)\n .map((w) => w.charAt(0).toUpperCase())\n .join('')\n let hash = 0\n for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) | 0\n return (\n <span\n aria-hidden=\"true\"\n className={cn(\n 'inline-flex size-[22px] shrink-0 items-center justify-center rounded-full text-[9.5px] font-extrabold text-stone-700',\n AVATAR_TONES[Math.abs(hash) % AVATAR_TONES.length],\n )}\n >\n {initials}\n </span>\n )\n}\n\nconst FIELD_LABELS: Record<MatchField, string> = {\n name: 'name',\n summary: 'summary',\n description: 'how to use',\n owner: 'author',\n address: 'address',\n tag: 'tag',\n}\n\nexport interface FeedRowProps {\n tool: CommunityTool\n /** Shown in the ranked feed; search results have no rank. */\n rank?: number\n /** Recently updated shows when the site changed; every other sort, when it was added. */\n sort: FeedSort\n tokens: string[]\n /** Set while searching: which fields the query hit. */\n matched?: MatchField[]\n selected?: boolean\n /**\n * Set while searching: the row is a row of the results grid, and this is the\n * id of its main cell, the one the search box points at.\n */\n optionId?: string\n pinned: boolean\n /** Its pin is being saved. */\n pinBusy?: boolean\n onTogglePin: () => void\n onToggleLike: () => void\n}\n\n/** The feed row's thumbnail: big enough to read the page title in its top left. */\nexport const ROW_THUMB = 'h-[150px] w-[240px] rounded-[10px]'\n\n/** The Details panel's width, reserved at rest so nothing moves when it shows. */\nconst PANEL = 'w-[120px]'\n\n/**\n * One tool in the feed or the search results. Everything left of the end\n * panel (rank, picture, name, summary, meta) is one link that opens the site\n * in a new tab; the 120 px panel opens the card. The star sits in the meta\n * line as its own target, above that link.\n */\nexport function FeedRow({\n tool,\n rank,\n sort,\n tokens,\n matched,\n selected = false,\n optionId,\n pinned,\n pinBusy,\n onTogglePin,\n onToggleLike,\n}: FeedRowProps) {\n const archived = tool.lifecycle.state === 'archived'\n const cell = optionId ? 'gridcell' : undefined\n const owners = tool.owners.map((o) => o.name).join(', ')\n const dated =\n sort !== 'updated'\n ? `Added ${timeAgo(tool.createdAt)}`\n : tool.siteUpdatedAt\n ? `Updated ${timeAgo(tool.siteUpdatedAt)}`\n : null\n\n return (\n <div\n data-testid=\"feed-row\"\n data-slug={tool.slug}\n data-selected={selected || undefined}\n role={optionId ? 'row' : undefined}\n className={cn(\n 'group/row relative flex min-h-[176px] items-center overflow-hidden rounded-[14px] border bg-card transition-[border-color,box-shadow]',\n selected\n ? 'border-primary ring-[3px] ring-primary/20'\n : 'border-border hover:border-primary/35 hover:shadow-sm has-[:focus-visible]:border-primary/35',\n archived && 'opacity-55',\n )}\n >\n {/* Two cells while searching: the site, and the card. A grid rather\n than a listbox, because an option's content is presentational and\n would hide the link and the star. */}\n <div\n id={optionId}\n role={cell}\n aria-selected={optionId ? selected : undefined}\n className=\"flex min-w-0 flex-1 items-center gap-4 self-stretch py-3 pl-3\"\n >\n {rank !== undefined && (\n <span className=\"w-[26px] shrink-0 text-center font-serif text-[22px] font-semibold text-muted-foreground/70\">\n {rank}\n </span>\n )}\n <ToolThumb tool={tool} size={512} className={ROW_THUMB} />\n <div className=\"min-w-0 flex-1 self-start pt-5 pr-6 pb-[52px]\">\n <div className=\"flex items-center gap-2.5 text-[15.5px] font-extrabold\">\n <a\n href={tool.url}\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n data-site\n data-testid=\"feed-row-name\"\n aria-label={`${tool.name}, opens ${tool.host} in a new tab`}\n className={cn(\n 'truncate decoration-1 underline-offset-[3px] after:absolute after:inset-y-0 after:left-0 after:right-[120px] after:z-[1] after:rounded-l-[13px] focus-visible:outline-none focus-visible:after:outline-2 focus-visible:after:-outline-offset-[3px] focus-visible:after:outline-ring',\n 'group-hover/row:underline group-has-[[data-details]:hover]/row:no-underline',\n archived && 'text-muted-foreground',\n )}\n >\n {highlightTokens(tool.name, tokens)}\n </a>\n {selected && (\n <span className=\"inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-[11.5px] font-bold text-primary\">\n <CornerDownLeft aria-hidden=\"true\" className=\"size-3\" />\n Enter opens site\n </span>\n )}\n <LifecycleBadge lifecycle={tool.lifecycle} />\n {!tool.reviewed && <UnreviewedBadge />}\n </div>\n {tool.summary && (\n <div className=\"mt-1 line-clamp-2 text-foreground/75\">\n {highlightTokens(tool.summary, tokens)}\n </div>\n )}\n {matched && matched.length > 0 && (\n <div\n data-testid=\"match-reason\"\n className=\"mt-1 text-xs font-semibold text-muted-foreground\"\n >\n Matched: {matched.map((f) => FIELD_LABELS[f]).join(', ')}\n </div>\n )}\n </div>\n {/* Meta on the left, the date on the right, flush to the panel. A\n long author list gives way first. Clicks fall through to the row\n link, except on star and like. */}\n <div\n className={cn(\n 'pointer-events-none absolute right-[120px] bottom-0 z-[2] flex h-12 items-center gap-4',\n rank !== undefined ? 'left-[310px]' : 'left-[268px]',\n )}\n >\n <div className=\"flex min-w-0 items-center gap-1.5 text-[13px] font-semibold whitespace-nowrap text-foreground/75 [&>*]:shrink-0\">\n {tool.owners[0] && <PersonAvatar name={tool.owners[0].name} />}\n <span\n data-testid=\"feed-row-owner\"\n className=\"mr-1 min-w-0 !shrink truncate\"\n >\n {highlightTokens(owners, tokens)}\n </span>\n <span className=\"pointer-events-auto -mr-1 flex items-center\">\n <PinButton\n small\n pinned={pinned}\n busy={pinBusy}\n name={tool.name}\n onToggle={onTogglePin}\n />\n <LikeButton tool={tool} onToggle={onToggleLike} />\n </span>\n <span\n data-testid=\"feed-row-views\"\n className=\"inline-flex items-center gap-1 text-muted-foreground\"\n >\n <Eye aria-hidden=\"true\" className=\"size-[15px]\" />\n {tool.views30d}\n <span className=\"sr-only\"> views this month</span>\n </span>\n {tool.comments > 0 && (\n <span\n data-testid=\"feed-row-comments\"\n className=\"inline-flex items-center gap-1 text-muted-foreground\"\n >\n <MessageSquare aria-hidden=\"true\" className=\"size-[15px]\" />\n {tool.comments}\n <span className=\"sr-only\">\n {tool.comments === 1 ? ' comment' : ' comments'}\n </span>\n </span>\n )}\n </div>\n {dated && (\n <span\n data-testid=\"feed-row-date\"\n className=\"ml-auto shrink-0 pr-4 text-[13px] font-semibold whitespace-nowrap text-muted-foreground\"\n >\n {dated}\n </span>\n )}\n </div>\n </div>\n <div role={cell} className=\"flex self-stretch\">\n <Link\n to=\"/community/$slug\"\n params={{ slug: tool.slug }}\n search={(prev) => prev}\n data-details\n aria-label={`Details for ${tool.name}`}\n className={cn(\n PANEL,\n 'relative z-[2] flex flex-none flex-col items-center justify-center gap-1.5 border-l border-border/55 text-[13.5px] font-extrabold text-muted-foreground transition-colors',\n 'group-hover/row:border-border group-hover/row:bg-primary/[0.025] group-has-[:focus-visible]/row:bg-primary/[0.025]',\n 'hover:!border-primary/30 hover:!bg-primary/12 hover:text-foreground',\n 'focus-visible:rounded-r-[13px] focus-visible:!bg-primary/12 focus-visible:text-foreground focus-visible:outline-2 focus-visible:-outline-offset-[3px] focus-visible:outline-ring',\n )}\n >\n {/* Shown on hover or focus; always where nothing hovers. */}\n <span className=\"flex flex-col items-center gap-1.5 opacity-0 transition-opacity group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 [@media(hover:none)]:opacity-100\">\n <PanelRight aria-hidden=\"true\" className=\"size-[18px]\" />\n <span>Details</span>\n </span>\n </Link>\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
@@ -11,6 +11,7 @@ import { CommunityInput } from "../../appCatalog/ui/detail/CommunityInput.js";
11
11
  import { cn } from "../../../lib/utils.js";
12
12
  import { useAuth } from "../../auth/AuthContext.js";
13
13
  import "../../../ui/button.js";
14
+ import "../../auth/AuthModalContext.js";
14
15
  import { Skeleton } from "../../../ui/skeleton.js";
15
16
  import { formatCardDate, CardScreenshot } from "./CardScreenshot.js";
16
17
  import { CardTitleBlock } from "./CardTitleBlock.js";
@@ -1 +1 @@
1
- {"version":3,"file":"ToolCard.js","sources":["../../../../../src/modules/communityTools/ui/ToolCard.tsx"],"sourcesContent":["import type { Claim } from '@igstack/app-catalog-backend-core'\nimport type { CommunityTool } from '@igstack/app-catalog-shared-core'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport {\n Archive,\n ArrowLeft,\n ArrowRight,\n Check,\n Eye,\n Pencil,\n} from 'lucide-react'\nimport { useEffect, useId, useRef, useState } from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport {\n DIALOG_ROUND_BUTTON,\n DetailDialogShell,\n} from '~/modules/appCatalog/ui/catalog/DetailDialogShell'\nimport { MarkdownText } from '~/modules/appCatalog/ui/components/MarkdownText'\nimport { CommentsFeed } from '~/modules/appCatalog/ui/detail/CommentsFeed'\nimport { CommentsHelp } from '~/modules/appCatalog/ui/detail/CommentsHelp'\nimport { CommunityInput } from '~/modules/appCatalog/ui/detail/CommunityInput'\nimport type { CommunityInputHandle } from '~/modules/appCatalog/ui/detail/CommunityInput'\nimport { cn } from '~/lib/utils'\nimport { useAuth } from '~/modules/auth'\nimport { Skeleton } from '~/ui/skeleton'\nimport { CardScreenshot, formatCardDate } from './CardScreenshot'\nimport { CardTitleBlock } from './CardTitleBlock'\nimport { PersonAvatar } from './FeedRow'\nimport { useTimeUp } from '../useTimeUp'\nimport { PinButton } from './PinButton'\nimport { TagChip, tagKey, tagLabel } from './TagRow'\nimport { ArchiveConfirm, GHOST_BUTTON, ToolFormDialog } from './ToolFormDialog'\n\nconst KBD =\n 'rounded-[5px] border border-b-2 border-border bg-card px-1.5 py-px text-[11px] font-semibold text-muted-foreground'\n\nconst SECTION = 'mt-6 border-t border-border pt-[22px]'\n\nconst LABEL =\n 'text-[11.5px] font-bold tracking-wider text-muted-foreground uppercase'\n\nconst NO_CLAIMS: Claim[] = []\n\nconst DAY_MS = 24 * 60 * 60 * 1000\n\n/** How often a card whose first screenshot is on its way asks again. */\nconst CAPTURE_POLL_MS = 5000\n\n/** How long one open card waits for a first screenshot before it stops asking. */\nconst CAPTURE_WAIT_MS = 2 * 60 * 1000\n\nexport interface ToolCardProps {\n slug: string\n /** The list the card was opened from, in its order: ←/→ step through it. */\n siblings: string[]\n onClose: () => void\n onNavigate: (slug: string) => void\n pinned: boolean\n /** Its pin is being saved. */\n pinBusy?: boolean\n onTogglePin: (tool: CommunityTool) => void\n /** Display names of official apps by slug, for app tags. */\n appNames: Record<string, string>\n /** A tag chip on the card: close it and filter the feed to that tag. */\n onChooseTag: (tag: string) => void\n}\n\n/** Typing belongs to the field: the card's keys stay out of it. */\nfunction isTyping(target: HTMLElement): boolean {\n return (\n target.isContentEditable ||\n target.closest('input, textarea, select') !== null\n )\n}\n\n/**\n * A community tool over the Community page, in the official card's shell: no\n * tabs, one scroll. Title and screenshot to recognise it, how to use it, who\n * to ask, and the comments.\n */\nexport function ToolCard({\n slug,\n siblings,\n onClose,\n onNavigate,\n pinned,\n pinBusy,\n onTogglePin,\n appNames,\n onChooseTag,\n}: ToolCardProps) {\n const { user } = useAuth()\n const isAdmin = user?.isAdmin === true\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n // The feed already holds every tool, so the card paints at once from it\n // and the tool's own query only refreshes it.\n const list = useQuery(trpc.communityTools.list.queryOptions())\n const fromList = list.data?.find((t) => t.slug === slug)\n // Whatever the server says, an open card stops waiting after a while: a\n // capture that never reports back must not keep it asking forever.\n const gaveUpOnCapture = useTimeUp(slug, CAPTURE_WAIT_MS)\n const get = useQuery({\n ...trpc.communityTools.get.queryOptions({ slug }),\n placeholderData: fromList,\n // Until the first visit lands, keep asking, so the screenshot replaces\n // the placeholder without a reload.\n refetchInterval: (query) =>\n query.state.data?.capturePending && !gaveUpOnCapture\n ? CAPTURE_POLL_MS\n : false,\n })\n const tool = get.data\n\n // One view per open. The ref keeps StrictMode's second effect from counting\n // twice; stepping to another tool is another open.\n const [counts, setCounts] = useState<{\n slug: string\n views30d: number\n viewsAll: number\n } | null>(null)\n // The hook's own onSuccess, not one passed to `mutate`: StrictMode's\n // remount detaches the observer a `mutate` callback hangs on, so that one\n // never fires and the count stayed one short.\n const { mutate: recordView } = useMutation(\n trpc.communityTools.recordView.mutationOptions({\n onSuccess: (c, { slug: viewed }) => {\n setCounts({ slug: viewed, ...c })\n },\n }),\n )\n const recordedForRef = useRef<string | null>(null)\n useEffect(() => {\n if (recordedForRef.current === slug) return\n recordedForRef.current = slug\n recordView({ slug })\n }, [slug, recordView])\n const views = counts?.slug === slug ? counts : tool\n\n const [editing, setEditing] = useState(false)\n const [archiving, setArchiving] = useState(false)\n const refreshTools = () =>\n queryClient.invalidateQueries({\n queryKey: trpc.communityTools.pathKey(),\n })\n const approve = useMutation(\n trpc.communityTools.approve.mutationOptions({ onSuccess: refreshTools }),\n )\n const archive = useMutation(\n trpc.communityTools.archive.mutationOptions({\n onSuccess: () => {\n setArchiving(false)\n return refreshTools()\n },\n }),\n )\n const adminError = approve.error ?? archive.error\n\n const commentsRef = useRef<CommunityInputHandle>(null)\n const commentsHeadingId = useId()\n const commentTarget = { communityToolSlug: slug }\n const comments = useQuery(trpc.feedback.list.queryOptions(commentTarget))\n const commentCount = comments.data?.items.length ?? 0\n\n const index = siblings.indexOf(slug)\n const prev = index > 0 ? siblings[index - 1] : undefined\n const next = index >= 0 ? siblings[index + 1] : undefined\n const nameOf = (s: string) => list.data?.find((t) => t.slug === s)?.name ?? s\n\n const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n // React bubbles through portals: keys in the screenshot viewer are not ours.\n const target = e.target as HTMLElement\n if (!e.currentTarget.contains(target)) return\n if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return\n if (isTyping(target)) return\n if (e.key === 'ArrowLeft' && prev) {\n e.preventDefault()\n onNavigate(prev)\n } else if (e.key === 'ArrowRight' && next) {\n e.preventDefault()\n onNavigate(next)\n } else if (e.key === 'Enter' && tool) {\n // A link or button answers Enter itself; the title block is the link\n // that opens the site.\n if (target.closest('a, button')) return\n e.preventDefault()\n window.open(tool.url, '_blank', 'noopener,noreferrer')\n }\n }\n\n const controls = index >= 0 && siblings.length > 1 && (\n <>\n <span className=\"flex items-center gap-1 text-[13px] font-semibold text-muted-foreground\">\n <button\n type=\"button\"\n aria-label={prev ? `Previous tool: ${nameOf(prev)}` : 'Previous tool'}\n disabled={!prev}\n onClick={() => prev && onNavigate(prev)}\n className={cn(DIALOG_ROUND_BUTTON, 'disabled:opacity-40')}\n >\n <ArrowLeft className=\"size-4\" />\n </button>\n <span data-testid=\"card-position\">\n {index + 1} of {siblings.length}\n </span>\n <button\n type=\"button\"\n aria-label={next ? `Next tool: ${nameOf(next)}` : 'Next tool'}\n disabled={!next}\n onClick={() => next && onNavigate(next)}\n className={cn(DIALOG_ROUND_BUTTON, 'disabled:opacity-40')}\n >\n <ArrowRight className=\"size-4\" />\n </button>\n </span>\n <i aria-hidden=\"true\" className=\"mx-1 h-5 w-px bg-border\" />\n </>\n )\n\n return (\n <DetailDialogShell\n label={`${tool?.name ?? 'Community tool'} details`}\n shownKey={slug}\n maxWidthClass=\"max-w-[min(920px,94vw)]\"\n focusSelector='[data-testid=\"card-title-block\"]'\n controls={controls}\n testId=\"tool-card\"\n onClose={onClose}\n onKeyDown={onKeyDown}\n >\n {tool ? (\n <>\n <div className=\"border-b border-border pb-[22px]\">\n <div className=\"flex items-end justify-between gap-4 pr-44\">\n <CardTitleBlock tool={tool} />\n <div className=\"-mr-40 flex shrink-0 items-center gap-2\">\n {tool.canEdit && (\n <button\n type=\"button\"\n data-testid=\"card-edit-button\"\n onClick={() => setEditing(true)}\n className={cn(GHOST_BUTTON, 'h-10 px-3.5 text-[13.5px]')}\n >\n <Pencil aria-hidden=\"true\" className=\"size-4\" />\n Edit\n </button>\n )}\n <PinButton\n labelled\n pinned={pinned}\n busy={pinBusy}\n name={tool.name}\n onToggle={() => onTogglePin(tool)}\n />\n </div>\n </div>\n <div\n className={cn(\n tool.lifecycle.state === 'archived' && 'opacity-60',\n )}\n >\n {tool.summary && (\n <p className=\"mt-3.5 text-[15px] text-foreground/80\">\n {tool.summary}\n </p>\n )}\n {tool.tags.length > 0 && (\n <div\n data-testid=\"card-tags\"\n className=\"mt-2.5 flex flex-wrap gap-1.5\"\n >\n {tool.tags.map((tag) => (\n <TagChip\n key={tagKey(tag)}\n label={tagLabel(tag, appNames)}\n onClick={() => onChooseTag(tagKey(tag))}\n />\n ))}\n </div>\n )}\n {views && (\n <p\n data-testid=\"card-views\"\n className=\"mt-2.5 flex gap-[18px] text-[13px] font-semibold text-muted-foreground\"\n >\n <span className=\"inline-flex items-center gap-1.5\">\n <Eye aria-hidden=\"true\" className=\"size-[15px]\" />\n <b className=\"text-foreground\">\n {views.views30d.toLocaleString('en-US')}\n </b>{' '}\n opens this month\n </span>\n <span>\n <b className=\"text-foreground\">\n {views.viewsAll.toLocaleString('en-US')}\n </b>{' '}\n all time\n </span>\n </p>\n )}\n {/* Who to ask, up here with the facts: below the screenshot\n it fell under the fold. */}\n <p\n data-testid=\"card-people\"\n className=\"mt-2.5 flex flex-wrap items-center gap-x-[18px] gap-y-1.5 text-[13px] font-semibold text-muted-foreground\"\n >\n {tool.owners.length > 0 && (\n <span className=\"inline-flex flex-wrap items-center gap-x-3 gap-y-1.5\">\n <span className={LABEL}>Owners</span>\n {tool.owners.map((owner) => (\n <span\n key={owner.slug ?? `named:${owner.name}`}\n className=\"inline-flex items-center gap-1.5 text-foreground\"\n >\n <PersonAvatar name={owner.name} />\n <span data-testid=\"card-owner\">{owner.name}</span>\n </span>\n ))}\n </span>\n )}\n <span className=\"inline-flex items-center gap-1.5\">\n <span className={LABEL}>Added by</span>\n <span className=\"text-foreground\">{tool.addedBy.name}</span>\n <span className=\"font-normal\">\n {formatCardDate(tool.createdAt)}\n </span>\n </span>\n </p>\n </div>\n <LifecycleNote\n tool={tool}\n onTellOwner={() => commentsRef.current?.focus()}\n />\n {isAdmin && (\n <div\n data-testid=\"card-admin\"\n className=\"mt-3.5 flex flex-col gap-2.5 rounded-[10px] border border-dashed border-border px-3 py-2.5\"\n >\n {archiving ? (\n <ArchiveConfirm\n busy={archive.isPending}\n onCancel={() => setArchiving(false)}\n onArchive={(reason) => archive.mutate({ slug, reason })}\n />\n ) : (\n <div className=\"flex flex-wrap items-center gap-2.5\">\n <span className=\"mr-auto text-[12.5px] font-bold tracking-wider text-muted-foreground uppercase\">\n Admin\n </span>\n {!tool.reviewed && (\n <button\n type=\"button\"\n disabled={approve.isPending}\n aria-busy={approve.isPending || undefined}\n onClick={() => approve.mutate({ slug })}\n className={GHOST_BUTTON}\n >\n <Check aria-hidden=\"true\" className=\"size-4\" />\n {approve.isPending ? 'Approving…' : 'Approve'}\n </button>\n )}\n {tool.lifecycle.state !== 'archived' && (\n <button\n type=\"button\"\n onClick={() => setArchiving(true)}\n className={GHOST_BUTTON}\n >\n <Archive aria-hidden=\"true\" className=\"size-4\" />\n Archive\n </button>\n )}\n </div>\n )}\n {adminError && (\n <p\n role=\"alert\"\n className=\"text-[13px] font-semibold text-destructive\"\n >\n {adminError.message}\n </p>\n )}\n </div>\n )}\n </div>\n\n <CardScreenshot\n tool={tool}\n capturing={tool.capturePending && !gaveUpOnCapture}\n />\n\n {tool.descriptionMd.trim() && (\n <section\n data-testid=\"card-how-to-use\"\n className={cn(SECTION, 'text-[14.5px]')}\n >\n <h3 className=\"mb-2 font-serif text-[17px] font-semibold\">\n How to use\n </h3>\n <div className=\"prose prose-sm dark:prose-invert max-w-[68ch]\">\n <MarkdownText>{tool.descriptionMd}</MarkdownText>\n </div>\n </section>\n )}\n\n {/* The card's own order: what was said first, the input last. The\n official card's CommunitySection puts the input on top. */}\n <section data-testid=\"card-comments\" className={SECTION}>\n <div className=\"comments-head\">\n <h2 id={commentsHeadingId} className=\"behind-group__title\">\n Comments\n </h2>\n {commentCount > 0 && (\n <span\n data-testid=\"card-comment-count\"\n className=\"rounded-full bg-muted px-2 text-xs leading-5 font-bold text-muted-foreground\"\n >\n {commentCount}\n </span>\n )}\n <CommentsHelp appName={tool.name} />\n </div>\n <CommentsFeed\n target={commentTarget}\n claims={NO_CLAIMS}\n labelledBy={commentsHeadingId}\n />\n <div className=\"mt-3.5\">\n <CommunityInput\n ref={commentsRef}\n target={commentTarget}\n appName={tool.name}\n />\n </div>\n </section>\n\n <p className=\"mt-[22px] flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12.5px] font-semibold text-muted-foreground\">\n <span className=\"inline-flex items-center gap-1.5\">\n <kbd className={KBD}>Esc</kbd> close\n </span>\n <span className=\"inline-flex items-center gap-1.5\">\n <kbd className={KBD}>←</kbd>\n <kbd className={KBD}>→</kbd> previous / next tool\n </span>\n <span className=\"inline-flex items-center gap-1.5\">\n <kbd className={KBD}>Enter</kbd> open site\n <span className=\"font-normal\">\n (in the comment box, Enter sends)\n </span>\n </span>\n </p>\n {editing && (\n <ToolFormDialog\n mode=\"edit\"\n initial={tool}\n onClose={() => setEditing(false)}\n onDone={() => setEditing(false)}\n />\n )}\n </>\n ) : get.isError ? (\n <p className=\"py-10 text-center text-muted-foreground\">\n This tool is not listed any more.\n </p>\n ) : (\n <div aria-busy=\"true\" className=\"flex flex-col gap-4\">\n <Skeleton className=\"h-[72px] w-2/3 rounded-[14px]\" />\n <Skeleton className=\"h-4 w-1/2\" />\n <Skeleton className=\"aspect-[16/10] w-full rounded-[14px]\" />\n </div>\n )}\n </DetailDialogShell>\n )\n}\n\n/** Why a tool that is not active is shown the way it is. */\nfunction LifecycleNote({\n tool,\n onTellOwner,\n}: {\n tool: CommunityTool\n onTellOwner: () => void\n}) {\n const { lifecycle } = tool\n const [now] = useState(Date.now)\n if (lifecycle.state === 'not-working') {\n const since = new Date(now - lifecycle.days * DAY_MS).toISOString()\n return (\n <p\n data-testid=\"lifecycle-note\"\n className=\"mt-3 rounded-[10px] bg-amber-50 px-3 py-[9px] text-[13.5px] text-amber-900 dark:bg-amber-950 dark:text-amber-200\"\n >\n The daily check has not reached this site since{' '}\n {formatCardDate(since).replace(/ \\d{4}$/, '')}. It may be down or moved.{' '}\n <button\n type=\"button\"\n onClick={onTellOwner}\n className=\"font-bold underline underline-offset-2\"\n >\n Tell the owner\n </button>\n </p>\n )\n }\n if (lifecycle.state === 'archived') {\n const on = formatCardDate(lifecycle.at)\n return (\n <p\n data-testid=\"lifecycle-note\"\n className=\"mt-3 rounded-[10px] bg-muted px-3 py-[9px] text-[13.5px] text-muted-foreground\"\n >\n {lifecycle.byName\n ? `Archived by ${lifecycle.byName} on ${on}${lifecycle.reason ? `: ${lifecycle.reason}` : ''}.`\n : `Archived automatically on ${on} after 30 days not working.`}{' '}\n Kept here so old links still land somewhere.\n </p>\n )\n }\n return null\n}\n"],"names":["_a","_b"],"mappings":"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"}
1
+ {"version":3,"file":"ToolCard.js","sources":["../../../../../src/modules/communityTools/ui/ToolCard.tsx"],"sourcesContent":["import type { Claim } from '@igstack/app-catalog-backend-core'\nimport type { CommunityTool } from '@igstack/app-catalog-shared-core'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport {\n Archive,\n ArrowLeft,\n ArrowRight,\n Check,\n Eye,\n Pencil,\n} from 'lucide-react'\nimport { useEffect, useId, useRef, useState } from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport {\n DIALOG_ROUND_BUTTON,\n DetailDialogShell,\n} from '~/modules/appCatalog/ui/catalog/DetailDialogShell'\nimport { MarkdownText } from '~/modules/appCatalog/ui/components/MarkdownText'\nimport { CommentsFeed } from '~/modules/appCatalog/ui/detail/CommentsFeed'\nimport { CommentsHelp } from '~/modules/appCatalog/ui/detail/CommentsHelp'\nimport { CommunityInput } from '~/modules/appCatalog/ui/detail/CommunityInput'\nimport type { CommunityInputHandle } from '~/modules/appCatalog/ui/detail/CommunityInput'\nimport { cn } from '~/lib/utils'\nimport { useAuth } from '~/modules/auth'\nimport { Skeleton } from '~/ui/skeleton'\nimport { CardScreenshot, formatCardDate } from './CardScreenshot'\nimport { CardTitleBlock } from './CardTitleBlock'\nimport { PersonAvatar } from './FeedRow'\nimport { useTimeUp } from '../useTimeUp'\nimport { PinButton } from './PinButton'\nimport { TagChip, tagKey, tagLabel } from './TagRow'\nimport { ArchiveConfirm, GHOST_BUTTON, ToolFormDialog } from './ToolFormDialog'\n\nconst KBD =\n 'rounded-[5px] border border-b-2 border-border bg-card px-1.5 py-px text-[11px] font-semibold text-muted-foreground'\n\nconst SECTION = 'mt-6 border-t border-border pt-[22px]'\n\nconst LABEL =\n 'text-[11.5px] font-bold tracking-wider text-muted-foreground uppercase'\n\nconst NO_CLAIMS: Claim[] = []\n\nconst DAY_MS = 24 * 60 * 60 * 1000\n\n/** How often a card whose first screenshot is on its way asks again. */\nconst CAPTURE_POLL_MS = 5000\n\n/** How long one open card waits for a first screenshot before it stops asking. */\nconst CAPTURE_WAIT_MS = 2 * 60 * 1000\n\nexport interface ToolCardProps {\n slug: string\n /** The list the card was opened from, in its order: ←/→ step through it. */\n siblings: string[]\n onClose: () => void\n onNavigate: (slug: string) => void\n pinned: boolean\n /** Its pin is being saved. */\n pinBusy?: boolean\n onTogglePin: (tool: CommunityTool) => void\n /** Display names of official apps by slug, for app tags. */\n appNames: Record<string, string>\n /** A tag chip on the card: close it and filter the feed to that tag. */\n onChooseTag: (tag: string) => void\n}\n\n/** Typing belongs to the field: the card's keys stay out of it. */\nfunction isTyping(target: HTMLElement): boolean {\n return (\n target.isContentEditable ||\n target.closest('input, textarea, select') !== null\n )\n}\n\n/**\n * A community tool over the Community page, in the official card's shell: no\n * tabs, one scroll. Title and screenshot to recognise it, how to use it, who\n * to ask, and the comments.\n */\nexport function ToolCard({\n slug,\n siblings,\n onClose,\n onNavigate,\n pinned,\n pinBusy,\n onTogglePin,\n appNames,\n onChooseTag,\n}: ToolCardProps) {\n const { user } = useAuth()\n const isAdmin = user?.isAdmin === true\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n // The feed already holds every tool, so the card paints at once from it\n // and the tool's own query only refreshes it.\n const list = useQuery(trpc.communityTools.list.queryOptions())\n const fromList = list.data?.find((t) => t.slug === slug)\n // Whatever the server says, an open card stops waiting after a while: a\n // capture that never reports back must not keep it asking forever.\n const gaveUpOnCapture = useTimeUp(slug, CAPTURE_WAIT_MS)\n const get = useQuery({\n ...trpc.communityTools.get.queryOptions({ slug }),\n placeholderData: fromList,\n // Until the first visit lands, keep asking, so the screenshot replaces\n // the placeholder without a reload.\n refetchInterval: (query) =>\n query.state.data?.capturePending && !gaveUpOnCapture\n ? CAPTURE_POLL_MS\n : false,\n })\n const tool = get.data\n\n // One view per open. The ref keeps StrictMode's second effect from counting\n // twice; stepping to another tool is another open.\n const [counts, setCounts] = useState<{\n slug: string\n views30d: number\n viewsAll: number\n } | null>(null)\n // The hook's own onSuccess, not one passed to `mutate`: StrictMode's\n // remount detaches the observer a `mutate` callback hangs on, so that one\n // never fires and the count stayed one short.\n const { mutate: recordView } = useMutation(\n trpc.communityTools.recordView.mutationOptions({\n onSuccess: (c, { slug: viewed }) => {\n setCounts({ slug: viewed, ...c })\n },\n }),\n )\n const recordedForRef = useRef<string | null>(null)\n useEffect(() => {\n if (recordedForRef.current === slug) return\n recordedForRef.current = slug\n recordView({ slug })\n }, [slug, recordView])\n const views = counts?.slug === slug ? counts : tool\n\n const [editing, setEditing] = useState(false)\n const [archiving, setArchiving] = useState(false)\n const refreshTools = () =>\n queryClient.invalidateQueries({\n queryKey: trpc.communityTools.pathKey(),\n })\n const approve = useMutation(\n trpc.communityTools.approve.mutationOptions({ onSuccess: refreshTools }),\n )\n const archive = useMutation(\n trpc.communityTools.archive.mutationOptions({\n onSuccess: () => {\n setArchiving(false)\n return refreshTools()\n },\n }),\n )\n const adminError = approve.error ?? archive.error\n\n const commentsRef = useRef<CommunityInputHandle>(null)\n const commentsHeadingId = useId()\n const commentTarget = { communityToolSlug: slug }\n const comments = useQuery(trpc.feedback.list.queryOptions(commentTarget))\n const commentCount = comments.data?.items.length ?? 0\n\n const index = siblings.indexOf(slug)\n const prev = index > 0 ? siblings[index - 1] : undefined\n const next = index >= 0 ? siblings[index + 1] : undefined\n const nameOf = (s: string) => list.data?.find((t) => t.slug === s)?.name ?? s\n\n const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n // React bubbles through portals: keys in the screenshot viewer are not ours.\n const target = e.target as HTMLElement\n if (!e.currentTarget.contains(target)) return\n if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return\n if (isTyping(target)) return\n if (e.key === 'ArrowLeft' && prev) {\n e.preventDefault()\n onNavigate(prev)\n } else if (e.key === 'ArrowRight' && next) {\n e.preventDefault()\n onNavigate(next)\n } else if (e.key === 'Enter' && tool) {\n // A link or button answers Enter itself; the title block is the link\n // that opens the site.\n if (target.closest('a, button')) return\n e.preventDefault()\n window.open(tool.url, '_blank', 'noopener,noreferrer')\n }\n }\n\n const controls = index >= 0 && siblings.length > 1 && (\n <>\n <span className=\"flex items-center gap-1 text-[13px] font-semibold text-muted-foreground\">\n <button\n type=\"button\"\n aria-label={prev ? `Previous tool: ${nameOf(prev)}` : 'Previous tool'}\n disabled={!prev}\n onClick={() => prev && onNavigate(prev)}\n className={cn(DIALOG_ROUND_BUTTON, 'disabled:opacity-40')}\n >\n <ArrowLeft className=\"size-4\" />\n </button>\n <span data-testid=\"card-position\">\n {index + 1} of {siblings.length}\n </span>\n <button\n type=\"button\"\n aria-label={next ? `Next tool: ${nameOf(next)}` : 'Next tool'}\n disabled={!next}\n onClick={() => next && onNavigate(next)}\n className={cn(DIALOG_ROUND_BUTTON, 'disabled:opacity-40')}\n >\n <ArrowRight className=\"size-4\" />\n </button>\n </span>\n <i aria-hidden=\"true\" className=\"mx-1 h-5 w-px bg-border\" />\n </>\n )\n\n return (\n <DetailDialogShell\n label={`${tool?.name ?? 'Community tool'} details`}\n shownKey={slug}\n maxWidthClass=\"max-w-[min(920px,94vw)]\"\n focusSelector='[data-testid=\"card-title-block\"]'\n controls={controls}\n testId=\"tool-card\"\n onClose={onClose}\n onKeyDown={onKeyDown}\n >\n {tool ? (\n <>\n <div className=\"border-b border-border pb-[22px]\">\n <div className=\"flex items-end justify-between gap-4 pr-44\">\n <CardTitleBlock tool={tool} />\n <div className=\"-mr-40 flex shrink-0 items-center gap-2\">\n {tool.canEdit && (\n <button\n type=\"button\"\n data-testid=\"card-edit-button\"\n onClick={() => setEditing(true)}\n className={cn(GHOST_BUTTON, 'h-10 px-3.5 text-[13.5px]')}\n >\n <Pencil aria-hidden=\"true\" className=\"size-4\" />\n Edit\n </button>\n )}\n <PinButton\n labelled\n pinned={pinned}\n busy={pinBusy}\n name={tool.name}\n onToggle={() => onTogglePin(tool)}\n />\n </div>\n </div>\n <div\n className={cn(\n tool.lifecycle.state === 'archived' && 'opacity-60',\n )}\n >\n {tool.summary && (\n <p className=\"mt-3.5 text-[15px] text-foreground/80\">\n {tool.summary}\n </p>\n )}\n {tool.tags.length > 0 && (\n <div\n data-testid=\"card-tags\"\n className=\"mt-2.5 flex flex-wrap gap-1.5\"\n >\n {tool.tags.map((tag) => (\n <TagChip\n key={tagKey(tag)}\n label={tagLabel(tag, appNames)}\n onClick={() => onChooseTag(tagKey(tag))}\n />\n ))}\n </div>\n )}\n {views && (\n <p\n data-testid=\"card-views\"\n className=\"mt-2.5 flex gap-[18px] text-[13px] font-semibold text-muted-foreground\"\n >\n <span className=\"inline-flex items-center gap-1.5\">\n <Eye aria-hidden=\"true\" className=\"size-[15px]\" />\n <b className=\"text-foreground\">\n {views.views30d.toLocaleString('en-US')}\n </b>{' '}\n opens this month\n </span>\n <span>\n <b className=\"text-foreground\">\n {views.viewsAll.toLocaleString('en-US')}\n </b>{' '}\n all time\n </span>\n </p>\n )}\n {/* Who to ask, up here with the facts: below the screenshot\n it fell under the fold. */}\n <p\n data-testid=\"card-people\"\n className=\"mt-2.5 flex flex-wrap items-center gap-x-[18px] gap-y-1.5 text-[13px] font-semibold text-muted-foreground\"\n >\n {tool.owners.length > 0 && (\n <span className=\"inline-flex flex-wrap items-center gap-x-3 gap-y-1.5\">\n <span className={LABEL}>Owners</span>\n {tool.owners.map((owner) => (\n <span\n key={owner.slug ?? `named:${owner.name}`}\n className=\"inline-flex items-center gap-1.5 text-foreground\"\n >\n <PersonAvatar name={owner.name} />\n <span data-testid=\"card-owner\">{owner.name}</span>\n </span>\n ))}\n </span>\n )}\n <span className=\"inline-flex items-center gap-1.5\">\n <span className={LABEL}>Added by</span>\n <span className=\"text-foreground\">{tool.addedBy.name}</span>\n <span className=\"font-normal\">\n {formatCardDate(tool.createdAt)}\n </span>\n </span>\n </p>\n </div>\n <LifecycleNote\n tool={tool}\n onTellOwner={() => commentsRef.current?.focus()}\n />\n {isAdmin && (\n <div\n data-testid=\"card-admin\"\n className=\"mt-3.5 flex flex-col gap-2.5 rounded-[10px] border border-dashed border-border px-3 py-2.5\"\n >\n {archiving ? (\n <ArchiveConfirm\n busy={archive.isPending}\n onCancel={() => setArchiving(false)}\n onArchive={(reason) => archive.mutate({ slug, reason })}\n />\n ) : (\n <div className=\"flex flex-wrap items-center gap-2.5\">\n <span className=\"mr-auto text-[12.5px] font-bold tracking-wider text-muted-foreground uppercase\">\n Admin\n </span>\n {!tool.reviewed && (\n <button\n type=\"button\"\n disabled={approve.isPending}\n aria-busy={approve.isPending || undefined}\n onClick={() => approve.mutate({ slug })}\n className={GHOST_BUTTON}\n >\n <Check aria-hidden=\"true\" className=\"size-4\" />\n {approve.isPending ? 'Approving…' : 'Approve'}\n </button>\n )}\n {tool.lifecycle.state !== 'archived' && (\n <button\n type=\"button\"\n onClick={() => setArchiving(true)}\n className={GHOST_BUTTON}\n >\n <Archive aria-hidden=\"true\" className=\"size-4\" />\n Archive\n </button>\n )}\n </div>\n )}\n {adminError && (\n <p\n role=\"alert\"\n className=\"text-[13px] font-semibold text-destructive\"\n >\n {adminError.message}\n </p>\n )}\n </div>\n )}\n </div>\n\n <CardScreenshot\n tool={tool}\n capturing={tool.capturePending && !gaveUpOnCapture}\n />\n\n {tool.descriptionMd.trim() && (\n <section\n data-testid=\"card-how-to-use\"\n className={cn(SECTION, 'text-[14.5px]')}\n >\n <h3 className=\"mb-2 font-serif text-[17px] font-semibold\">\n How to use\n </h3>\n <div className=\"prose prose-sm dark:prose-invert max-w-[68ch]\">\n <MarkdownText>{tool.descriptionMd}</MarkdownText>\n </div>\n </section>\n )}\n\n {/* The card's own order: what was said first, the input last. The\n official card's CommunitySection puts the input on top. */}\n <section data-testid=\"card-comments\" className={SECTION}>\n <div className=\"comments-head\">\n <h2 id={commentsHeadingId} className=\"behind-group__title\">\n Comments\n </h2>\n {commentCount > 0 && (\n <span\n data-testid=\"card-comment-count\"\n className=\"rounded-full bg-muted px-2 text-xs leading-5 font-bold text-muted-foreground\"\n >\n {commentCount}\n </span>\n )}\n <CommentsHelp appName={tool.name} />\n </div>\n <CommentsFeed\n target={commentTarget}\n claims={NO_CLAIMS}\n labelledBy={commentsHeadingId}\n />\n <div className=\"mt-3.5\">\n <CommunityInput\n ref={commentsRef}\n target={commentTarget}\n appName={tool.name}\n />\n </div>\n </section>\n\n <p className=\"mt-[22px] flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12.5px] font-semibold text-muted-foreground\">\n <span className=\"inline-flex items-center gap-1.5\">\n <kbd className={KBD}>Esc</kbd> close\n </span>\n <span className=\"inline-flex items-center gap-1.5\">\n <kbd className={KBD}>←</kbd>\n <kbd className={KBD}>→</kbd> previous / next tool\n </span>\n <span className=\"inline-flex items-center gap-1.5\">\n <kbd className={KBD}>Enter</kbd> open site\n <span className=\"font-normal\">\n (in the comment box, Enter sends)\n </span>\n </span>\n </p>\n {editing && (\n <ToolFormDialog\n mode=\"edit\"\n initial={tool}\n onClose={() => setEditing(false)}\n onDone={() => setEditing(false)}\n />\n )}\n </>\n ) : get.isError ? (\n <p className=\"py-10 text-center text-muted-foreground\">\n This tool is not listed any more.\n </p>\n ) : (\n <div aria-busy=\"true\" className=\"flex flex-col gap-4\">\n <Skeleton className=\"h-[72px] w-2/3 rounded-[14px]\" />\n <Skeleton className=\"h-4 w-1/2\" />\n <Skeleton className=\"aspect-[16/10] w-full rounded-[14px]\" />\n </div>\n )}\n </DetailDialogShell>\n )\n}\n\n/** Why a tool that is not active is shown the way it is. */\nfunction LifecycleNote({\n tool,\n onTellOwner,\n}: {\n tool: CommunityTool\n onTellOwner: () => void\n}) {\n const { lifecycle } = tool\n const [now] = useState(Date.now)\n if (lifecycle.state === 'not-working') {\n const since = new Date(now - lifecycle.days * DAY_MS).toISOString()\n return (\n <p\n data-testid=\"lifecycle-note\"\n className=\"mt-3 rounded-[10px] bg-amber-50 px-3 py-[9px] text-[13.5px] text-amber-900 dark:bg-amber-950 dark:text-amber-200\"\n >\n The daily check has not reached this site since{' '}\n {formatCardDate(since).replace(/ \\d{4}$/, '')}. It may be down or moved.{' '}\n <button\n type=\"button\"\n onClick={onTellOwner}\n className=\"font-bold underline underline-offset-2\"\n >\n Tell the owner\n </button>\n </p>\n )\n }\n if (lifecycle.state === 'archived') {\n const on = formatCardDate(lifecycle.at)\n return (\n <p\n data-testid=\"lifecycle-note\"\n className=\"mt-3 rounded-[10px] bg-muted px-3 py-[9px] text-[13.5px] text-muted-foreground\"\n >\n {lifecycle.byName\n ? `Archived by ${lifecycle.byName} on ${on}${lifecycle.reason ? `: ${lifecycle.reason}` : ''}.`\n : `Archived automatically on ${on} after 30 days not working.`}{' '}\n Kept here so old links still land somewhere.\n </p>\n )\n }\n return null\n}\n"],"names":["_a","_b"],"mappings":"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"}
@@ -10,6 +10,7 @@ import { useAppCatalogContext } from "../../appCatalog/context/AppCatalogContext
10
10
  import { MarkdownText } from "../../appCatalog/ui/components/MarkdownText.js";
11
11
  import { useAuth } from "../../auth/AuthContext.js";
12
12
  import "../../../ui/button.js";
13
+ import "../../auth/AuthModalContext.js";
13
14
  import { useDismissable } from "../../dismiss/DismissStack.js";
14
15
  import { Dialog, DialogContent, DialogTitle } from "../../../ui/dialog.js";
15
16
  import { formatCardDate } from "./CardScreenshot.js";
@@ -1 +1 @@
1
- {"version":3,"file":"ToolFormDialog.js","sources":["../../../../../src/modules/communityTools/ui/ToolFormDialog.tsx"],"sourcesContent":["import type { ToolPreview } from '@igstack/app-catalog-backend-core'\nimport type {\n CommunityTag,\n CommunityTool,\n PersonRef,\n} from '@igstack/app-catalog-shared-core'\nimport { TAG_VALUE_MAX, hostOf } from '@igstack/app-catalog-shared-core'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { Link } from '@tanstack/react-router'\nimport {\n AlertCircle,\n AlertTriangle,\n Archive,\n Camera,\n CheckCircle2,\n ImageOff,\n Loader2,\n X,\n} from 'lucide-react'\nimport { useEffect, useId, useRef, useState } from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { cn } from '~/lib/utils'\nimport { useAppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'\nimport { MarkdownText } from '~/modules/appCatalog/ui/components/MarkdownText'\nimport { useAuth } from '~/modules/auth'\nimport { useDismissable } from '~/modules/dismiss/DismissStack'\nimport { Dialog, DialogContent, DialogTitle } from '~/ui/dialog'\nimport { formatCardDate } from './CardScreenshot'\nimport { screenshotUrl } from './FeedRow'\nimport { FIELD_BAD, FIELD_BOX, OwnerPicker, personName } from './OwnerPicker'\nimport { TagPicker } from './TagPicker'\n\n/** How long the address must sit still before the site is read. */\nconst PREVIEW_DELAY_MS = 400\n\nconst NAME_MAX = 80\nconst SUMMARY_MAX = 160\nconst DESCRIPTION_MAX = 5000\n\nexport const GHOST_BUTTON =\n 'inline-flex h-[34px] items-center gap-[7px] rounded-[10px] border border-border bg-card px-[11px] text-[13px] font-bold whitespace-nowrap hover:border-primary/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:opacity-50'\n\nexport const PRIMARY_BUTTON =\n 'inline-flex h-[34px] items-center gap-2 rounded-[10px] bg-primary px-4 text-[13.5px] font-extrabold whitespace-nowrap text-primary-foreground shadow-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:cursor-not-allowed disabled:opacity-50'\n\nconst LABEL = 'text-[13px] font-bold'\nconst HINT = 'text-[12.5px] text-muted-foreground'\n\ntype Field = 'name' | 'owners' | 'tags'\n\nexport interface ToolFormDialogProps {\n mode: 'add' | 'edit'\n /** The tool being edited. */\n initial?: CommunityTool\n /** What the visitor searched for: a first guess at the name. */\n prefillName?: string\n /** Published, saved or archived. */\n onDone: (slug: string) => void\n onClose: () => void\n /** The address is already listed, and they chose to look at that card. */\n onOpenCard?: (slug: string) => void\n}\n\n/** A field's error, next to it; the form points at the first one on submit. */\nfunction FieldError({ id, children }: { id: string; children: string }) {\n return (\n <p\n id={id}\n role=\"alert\"\n className=\"flex items-center gap-1.5 text-[12.5px] font-bold text-destructive\"\n >\n <AlertCircle aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n {children}\n </p>\n )\n}\n\n/**\n * Add a tool, or edit one: the same form. The address comes first; once it\n * sits still the backend reads the page, the title fills the name and a\n * screenshot preview shows. A host already listed is caught before anything\n * else is asked. Publish makes the tool live at once and puts it in the\n * review list.\n */\nexport function ToolFormDialog({\n mode,\n initial,\n prefillName,\n onDone,\n onClose,\n onOpenCard,\n}: ToolFormDialogProps) {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n const { user } = useAuth()\n const { persons, resources } = useAppCatalogContext()\n const list = useQuery(trpc.communityTools.list.queryOptions())\n const formId = useId()\n const ids = {\n url: `${formId}-url`,\n name: `${formId}-name`,\n summary: `${formId}-summary`,\n owners: `${formId}-owners`,\n tags: `${formId}-tags`,\n description: `${formId}-description`,\n }\n const errorId = (field: string) => `${field}-error`\n\n const [url, setUrl] = useState(initial?.url ?? '')\n const [name, setName] = useState(initial?.name ?? prefillName ?? '')\n // The page title fills the name only until the visitor writes their own.\n const [nameTouched, setNameTouched] = useState(mode === 'edit')\n // The same, for the debounced read below, without restarting its timer.\n const nameTouchedRef = useRef(nameTouched)\n const [summary, setSummary] = useState(initial?.summary ?? '')\n const [descriptionMd, setDescriptionMd] = useState(\n initial?.descriptionMd ?? '',\n )\n const [owners, setOwners] = useState<PersonRef[]>(() => {\n if (initial) return initial.owners\n // The person adding it is the first owner, when the directory knows them.\n const email = user?.email?.toLowerCase()\n const me = email\n ? persons.find(\n (p) =>\n p.email?.toLowerCase() === email || p.slug.toLowerCase() === email,\n )\n : undefined\n return me ? [{ slug: me.slug, name: personName(me) }] : []\n })\n const [tags, setTags] = useState<CommunityTag[]>(initial?.tags ?? [])\n const [tab, setTab] = useState<'write' | 'preview'>('write')\n const [errors, setErrors] = useState<Partial<Record<Field, string>>>({})\n const [archiving, setArchiving] = useState(false)\n\n // The address as it was when the visitor stopped typing, and what reading it gave.\n const [checked, setChecked] = useState(initial?.url ?? '')\n const [preview, setPreview] = useState<{\n url: string\n data: ToolPreview | null\n } | null>(null)\n const [addAnywayFor, setAddAnywayFor] = useState<string | null>(null)\n\n const previewMutation = useMutation(\n trpc.communityTools.preview.mutationOptions(),\n )\n const { mutateAsync: readSite } = previewMutation\n useEffect(() => {\n const address = url.trim()\n // Set once the address changes again: a slower answer for this one must\n // not overwrite the newer address's preview or name.\n let stale = false\n const timer = setTimeout(() => {\n setChecked(address)\n if (!needsPreviewFor(address, mode, initial?.url)) return\n readSite({ url: address }).then(\n (data) => {\n if (stale) return\n setPreview({ url: address, data })\n if (data.title && !nameTouchedRef.current) setName(data.title)\n },\n () => {\n if (!stale) setPreview({ url: address, data: null })\n },\n )\n }, PREVIEW_DELAY_MS)\n return () => {\n stale = true\n clearTimeout(timer)\n }\n }, [url, mode, initial?.url, readSite])\n\n const address = url.trim()\n const validAddress = hostOf(address) !== null\n const showBadAddress = checked !== '' && checked === address && !validAddress\n const reading =\n needsPreviewFor(address, mode, initial?.url) &&\n (checked !== address || preview?.url !== address)\n const current = preview?.url === address ? preview.data : null\n const duplicate =\n current?.duplicate && current.duplicate.slug !== initial?.slug\n ? current.duplicate\n : null\n // An official catalog app on the same host outranks a community duplicate.\n const officialApp = current?.officialApp ?? null\n const blockedByDuplicate =\n (officialApp !== null || duplicate !== null) && addAnywayFor !== address\n const showDetails =\n mode === 'edit' ||\n (validAddress && !reading && !blockedByDuplicate) ||\n (!validAddress && name.trim() !== '' && !showBadAddress)\n\n const invalidate = () =>\n queryClient.invalidateQueries({\n queryKey: trpc.communityTools.pathKey(),\n })\n const add = useMutation(\n trpc.communityTools.add.mutationOptions({ onSuccess: invalidate }),\n )\n const edit = useMutation(\n trpc.communityTools.edit.mutationOptions({ onSuccess: invalidate }),\n )\n const archive = useMutation(\n trpc.communityTools.archive.mutationOptions({ onSuccess: invalidate }),\n )\n const save = mode === 'add' ? add : edit\n const busy = save.isPending || archive.isPending\n\n const urlRef = useRef<HTMLInputElement>(null)\n\n const submit = (e: React.FormEvent) => {\n e.preventDefault()\n // Publish stays disabled until the address is one; Enter in a field\n // cannot submit past a disabled button either.\n if (busy || !validAddress) return\n const found: Partial<Record<Field, string>> = {}\n if (!name.trim()) found.name = 'Give it the name colleagues call it.'\n if (owners.length === 0) {\n found.owners = 'Add at least one person to ask about it.'\n }\n setErrors(found)\n const first = (['name', 'owners'] as const).find((f) => found[f])\n if (first) {\n document.getElementById(ids[first])?.focus()\n return\n }\n const input = {\n url: address,\n name: name.trim(),\n summary: summary.trim(),\n descriptionMd,\n ownerSlugs: owners.flatMap((o) => (o.slug ? [o.slug] : [])),\n ownerNames: owners.flatMap((o) => (o.slug ? [] : [o.name])),\n tags,\n }\n if (mode === 'add') {\n add.mutate(input, { onSuccess: (tool) => onDone(tool.slug) })\n } else if (initial) {\n edit.mutate(\n { ...input, slug: initial.slug },\n { onSuccess: (tool) => onDone(tool.slug) },\n )\n }\n }\n\n const contentRef = useRef<HTMLDivElement>(null)\n // On the dismiss stack, so Escape closes this form and not the card under\n // it; while it saves, Escape does nothing at all.\n useDismissable(contentRef, () => {\n if (!busy) onClose()\n })\n\n const officialApps = resources.filter((r) => !r.parentSlug)\n const knownTopics = [\n ...new Set(\n (list.data ?? []).flatMap((t) =>\n t.tags.filter((g) => g.kind === 'topic').map((g) => g.value),\n ),\n ),\n ].sort()\n\n const title = mode === 'add' ? 'Add a tool' : 'Edit tool'\n const serverError = save.error ?? archive.error\n\n return (\n <Dialog\n open\n onOpenChange={(open) => {\n if (!open && !busy) onClose()\n }}\n >\n <DialogContent\n ref={contentRef}\n data-testid={mode === 'add' ? 'add-tool-dialog' : 'edit-tool-dialog'}\n aria-describedby={undefined}\n showCloseButton={false}\n // Top-anchored: the form grows downward as the site is read, so\n // nothing above the caret moves.\n className=\"top-[6vh] max-h-[88vh] translate-y-0 gap-0 overflow-y-auto p-0 sm:max-w-[680px]\"\n onOpenAutoFocus={(e) => {\n e.preventDefault()\n urlRef.current?.focus()\n }}\n // Typing is lost on a stray click; only Cancel, Close or Esc leave.\n onInteractOutside={(e) => e.preventDefault()}\n onEscapeKeyDown={(e) => {\n // An open suggestion list takes Escape for itself.\n const target = e.target as Element | null\n if (target?.closest('[aria-expanded=\"true\"]')) e.preventDefault()\n }}\n >\n <form onSubmit={submit} noValidate aria-busy={busy || undefined}>\n <div className=\"flex items-center justify-between px-5 pt-4\">\n <DialogTitle className=\"font-serif text-[19px] leading-tight font-semibold\">\n {title}\n </DialogTitle>\n <button\n type=\"button\"\n aria-label=\"Close\"\n disabled={busy}\n onClick={onClose}\n className=\"rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50\"\n >\n <X aria-hidden=\"true\" className=\"size-4\" />\n </button>\n </div>\n\n <div className=\"flex flex-col gap-3.5 px-5 pt-4 pb-1.5\">\n <div className=\"flex flex-col gap-1.5\">\n <label htmlFor={ids.url} className={LABEL}>\n Site address\n </label>\n <div className={cn(FIELD_BOX, showBadAddress && FIELD_BAD)}>\n <input\n ref={urlRef}\n id={ids.url}\n type=\"url\"\n inputMode=\"url\"\n autoComplete=\"off\"\n placeholder=\"Paste the link to the tool\"\n value={url}\n disabled={busy}\n aria-invalid={showBadAddress || undefined}\n aria-describedby={\n showBadAddress ? errorId(ids.url) : `${ids.url}-hint`\n }\n onChange={(e) => setUrl(e.target.value)}\n className=\"h-10 min-w-0 flex-1 bg-transparent font-mono text-[13px] outline-none placeholder:font-sans placeholder:text-muted-foreground\"\n />\n {validAddress && reading && (\n <span\n data-testid=\"reading-site\"\n className=\"inline-flex shrink-0 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground\"\n >\n <Loader2\n aria-hidden=\"true\"\n className=\"size-3.5 animate-spin\"\n />\n Reading the site…\n </span>\n )}\n {validAddress &&\n !reading &&\n (current?.title || current?.screenshotDataUrl) && (\n <span className=\"inline-flex shrink-0 items-center gap-1.5 text-[12.5px] font-semibold text-green-700 dark:text-green-400\">\n <CheckCircle2 aria-hidden=\"true\" className=\"size-3.5\" />\n Found\n </span>\n )}\n </div>\n {showBadAddress ? (\n <FieldError id={errorId(ids.url)}>\n That is not a web address. Paste the full link, starting with\n https://\n </FieldError>\n ) : (\n !validAddress &&\n !showDetails &&\n !blockedByDuplicate && (\n <p id={`${ids.url}-hint`} className={HINT}>\n Paste it first. The name and screenshot fill in from the\n site.\n </p>\n )\n )}\n </div>\n\n {validAddress && reading && <ReadingSkeleton />}\n\n {blockedByDuplicate && officialApp && (\n <div\n data-testid=\"official-app-warning\"\n role=\"alert\"\n className=\"flex gap-2.5 rounded-[10px] bg-amber-50 px-3.5 py-3 text-[13.5px] text-amber-900 dark:bg-amber-950 dark:text-amber-200\"\n >\n <AlertTriangle\n aria-hidden=\"true\"\n className=\"mt-px size-[18px] shrink-0\"\n />\n <div>\n <b>This is already in the catalog as {officialApp.name}.</b>\n <div className=\"mt-2.5 flex items-center gap-3.5\">\n <Link\n to=\"/app/$slug\"\n params={{ slug: officialApp.slug }}\n className={GHOST_BUTTON}\n >\n Open {officialApp.name}\n </Link>\n <button\n type=\"button\"\n className=\"font-bold underline underline-offset-2\"\n onClick={() => setAddAnywayFor(address)}\n >\n Add anyway\n </button>\n </div>\n </div>\n </div>\n )}\n\n {blockedByDuplicate && !officialApp && duplicate && (\n <div\n data-testid=\"duplicate-warning\"\n role=\"alert\"\n className=\"flex gap-2.5 rounded-[10px] bg-amber-50 px-3.5 py-3 text-[13.5px] text-amber-900 dark:bg-amber-950 dark:text-amber-200\"\n >\n <AlertTriangle\n aria-hidden=\"true\"\n className=\"mt-px size-[18px] shrink-0\"\n />\n <div>\n <b>This site is already here.</b> “{duplicate.name}” uses the\n same address.\n <div className=\"mt-2.5 flex items-center gap-3.5\">\n <button\n type=\"button\"\n className={GHOST_BUTTON}\n onClick={() => onOpenCard?.(duplicate.slug)}\n >\n Open that card\n </button>\n <button\n type=\"button\"\n className=\"font-bold underline underline-offset-2\"\n onClick={() => setAddAnywayFor(address)}\n >\n Add anyway\n </button>\n </div>\n </div>\n </div>\n )}\n\n {showDetails && (\n <>\n <div className=\"grid grid-cols-[minmax(0,1fr)_190px] items-start gap-4\">\n <div className=\"flex min-w-0 flex-col gap-3.5\">\n <div className=\"flex flex-col gap-1.5\">\n <label htmlFor={ids.name} className={LABEL}>\n Name\n </label>\n <div\n className={cn(\n FIELD_BOX,\n !nameTouched &&\n current?.title &&\n name === current.title &&\n 'bg-amber-50/70 dark:bg-amber-950/40',\n errors.name && FIELD_BAD,\n )}\n >\n <input\n id={ids.name}\n type=\"text\"\n maxLength={NAME_MAX}\n value={name}\n disabled={busy}\n aria-invalid={!!errors.name || undefined}\n aria-describedby={\n errors.name ? errorId(ids.name) : `${ids.name}-hint`\n }\n onChange={(e) => {\n setName(e.target.value)\n setNameTouched(true)\n nameTouchedRef.current = true\n setErrors((prev) => ({ ...prev, name: undefined }))\n }}\n className=\"h-10 min-w-0 flex-1 bg-transparent outline-none\"\n />\n </div>\n {errors.name ? (\n <FieldError id={errorId(ids.name)}>\n {errors.name}\n </FieldError>\n ) : (\n current?.title &&\n !nameTouched && (\n <p id={`${ids.name}-hint`} className={HINT}>\n From the page title. Change it if colleagues call it\n something else.\n </p>\n )\n )}\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <label htmlFor={ids.owners} className={LABEL}>\n Owners\n </label>\n <OwnerPicker\n id={ids.owners}\n persons={persons}\n value={owners}\n disabled={busy}\n invalid={!!errors.owners}\n describedBy={\n errors.owners\n ? errorId(ids.owners)\n : `${ids.owners}-hint`\n }\n onChange={(next) => {\n setOwners(next)\n setErrors((prev) => ({ ...prev, owners: undefined }))\n }}\n />\n {errors.owners ? (\n <FieldError id={errorId(ids.owners)}>\n {errors.owners}\n </FieldError>\n ) : (\n <p id={`${ids.owners}-hint`} className={HINT}>\n Who to ask about it. You are added by default.\n </p>\n )}\n </div>\n </div>\n <ShotPreview\n preview={current}\n initial={address === initial?.url ? initial : undefined}\n validAddress={validAddress}\n />\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <label htmlFor={ids.summary} className={LABEL}>\n Summary{' '}\n <span className=\"font-normal text-muted-foreground\">\n (optional)\n </span>\n </label>\n <div className={FIELD_BOX}>\n <input\n id={ids.summary}\n type=\"text\"\n maxLength={SUMMARY_MAX}\n value={summary}\n disabled={busy}\n aria-describedby={`${ids.summary}-hint`}\n onChange={(e) => setSummary(e.target.value)}\n className=\"h-10 min-w-0 flex-1 bg-transparent outline-none\"\n />\n </div>\n <p id={`${ids.summary}-hint`} className={HINT}>\n One line under the name, in the list and on the card.\n </p>\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <label htmlFor={ids.tags} className={LABEL}>\n Tags{' '}\n <span className=\"font-normal text-muted-foreground\">\n (optional)\n </span>\n </label>\n <TagPicker\n id={ids.tags}\n apps={officialApps}\n knownTopics={knownTopics}\n value={tags}\n disabled={busy}\n describedBy={\n errors.tags ? errorId(ids.tags) : `${ids.tags}-hint`\n }\n onChange={(next) => {\n setTags(next)\n setErrors((prev) => ({ ...prev, tags: undefined }))\n }}\n onTooLong={() =>\n setErrors((prev) => ({\n ...prev,\n tags: `A topic is at most ${TAG_VALUE_MAX} characters.`,\n }))\n }\n />\n {errors.tags ? (\n <FieldError id={errorId(ids.tags)}>\n {errors.tags}\n </FieldError>\n ) : (\n <p id={`${ids.tags}-hint`} className={HINT}>\n The app it is for, or a topic word. Press Enter to add a\n new topic.\n </p>\n )}\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <label htmlFor={ids.description} className={LABEL}>\n How to use\n </label>\n <div className=\"overflow-hidden rounded-[10px] border border-border bg-card\">\n <div\n role=\"tablist\"\n aria-label=\"Write or preview\"\n className=\"flex items-center gap-0.5 border-b border-border bg-muted p-1\"\n >\n {(['write', 'preview'] as const).map((t) => (\n <button\n key={t}\n type=\"button\"\n role=\"tab\"\n aria-selected={tab === t}\n onClick={() => setTab(t)}\n className={cn(\n 'rounded-[7px] px-3 py-1 text-[12.5px] font-bold text-muted-foreground',\n tab === t && 'bg-card text-foreground shadow-sm',\n )}\n >\n {t === 'write' ? 'Write' : 'Preview'}\n </button>\n ))}\n <span className=\"ml-auto pr-2 text-[11.5px] text-muted-foreground\">\n Markdown\n </span>\n </div>\n {/* Kept mounted while previewing, so the label and its draft stay. */}\n <textarea\n id={ids.description}\n rows={6}\n maxLength={DESCRIPTION_MAX}\n value={descriptionMd}\n disabled={busy}\n hidden={tab !== 'write'}\n aria-describedby={`${ids.description}-hint`}\n onChange={(e) => setDescriptionMd(e.target.value)}\n className=\"block w-full resize-y bg-transparent px-3 py-2.5 font-mono text-[12.5px] leading-relaxed outline-none\"\n />\n {tab === 'preview' && (\n <div\n data-testid=\"description-preview\"\n role=\"tabpanel\"\n className=\"prose prose-sm dark:prose-invert min-h-24 max-w-none px-3.5 py-2.5\"\n >\n {descriptionMd.trim() ? (\n <MarkdownText>{descriptionMd}</MarkdownText>\n ) : (\n <p className=\"text-muted-foreground\">\n Nothing written yet.\n </p>\n )}\n </div>\n )}\n </div>\n <p id={`${ids.description}-hint`} className={HINT}>\n Short steps work best. This is the text people read on the\n card.\n </p>\n </div>\n </>\n )}\n </div>\n\n {serverError && (\n <p\n role=\"alert\"\n className=\"mx-5 mt-2 rounded-[10px] bg-destructive/10 px-3 py-2 text-[13px] font-semibold text-destructive\"\n >\n {archive.error ? 'Could not archive it: ' : 'Could not save it: '}\n {serverError.message}\n </p>\n )}\n\n {archiving && initial ? (\n <div className=\"mt-2 border-t border-border px-5 pt-3 pb-4\">\n <ArchiveConfirm\n busy={archive.isPending}\n onCancel={() => setArchiving(false)}\n onArchive={(reason) =>\n archive.mutate(\n { slug: initial.slug, reason },\n { onSuccess: () => onDone(initial.slug) },\n )\n }\n />\n </div>\n ) : (\n <div className=\"mt-2 flex items-center justify-end gap-2.5 border-t border-border px-5 pt-3 pb-4\">\n {/* Already archived: nothing to archive, as on the card. */}\n {mode === 'edit' && initial?.lifecycle.state !== 'archived' && (\n <button\n type=\"button\"\n disabled={busy}\n onClick={() => setArchiving(true)}\n className=\"inline-flex items-center gap-1.5 text-[13px] font-bold text-destructive hover:underline disabled:opacity-50\"\n >\n <Archive aria-hidden=\"true\" className=\"size-3.5\" />\n Archive this tool\n </button>\n )}\n <span\n className={cn(\n 'text-[12.5px] text-muted-foreground',\n mode === 'add' ? 'mr-auto' : 'ml-auto',\n )}\n >\n {mode === 'add'\n ? 'Goes live now and joins the review list.'\n : 'Changes show on the card right away.'}\n </span>\n <button\n type=\"button\"\n disabled={busy}\n onClick={onClose}\n className={GHOST_BUTTON}\n >\n Cancel\n </button>\n <button\n type=\"submit\"\n disabled={!validAddress || reading || busy}\n aria-busy={save.isPending || undefined}\n className={PRIMARY_BUTTON}\n >\n {save.isPending && (\n <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin\" />\n )}\n {mode === 'add'\n ? save.isPending\n ? 'Publishing…'\n : 'Publish'\n : save.isPending\n ? 'Saving…'\n : 'Save changes'}\n </button>\n </div>\n )}\n </form>\n </DialogContent>\n </Dialog>\n )\n}\n\nfunction needsPreviewFor(\n address: string,\n mode: 'add' | 'edit',\n initialUrl: string | undefined,\n): boolean {\n return hostOf(address) !== null && (mode === 'add' || address !== initialUrl)\n}\n\n/** While the site is read: where the name, owners and picture will be. */\nfunction ReadingSkeleton() {\n return (\n <div\n aria-hidden=\"true\"\n className=\"grid grid-cols-[minmax(0,1fr)_190px] items-start gap-4\"\n >\n <div className=\"flex flex-col gap-3.5\">\n {['55%', '40%'].map((w, i) => (\n <div key={w} className=\"flex flex-col gap-1.5\">\n <span className={LABEL}>{i === 0 ? 'Name' : 'Owners'}</span>\n <span className={cn(FIELD_BOX, 'pointer-events-none')}>\n <i\n className=\"block h-3 animate-pulse rounded-md bg-muted\"\n style={{ width: w }}\n />\n </span>\n </div>\n ))}\n </div>\n <div className=\"mt-[22px] flex aspect-[16/11] flex-col items-center justify-center gap-1.5 rounded-[10px] border border-border bg-muted text-xs font-semibold text-muted-foreground\">\n <Camera aria-hidden=\"true\" className=\"size-5 animate-pulse\" />\n Taking a screenshot…\n </div>\n </div>\n )\n}\n\n/** The picture the card will lead with, or why there will be none. */\nfunction ShotPreview({\n preview,\n initial,\n validAddress,\n}: {\n preview: ToolPreview | null\n /** The tool being edited, while its address is unchanged. */\n initial?: CommunityTool\n validAddress: boolean\n}) {\n const box =\n 'relative mt-[22px] flex aspect-[16/11] flex-col items-center justify-center gap-1.5 overflow-hidden rounded-[10px] border border-border bg-muted px-3 text-center text-xs font-semibold text-muted-foreground'\n const caption =\n 'absolute bottom-1.5 left-1.5 inline-flex items-center gap-1 rounded-md bg-white/90 px-[7px] py-0.5 text-[11px] font-bold text-muted-foreground dark:bg-black/70'\n\n if (preview?.screenshotDataUrl) {\n return (\n <div data-testid=\"shot-preview\" className={box}>\n <img\n src={preview.screenshotDataUrl}\n alt=\"Screenshot preview of the site\"\n className=\"absolute inset-0 size-full object-cover object-left-top\"\n />\n <span className={caption}>\n <Camera aria-hidden=\"true\" className=\"size-3\" />\n Captured just now\n </span>\n </div>\n )\n }\n if (initial?.screenshotId) {\n return (\n <div data-testid=\"shot-preview\" className={box}>\n <img\n src={screenshotUrl(initial.screenshotId, 640)}\n alt={`Screenshot of ${initial.name}`}\n className=\"absolute inset-0 size-full object-cover object-left-top\"\n />\n {initial.capturedAt && (\n <span className={caption}>\n <Camera aria-hidden=\"true\" className=\"size-3\" />\n Captured {formatCardDate(initial.capturedAt)}\n </span>\n )}\n </div>\n )\n }\n return (\n <div data-testid=\"shot-preview\" data-state=\"none\" className={box}>\n <ImageOff aria-hidden=\"true\" className=\"size-5\" />\n {preview && !preview.captureAllowed\n ? \"This site's address is outside the hosts we take screenshots of, so it will be listed without a picture.\"\n : validAddress\n ? 'No preview. The site may need a login, or it did not load.'\n : 'The screenshot shows here once the address is in.'}\n </div>\n )\n}\n\n/**\n * Archiving asks why, in one step: the reason is what colleagues read on the\n * card, and what the review list records.\n */\nexport function ArchiveConfirm({\n busy,\n onArchive,\n onCancel,\n}: {\n busy: boolean\n onArchive: (reason: string) => void\n onCancel: () => void\n}) {\n const id = useId()\n const inputRef = useRef<HTMLInputElement>(null)\n const [reason, setReason] = useState('')\n const [missing, setMissing] = useState(false)\n\n useEffect(() => {\n inputRef.current?.focus()\n }, [])\n\n const confirm = () => {\n if (!reason.trim()) {\n setMissing(true)\n inputRef.current?.focus()\n return\n }\n onArchive(reason.trim())\n }\n\n return (\n <div data-testid=\"archive-confirm\" className=\"flex flex-col gap-1.5\">\n <label htmlFor={id} className={LABEL}>\n Why archive it?\n </label>\n <div className=\"flex items-center gap-2.5\">\n <div className={cn(FIELD_BOX, 'flex-1', missing && FIELD_BAD)}>\n <input\n ref={inputRef}\n id={id}\n type=\"text\"\n maxLength={500}\n value={reason}\n disabled={busy}\n aria-invalid={missing || undefined}\n aria-describedby={missing ? `${id}-error` : `${id}-hint`}\n onChange={(e) => {\n setReason(e.target.value)\n setMissing(false)\n }}\n onKeyDown={(e) => {\n if (e.key === 'Enter') {\n e.preventDefault()\n confirm()\n }\n }}\n className=\"h-10 min-w-0 flex-1 bg-transparent outline-none\"\n />\n </div>\n <button\n type=\"button\"\n disabled={busy}\n onClick={onCancel}\n className={GHOST_BUTTON}\n >\n Keep it\n </button>\n <button\n type=\"button\"\n disabled={busy}\n aria-busy={busy || undefined}\n onClick={confirm}\n className={cn(PRIMARY_BUTTON, 'bg-destructive text-white')}\n >\n {busy && (\n <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin\" />\n )}\n {busy ? 'Archiving…' : 'Archive'}\n </button>\n </div>\n {missing ? (\n <FieldError id={`${id}-error`}>\n Say why, so colleagues know what happened to it.\n </FieldError>\n ) : (\n <p id={`${id}-hint`} className={HINT}>\n Colleagues read this on the card. It stays reachable by its link.\n </p>\n )}\n </div>\n )\n}\n"],"names":["_a","address"],"mappings":"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"}
1
+ {"version":3,"file":"ToolFormDialog.js","sources":["../../../../../src/modules/communityTools/ui/ToolFormDialog.tsx"],"sourcesContent":["import type { ToolPreview } from '@igstack/app-catalog-backend-core'\nimport type {\n CommunityTag,\n CommunityTool,\n PersonRef,\n} from '@igstack/app-catalog-shared-core'\nimport { TAG_VALUE_MAX, hostOf } from '@igstack/app-catalog-shared-core'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { Link } from '@tanstack/react-router'\nimport {\n AlertCircle,\n AlertTriangle,\n Archive,\n Camera,\n CheckCircle2,\n ImageOff,\n Loader2,\n X,\n} from 'lucide-react'\nimport { useEffect, useId, useRef, useState } from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { cn } from '~/lib/utils'\nimport { useAppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'\nimport { MarkdownText } from '~/modules/appCatalog/ui/components/MarkdownText'\nimport { useAuth } from '~/modules/auth'\nimport { useDismissable } from '~/modules/dismiss/DismissStack'\nimport { Dialog, DialogContent, DialogTitle } from '~/ui/dialog'\nimport { formatCardDate } from './CardScreenshot'\nimport { screenshotUrl } from './FeedRow'\nimport { FIELD_BAD, FIELD_BOX, OwnerPicker, personName } from './OwnerPicker'\nimport { TagPicker } from './TagPicker'\n\n/** How long the address must sit still before the site is read. */\nconst PREVIEW_DELAY_MS = 400\n\nconst NAME_MAX = 80\nconst SUMMARY_MAX = 160\nconst DESCRIPTION_MAX = 5000\n\nexport const GHOST_BUTTON =\n 'inline-flex h-[34px] items-center gap-[7px] rounded-[10px] border border-border bg-card px-[11px] text-[13px] font-bold whitespace-nowrap hover:border-primary/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:opacity-50'\n\nexport const PRIMARY_BUTTON =\n 'inline-flex h-[34px] items-center gap-2 rounded-[10px] bg-primary px-4 text-[13.5px] font-extrabold whitespace-nowrap text-primary-foreground shadow-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:cursor-not-allowed disabled:opacity-50'\n\nconst LABEL = 'text-[13px] font-bold'\nconst HINT = 'text-[12.5px] text-muted-foreground'\n\ntype Field = 'name' | 'owners' | 'tags'\n\nexport interface ToolFormDialogProps {\n mode: 'add' | 'edit'\n /** The tool being edited. */\n initial?: CommunityTool\n /** What the visitor searched for: a first guess at the name. */\n prefillName?: string\n /** Published, saved or archived. */\n onDone: (slug: string) => void\n onClose: () => void\n /** The address is already listed, and they chose to look at that card. */\n onOpenCard?: (slug: string) => void\n}\n\n/** A field's error, next to it; the form points at the first one on submit. */\nfunction FieldError({ id, children }: { id: string; children: string }) {\n return (\n <p\n id={id}\n role=\"alert\"\n className=\"flex items-center gap-1.5 text-[12.5px] font-bold text-destructive\"\n >\n <AlertCircle aria-hidden=\"true\" className=\"size-3.5 shrink-0\" />\n {children}\n </p>\n )\n}\n\n/**\n * Add a tool, or edit one: the same form. The address comes first; once it\n * sits still the backend reads the page, the title fills the name and a\n * screenshot preview shows. A host already listed is caught before anything\n * else is asked. Publish makes the tool live at once and puts it in the\n * review list.\n */\nexport function ToolFormDialog({\n mode,\n initial,\n prefillName,\n onDone,\n onClose,\n onOpenCard,\n}: ToolFormDialogProps) {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n const { user } = useAuth()\n const { persons, resources } = useAppCatalogContext()\n const list = useQuery(trpc.communityTools.list.queryOptions())\n const formId = useId()\n const ids = {\n url: `${formId}-url`,\n name: `${formId}-name`,\n summary: `${formId}-summary`,\n owners: `${formId}-owners`,\n tags: `${formId}-tags`,\n description: `${formId}-description`,\n }\n const errorId = (field: string) => `${field}-error`\n\n const [url, setUrl] = useState(initial?.url ?? '')\n const [name, setName] = useState(initial?.name ?? prefillName ?? '')\n // The page title fills the name only until the visitor writes their own.\n const [nameTouched, setNameTouched] = useState(mode === 'edit')\n // The same, for the debounced read below, without restarting its timer.\n const nameTouchedRef = useRef(nameTouched)\n const [summary, setSummary] = useState(initial?.summary ?? '')\n const [descriptionMd, setDescriptionMd] = useState(\n initial?.descriptionMd ?? '',\n )\n const [owners, setOwners] = useState<PersonRef[]>(() => {\n if (initial) return initial.owners\n // The person adding it is the first owner, when the directory knows them.\n const email = user?.email?.toLowerCase()\n const me = email\n ? persons.find(\n (p) =>\n p.email?.toLowerCase() === email || p.slug.toLowerCase() === email,\n )\n : undefined\n return me ? [{ slug: me.slug, name: personName(me) }] : []\n })\n const [tags, setTags] = useState<CommunityTag[]>(initial?.tags ?? [])\n const [tab, setTab] = useState<'write' | 'preview'>('write')\n const [errors, setErrors] = useState<Partial<Record<Field, string>>>({})\n const [archiving, setArchiving] = useState(false)\n\n // The address as it was when the visitor stopped typing, and what reading it gave.\n const [checked, setChecked] = useState(initial?.url ?? '')\n const [preview, setPreview] = useState<{\n url: string\n data: ToolPreview | null\n } | null>(null)\n const [addAnywayFor, setAddAnywayFor] = useState<string | null>(null)\n\n const previewMutation = useMutation(\n trpc.communityTools.preview.mutationOptions(),\n )\n const { mutateAsync: readSite } = previewMutation\n useEffect(() => {\n const address = url.trim()\n // Set once the address changes again: a slower answer for this one must\n // not overwrite the newer address's preview or name.\n let stale = false\n const timer = setTimeout(() => {\n setChecked(address)\n if (!needsPreviewFor(address, mode, initial?.url)) return\n readSite({ url: address }).then(\n (data) => {\n if (stale) return\n setPreview({ url: address, data })\n if (data.title && !nameTouchedRef.current) setName(data.title)\n },\n () => {\n if (!stale) setPreview({ url: address, data: null })\n },\n )\n }, PREVIEW_DELAY_MS)\n return () => {\n stale = true\n clearTimeout(timer)\n }\n }, [url, mode, initial?.url, readSite])\n\n const address = url.trim()\n const validAddress = hostOf(address) !== null\n const showBadAddress = checked !== '' && checked === address && !validAddress\n const reading =\n needsPreviewFor(address, mode, initial?.url) &&\n (checked !== address || preview?.url !== address)\n const current = preview?.url === address ? preview.data : null\n const duplicate =\n current?.duplicate && current.duplicate.slug !== initial?.slug\n ? current.duplicate\n : null\n // An official catalog app on the same host outranks a community duplicate.\n const officialApp = current?.officialApp ?? null\n const blockedByDuplicate =\n (officialApp !== null || duplicate !== null) && addAnywayFor !== address\n const showDetails =\n mode === 'edit' ||\n (validAddress && !reading && !blockedByDuplicate) ||\n (!validAddress && name.trim() !== '' && !showBadAddress)\n\n const invalidate = () =>\n queryClient.invalidateQueries({\n queryKey: trpc.communityTools.pathKey(),\n })\n const add = useMutation(\n trpc.communityTools.add.mutationOptions({ onSuccess: invalidate }),\n )\n const edit = useMutation(\n trpc.communityTools.edit.mutationOptions({ onSuccess: invalidate }),\n )\n const archive = useMutation(\n trpc.communityTools.archive.mutationOptions({ onSuccess: invalidate }),\n )\n const save = mode === 'add' ? add : edit\n const busy = save.isPending || archive.isPending\n\n const urlRef = useRef<HTMLInputElement>(null)\n\n const submit = (e: React.FormEvent) => {\n e.preventDefault()\n // Publish stays disabled until the address is one; Enter in a field\n // cannot submit past a disabled button either.\n if (busy || !validAddress) return\n const found: Partial<Record<Field, string>> = {}\n if (!name.trim()) found.name = 'Give it the name colleagues call it.'\n if (owners.length === 0) {\n found.owners = 'Add at least one person to ask about it.'\n }\n setErrors(found)\n const first = (['name', 'owners'] as const).find((f) => found[f])\n if (first) {\n document.getElementById(ids[first])?.focus()\n return\n }\n const input = {\n url: address,\n name: name.trim(),\n summary: summary.trim(),\n descriptionMd,\n ownerSlugs: owners.flatMap((o) => (o.slug ? [o.slug] : [])),\n ownerNames: owners.flatMap((o) => (o.slug ? [] : [o.name])),\n tags,\n }\n if (mode === 'add') {\n add.mutate(input, { onSuccess: (tool) => onDone(tool.slug) })\n } else if (initial) {\n edit.mutate(\n { ...input, slug: initial.slug },\n { onSuccess: (tool) => onDone(tool.slug) },\n )\n }\n }\n\n const contentRef = useRef<HTMLDivElement>(null)\n // On the dismiss stack, so Escape closes this form and not the card under\n // it; while it saves, Escape does nothing at all.\n useDismissable(contentRef, () => {\n if (!busy) onClose()\n })\n\n const officialApps = resources.filter((r) => !r.parentSlug)\n const knownTopics = [\n ...new Set(\n (list.data ?? []).flatMap((t) =>\n t.tags.filter((g) => g.kind === 'topic').map((g) => g.value),\n ),\n ),\n ].sort()\n\n const title = mode === 'add' ? 'Add a tool' : 'Edit tool'\n const serverError = save.error ?? archive.error\n\n return (\n <Dialog\n open\n onOpenChange={(open) => {\n if (!open && !busy) onClose()\n }}\n >\n <DialogContent\n ref={contentRef}\n data-testid={mode === 'add' ? 'add-tool-dialog' : 'edit-tool-dialog'}\n aria-describedby={undefined}\n showCloseButton={false}\n // Top-anchored: the form grows downward as the site is read, so\n // nothing above the caret moves.\n className=\"top-[6vh] max-h-[88vh] translate-y-0 gap-0 overflow-y-auto p-0 sm:max-w-[680px]\"\n onOpenAutoFocus={(e) => {\n e.preventDefault()\n urlRef.current?.focus()\n }}\n // Typing is lost on a stray click; only Cancel, Close or Esc leave.\n onInteractOutside={(e) => e.preventDefault()}\n onEscapeKeyDown={(e) => {\n // An open suggestion list takes Escape for itself.\n const target = e.target as Element | null\n if (target?.closest('[aria-expanded=\"true\"]')) e.preventDefault()\n }}\n >\n <form onSubmit={submit} noValidate aria-busy={busy || undefined}>\n <div className=\"flex items-center justify-between px-5 pt-4\">\n <DialogTitle className=\"font-serif text-[19px] leading-tight font-semibold\">\n {title}\n </DialogTitle>\n <button\n type=\"button\"\n aria-label=\"Close\"\n disabled={busy}\n onClick={onClose}\n className=\"rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-50\"\n >\n <X aria-hidden=\"true\" className=\"size-4\" />\n </button>\n </div>\n\n <div className=\"flex flex-col gap-3.5 px-5 pt-4 pb-1.5\">\n <div className=\"flex flex-col gap-1.5\">\n <label htmlFor={ids.url} className={LABEL}>\n Site address\n </label>\n <div className={cn(FIELD_BOX, showBadAddress && FIELD_BAD)}>\n <input\n ref={urlRef}\n id={ids.url}\n type=\"url\"\n inputMode=\"url\"\n autoComplete=\"off\"\n placeholder=\"Paste the link to the tool\"\n value={url}\n disabled={busy}\n aria-invalid={showBadAddress || undefined}\n aria-describedby={\n showBadAddress ? errorId(ids.url) : `${ids.url}-hint`\n }\n onChange={(e) => setUrl(e.target.value)}\n className=\"h-10 min-w-0 flex-1 bg-transparent font-mono text-[13px] outline-none placeholder:font-sans placeholder:text-muted-foreground\"\n />\n {validAddress && reading && (\n <span\n data-testid=\"reading-site\"\n className=\"inline-flex shrink-0 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground\"\n >\n <Loader2\n aria-hidden=\"true\"\n className=\"size-3.5 animate-spin\"\n />\n Reading the site…\n </span>\n )}\n {validAddress &&\n !reading &&\n (current?.title || current?.screenshotDataUrl) && (\n <span className=\"inline-flex shrink-0 items-center gap-1.5 text-[12.5px] font-semibold text-green-700 dark:text-green-400\">\n <CheckCircle2 aria-hidden=\"true\" className=\"size-3.5\" />\n Found\n </span>\n )}\n </div>\n {showBadAddress ? (\n <FieldError id={errorId(ids.url)}>\n That is not a web address. Paste the full link, starting with\n https://\n </FieldError>\n ) : (\n !validAddress &&\n !showDetails &&\n !blockedByDuplicate && (\n <p id={`${ids.url}-hint`} className={HINT}>\n Paste it first. The name and screenshot fill in from the\n site.\n </p>\n )\n )}\n </div>\n\n {validAddress && reading && <ReadingSkeleton />}\n\n {blockedByDuplicate && officialApp && (\n <div\n data-testid=\"official-app-warning\"\n role=\"alert\"\n className=\"flex gap-2.5 rounded-[10px] bg-amber-50 px-3.5 py-3 text-[13.5px] text-amber-900 dark:bg-amber-950 dark:text-amber-200\"\n >\n <AlertTriangle\n aria-hidden=\"true\"\n className=\"mt-px size-[18px] shrink-0\"\n />\n <div>\n <b>This is already in the catalog as {officialApp.name}.</b>\n <div className=\"mt-2.5 flex items-center gap-3.5\">\n <Link\n to=\"/app/$slug\"\n params={{ slug: officialApp.slug }}\n className={GHOST_BUTTON}\n >\n Open {officialApp.name}\n </Link>\n <button\n type=\"button\"\n className=\"font-bold underline underline-offset-2\"\n onClick={() => setAddAnywayFor(address)}\n >\n Add anyway\n </button>\n </div>\n </div>\n </div>\n )}\n\n {blockedByDuplicate && !officialApp && duplicate && (\n <div\n data-testid=\"duplicate-warning\"\n role=\"alert\"\n className=\"flex gap-2.5 rounded-[10px] bg-amber-50 px-3.5 py-3 text-[13.5px] text-amber-900 dark:bg-amber-950 dark:text-amber-200\"\n >\n <AlertTriangle\n aria-hidden=\"true\"\n className=\"mt-px size-[18px] shrink-0\"\n />\n <div>\n <b>This site is already here.</b> “{duplicate.name}” uses the\n same address.\n <div className=\"mt-2.5 flex items-center gap-3.5\">\n <button\n type=\"button\"\n className={GHOST_BUTTON}\n onClick={() => onOpenCard?.(duplicate.slug)}\n >\n Open that card\n </button>\n <button\n type=\"button\"\n className=\"font-bold underline underline-offset-2\"\n onClick={() => setAddAnywayFor(address)}\n >\n Add anyway\n </button>\n </div>\n </div>\n </div>\n )}\n\n {showDetails && (\n <>\n <div className=\"grid grid-cols-[minmax(0,1fr)_190px] items-start gap-4\">\n <div className=\"flex min-w-0 flex-col gap-3.5\">\n <div className=\"flex flex-col gap-1.5\">\n <label htmlFor={ids.name} className={LABEL}>\n Name\n </label>\n <div\n className={cn(\n FIELD_BOX,\n !nameTouched &&\n current?.title &&\n name === current.title &&\n 'bg-amber-50/70 dark:bg-amber-950/40',\n errors.name && FIELD_BAD,\n )}\n >\n <input\n id={ids.name}\n type=\"text\"\n maxLength={NAME_MAX}\n value={name}\n disabled={busy}\n aria-invalid={!!errors.name || undefined}\n aria-describedby={\n errors.name ? errorId(ids.name) : `${ids.name}-hint`\n }\n onChange={(e) => {\n setName(e.target.value)\n setNameTouched(true)\n nameTouchedRef.current = true\n setErrors((prev) => ({ ...prev, name: undefined }))\n }}\n className=\"h-10 min-w-0 flex-1 bg-transparent outline-none\"\n />\n </div>\n {errors.name ? (\n <FieldError id={errorId(ids.name)}>\n {errors.name}\n </FieldError>\n ) : (\n current?.title &&\n !nameTouched && (\n <p id={`${ids.name}-hint`} className={HINT}>\n From the page title. Change it if colleagues call it\n something else.\n </p>\n )\n )}\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <label htmlFor={ids.owners} className={LABEL}>\n Owners\n </label>\n <OwnerPicker\n id={ids.owners}\n persons={persons}\n value={owners}\n disabled={busy}\n invalid={!!errors.owners}\n describedBy={\n errors.owners\n ? errorId(ids.owners)\n : `${ids.owners}-hint`\n }\n onChange={(next) => {\n setOwners(next)\n setErrors((prev) => ({ ...prev, owners: undefined }))\n }}\n />\n {errors.owners ? (\n <FieldError id={errorId(ids.owners)}>\n {errors.owners}\n </FieldError>\n ) : (\n <p id={`${ids.owners}-hint`} className={HINT}>\n Who to ask about it. You are added by default.\n </p>\n )}\n </div>\n </div>\n <ShotPreview\n preview={current}\n initial={address === initial?.url ? initial : undefined}\n validAddress={validAddress}\n />\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <label htmlFor={ids.summary} className={LABEL}>\n Summary{' '}\n <span className=\"font-normal text-muted-foreground\">\n (optional)\n </span>\n </label>\n <div className={FIELD_BOX}>\n <input\n id={ids.summary}\n type=\"text\"\n maxLength={SUMMARY_MAX}\n value={summary}\n disabled={busy}\n aria-describedby={`${ids.summary}-hint`}\n onChange={(e) => setSummary(e.target.value)}\n className=\"h-10 min-w-0 flex-1 bg-transparent outline-none\"\n />\n </div>\n <p id={`${ids.summary}-hint`} className={HINT}>\n One line under the name, in the list and on the card.\n </p>\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <label htmlFor={ids.tags} className={LABEL}>\n Tags{' '}\n <span className=\"font-normal text-muted-foreground\">\n (optional)\n </span>\n </label>\n <TagPicker\n id={ids.tags}\n apps={officialApps}\n knownTopics={knownTopics}\n value={tags}\n disabled={busy}\n describedBy={\n errors.tags ? errorId(ids.tags) : `${ids.tags}-hint`\n }\n onChange={(next) => {\n setTags(next)\n setErrors((prev) => ({ ...prev, tags: undefined }))\n }}\n onTooLong={() =>\n setErrors((prev) => ({\n ...prev,\n tags: `A topic is at most ${TAG_VALUE_MAX} characters.`,\n }))\n }\n />\n {errors.tags ? (\n <FieldError id={errorId(ids.tags)}>\n {errors.tags}\n </FieldError>\n ) : (\n <p id={`${ids.tags}-hint`} className={HINT}>\n The app it is for, or a topic word. Press Enter to add a\n new topic.\n </p>\n )}\n </div>\n\n <div className=\"flex flex-col gap-1.5\">\n <label htmlFor={ids.description} className={LABEL}>\n How to use\n </label>\n <div className=\"overflow-hidden rounded-[10px] border border-border bg-card\">\n <div\n role=\"tablist\"\n aria-label=\"Write or preview\"\n className=\"flex items-center gap-0.5 border-b border-border bg-muted p-1\"\n >\n {(['write', 'preview'] as const).map((t) => (\n <button\n key={t}\n type=\"button\"\n role=\"tab\"\n aria-selected={tab === t}\n onClick={() => setTab(t)}\n className={cn(\n 'rounded-[7px] px-3 py-1 text-[12.5px] font-bold text-muted-foreground',\n tab === t && 'bg-card text-foreground shadow-sm',\n )}\n >\n {t === 'write' ? 'Write' : 'Preview'}\n </button>\n ))}\n <span className=\"ml-auto pr-2 text-[11.5px] text-muted-foreground\">\n Markdown\n </span>\n </div>\n {/* Kept mounted while previewing, so the label and its draft stay. */}\n <textarea\n id={ids.description}\n rows={6}\n maxLength={DESCRIPTION_MAX}\n value={descriptionMd}\n disabled={busy}\n hidden={tab !== 'write'}\n aria-describedby={`${ids.description}-hint`}\n onChange={(e) => setDescriptionMd(e.target.value)}\n className=\"block w-full resize-y bg-transparent px-3 py-2.5 font-mono text-[12.5px] leading-relaxed outline-none\"\n />\n {tab === 'preview' && (\n <div\n data-testid=\"description-preview\"\n role=\"tabpanel\"\n className=\"prose prose-sm dark:prose-invert min-h-24 max-w-none px-3.5 py-2.5\"\n >\n {descriptionMd.trim() ? (\n <MarkdownText>{descriptionMd}</MarkdownText>\n ) : (\n <p className=\"text-muted-foreground\">\n Nothing written yet.\n </p>\n )}\n </div>\n )}\n </div>\n <p id={`${ids.description}-hint`} className={HINT}>\n Short steps work best. This is the text people read on the\n card.\n </p>\n </div>\n </>\n )}\n </div>\n\n {serverError && (\n <p\n role=\"alert\"\n className=\"mx-5 mt-2 rounded-[10px] bg-destructive/10 px-3 py-2 text-[13px] font-semibold text-destructive\"\n >\n {archive.error ? 'Could not archive it: ' : 'Could not save it: '}\n {serverError.message}\n </p>\n )}\n\n {archiving && initial ? (\n <div className=\"mt-2 border-t border-border px-5 pt-3 pb-4\">\n <ArchiveConfirm\n busy={archive.isPending}\n onCancel={() => setArchiving(false)}\n onArchive={(reason) =>\n archive.mutate(\n { slug: initial.slug, reason },\n { onSuccess: () => onDone(initial.slug) },\n )\n }\n />\n </div>\n ) : (\n <div className=\"mt-2 flex items-center justify-end gap-2.5 border-t border-border px-5 pt-3 pb-4\">\n {/* Already archived: nothing to archive, as on the card. */}\n {mode === 'edit' && initial?.lifecycle.state !== 'archived' && (\n <button\n type=\"button\"\n disabled={busy}\n onClick={() => setArchiving(true)}\n className=\"inline-flex items-center gap-1.5 text-[13px] font-bold text-destructive hover:underline disabled:opacity-50\"\n >\n <Archive aria-hidden=\"true\" className=\"size-3.5\" />\n Archive this tool\n </button>\n )}\n <span\n className={cn(\n 'text-[12.5px] text-muted-foreground',\n mode === 'add' ? 'mr-auto' : 'ml-auto',\n )}\n >\n {mode === 'add'\n ? 'Goes live now and joins the review list.'\n : 'Changes show on the card right away.'}\n </span>\n <button\n type=\"button\"\n disabled={busy}\n onClick={onClose}\n className={GHOST_BUTTON}\n >\n Cancel\n </button>\n <button\n type=\"submit\"\n disabled={!validAddress || reading || busy}\n aria-busy={save.isPending || undefined}\n className={PRIMARY_BUTTON}\n >\n {save.isPending && (\n <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin\" />\n )}\n {mode === 'add'\n ? save.isPending\n ? 'Publishing…'\n : 'Publish'\n : save.isPending\n ? 'Saving…'\n : 'Save changes'}\n </button>\n </div>\n )}\n </form>\n </DialogContent>\n </Dialog>\n )\n}\n\nfunction needsPreviewFor(\n address: string,\n mode: 'add' | 'edit',\n initialUrl: string | undefined,\n): boolean {\n return hostOf(address) !== null && (mode === 'add' || address !== initialUrl)\n}\n\n/** While the site is read: where the name, owners and picture will be. */\nfunction ReadingSkeleton() {\n return (\n <div\n aria-hidden=\"true\"\n className=\"grid grid-cols-[minmax(0,1fr)_190px] items-start gap-4\"\n >\n <div className=\"flex flex-col gap-3.5\">\n {['55%', '40%'].map((w, i) => (\n <div key={w} className=\"flex flex-col gap-1.5\">\n <span className={LABEL}>{i === 0 ? 'Name' : 'Owners'}</span>\n <span className={cn(FIELD_BOX, 'pointer-events-none')}>\n <i\n className=\"block h-3 animate-pulse rounded-md bg-muted\"\n style={{ width: w }}\n />\n </span>\n </div>\n ))}\n </div>\n <div className=\"mt-[22px] flex aspect-[16/11] flex-col items-center justify-center gap-1.5 rounded-[10px] border border-border bg-muted text-xs font-semibold text-muted-foreground\">\n <Camera aria-hidden=\"true\" className=\"size-5 animate-pulse\" />\n Taking a screenshot…\n </div>\n </div>\n )\n}\n\n/** The picture the card will lead with, or why there will be none. */\nfunction ShotPreview({\n preview,\n initial,\n validAddress,\n}: {\n preview: ToolPreview | null\n /** The tool being edited, while its address is unchanged. */\n initial?: CommunityTool\n validAddress: boolean\n}) {\n const box =\n 'relative mt-[22px] flex aspect-[16/11] flex-col items-center justify-center gap-1.5 overflow-hidden rounded-[10px] border border-border bg-muted px-3 text-center text-xs font-semibold text-muted-foreground'\n const caption =\n 'absolute bottom-1.5 left-1.5 inline-flex items-center gap-1 rounded-md bg-white/90 px-[7px] py-0.5 text-[11px] font-bold text-muted-foreground dark:bg-black/70'\n\n if (preview?.screenshotDataUrl) {\n return (\n <div data-testid=\"shot-preview\" className={box}>\n <img\n src={preview.screenshotDataUrl}\n alt=\"Screenshot preview of the site\"\n className=\"absolute inset-0 size-full object-cover object-left-top\"\n />\n <span className={caption}>\n <Camera aria-hidden=\"true\" className=\"size-3\" />\n Captured just now\n </span>\n </div>\n )\n }\n if (initial?.screenshotId) {\n return (\n <div data-testid=\"shot-preview\" className={box}>\n <img\n src={screenshotUrl(initial.screenshotId, 640)}\n alt={`Screenshot of ${initial.name}`}\n className=\"absolute inset-0 size-full object-cover object-left-top\"\n />\n {initial.capturedAt && (\n <span className={caption}>\n <Camera aria-hidden=\"true\" className=\"size-3\" />\n Captured {formatCardDate(initial.capturedAt)}\n </span>\n )}\n </div>\n )\n }\n return (\n <div data-testid=\"shot-preview\" data-state=\"none\" className={box}>\n <ImageOff aria-hidden=\"true\" className=\"size-5\" />\n {preview && !preview.captureAllowed\n ? \"This site's address is outside the hosts we take screenshots of, so it will be listed without a picture.\"\n : validAddress\n ? 'No preview. The site may need a login, or it did not load.'\n : 'The screenshot shows here once the address is in.'}\n </div>\n )\n}\n\n/**\n * Archiving asks why, in one step: the reason is what colleagues read on the\n * card, and what the review list records.\n */\nexport function ArchiveConfirm({\n busy,\n onArchive,\n onCancel,\n}: {\n busy: boolean\n onArchive: (reason: string) => void\n onCancel: () => void\n}) {\n const id = useId()\n const inputRef = useRef<HTMLInputElement>(null)\n const [reason, setReason] = useState('')\n const [missing, setMissing] = useState(false)\n\n useEffect(() => {\n inputRef.current?.focus()\n }, [])\n\n const confirm = () => {\n if (!reason.trim()) {\n setMissing(true)\n inputRef.current?.focus()\n return\n }\n onArchive(reason.trim())\n }\n\n return (\n <div data-testid=\"archive-confirm\" className=\"flex flex-col gap-1.5\">\n <label htmlFor={id} className={LABEL}>\n Why archive it?\n </label>\n <div className=\"flex items-center gap-2.5\">\n <div className={cn(FIELD_BOX, 'flex-1', missing && FIELD_BAD)}>\n <input\n ref={inputRef}\n id={id}\n type=\"text\"\n maxLength={500}\n value={reason}\n disabled={busy}\n aria-invalid={missing || undefined}\n aria-describedby={missing ? `${id}-error` : `${id}-hint`}\n onChange={(e) => {\n setReason(e.target.value)\n setMissing(false)\n }}\n onKeyDown={(e) => {\n if (e.key === 'Enter') {\n e.preventDefault()\n confirm()\n }\n }}\n className=\"h-10 min-w-0 flex-1 bg-transparent outline-none\"\n />\n </div>\n <button\n type=\"button\"\n disabled={busy}\n onClick={onCancel}\n className={GHOST_BUTTON}\n >\n Keep it\n </button>\n <button\n type=\"button\"\n disabled={busy}\n aria-busy={busy || undefined}\n onClick={confirm}\n className={cn(PRIMARY_BUTTON, 'bg-destructive text-white')}\n >\n {busy && (\n <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin\" />\n )}\n {busy ? 'Archiving…' : 'Archive'}\n </button>\n </div>\n {missing ? (\n <FieldError id={`${id}-error`}>\n Say why, so colleagues know what happened to it.\n </FieldError>\n ) : (\n <p id={`${id}-hint`} className={HINT}>\n Colleagues read this on the card. It stays reachable by its link.\n </p>\n )}\n </div>\n )\n}\n"],"names":["_a","address"],"mappings":"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"}
@@ -5,6 +5,7 @@ import { CommunityTool } from '@igstack/app-catalog-shared-core';
5
5
  */
6
6
  export declare function useCommunityTools(): {
7
7
  list: import('@tanstack/react-query').UseQueryResult<{
8
+ comments: number;
8
9
  createdAt: string;
9
10
  slug: string;
10
11
  name: string;
@@ -42,6 +43,7 @@ export declare function useCommunityTools(): {
42
43
  errorShape: import('@trpc/server').TRPCDefaultErrorShape;
43
44
  }>>;
44
45
  pins: import('@tanstack/react-query').UseQueryResult<{
46
+ comments: number;
45
47
  createdAt: string;
46
48
  slug: string;
47
49
  name: string;
@@ -5,6 +5,7 @@ import { useAppCatalogContext } from "../appCatalog/context/AppCatalogContext.js
5
5
  import { useAuth } from "../auth/AuthContext.js";
6
6
  import "react/jsx-runtime";
7
7
  import "../../ui/button.js";
8
+ import "../auth/AuthModalContext.js";
8
9
  import { withLike } from "./likedBy.js";
9
10
  function useCommunityTools() {
10
11
  const trpc = useTRPC();
@@ -1 +1 @@
1
- {"version":3,"file":"useCommunityTools.js","sources":["../../../../src/modules/communityTools/useCommunityTools.ts"],"sourcesContent":["import type { CommunityTool } from '@igstack/app-catalog-shared-core'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { useAppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'\nimport { useAuth } from '~/modules/auth'\nimport { withLike } from './likedBy'\n\n/**\n * Everything the community page reads, and the pin writes. Pins\n * belong to the signed-in user, so they are not asked for until there is one.\n */\nexport function useCommunityTools() {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n const { isAuthenticated, isLoading: isAuthLoading } = useAuth()\n const { resources } = useAppCatalogContext()\n\n const list = useQuery(trpc.communityTools.list.queryOptions())\n const pins = useQuery(\n trpc.communityTools.pins.queryOptions(undefined, {\n enabled: isAuthenticated,\n }),\n )\n\n /** Display names of official apps by slug, for app tags. */\n const appNames = useMemo(\n () => Object.fromEntries(resources.map((r) => [r.slug, r.displayName])),\n [resources],\n )\n\n const pinsKey = trpc.communityTools.pins.queryKey()\n const refreshPins = () => queryClient.invalidateQueries({ queryKey: pinsKey })\n\n // The star and the shelf change at once; a failed save puts them back.\n const optimisticPins = (\n change: (old: CommunityTool[], slug: string) => CommunityTool[],\n ) => ({\n onMutate: async ({ slug }: { slug: string }) => {\n await queryClient.cancelQueries({ queryKey: pinsKey })\n const previous = queryClient.getQueryData<CommunityTool[]>(pinsKey)\n queryClient.setQueryData<CommunityTool[]>(pinsKey, (old) =>\n old ? change(old, slug) : old,\n )\n return { previous }\n },\n onError: (\n _error: unknown,\n _input: { slug: string },\n context: { previous?: CommunityTool[] } | undefined,\n ) => {\n queryClient.setQueryData(pinsKey, context?.previous)\n },\n // Not awaited: the pin is saved once the server answers; the refetch\n // behind it only confirms what the star already shows.\n onSettled: () => {\n void refreshPins()\n },\n })\n const pin = useMutation(\n trpc.communityTools.pin.mutationOptions(\n optimisticPins((old, slug) => {\n const tool = list.data?.find((t) => t.slug === slug)\n return tool && !old.some((t) => t.slug === slug) ? [...old, tool] : old\n }),\n ),\n )\n const unpin = useMutation(\n trpc.communityTools.unpin.mutationOptions(\n optimisticPins((old, slug) => old.filter((t) => t.slug !== slug)),\n ),\n )\n /** The tool whose pin is being saved, if any. */\n const pinBusySlug = pin.isPending\n ? pin.variables.slug\n : unpin.isPending\n ? unpin.variables.slug\n : undefined\n const reorder = useMutation(\n trpc.communityTools.reorderPins.mutationOptions({\n onMutate: ({ slugs }) => {\n // The tile moves at once; the server catches up behind it.\n queryClient.setQueryData<CommunityTool[]>(pinsKey, (old) =>\n old ? slugs.flatMap((s) => old.find((t) => t.slug === s) ?? []) : old,\n )\n },\n onSettled: refreshPins,\n }),\n )\n\n // My likes show at once, as an overlay on the tools as fetched. The feed\n // ranks from the fetched tools, so a like changes its own count and never\n // moves a row out from under the pointer. Settling marks the list stale\n // without refetching it: it re-ranks on the next refocus or remount.\n const [myLikes, setMyLikes] = useState<ReadonlyMap<string, boolean>>(\n () => new Map(),\n )\n const setMyLike = (slug: string, liked: boolean | undefined) =>\n setMyLikes((prev) => {\n const next = new Map(prev)\n if (liked === undefined) next.delete(slug)\n else next.set(slug, liked)\n return next\n })\n const likeOptions = (liked: boolean) => ({\n onMutate: ({ slug }: { slug: string }) => setMyLike(slug, liked),\n onError: (_error: unknown, { slug }: { slug: string }) =>\n setMyLike(slug, undefined),\n onSettled: () => {\n void queryClient.invalidateQueries({\n queryKey: trpc.communityTools.list.queryKey(),\n refetchType: 'none',\n })\n void refreshPins()\n },\n })\n const like = useMutation(\n trpc.communityTools.like.mutationOptions(likeOptions(true)),\n )\n const unlike = useMutation(\n trpc.communityTools.unlike.mutationOptions(likeOptions(false)),\n )\n /** The tool with my latest like or unlike, saved or still saving. */\n const withMyLike = useCallback(\n (tool: CommunityTool) => {\n const mine = myLikes.get(tool.slug)\n return mine === undefined ? tool : withLike(tool, mine)\n },\n [myLikes],\n )\n\n return {\n list,\n pins,\n appNames,\n isAuthenticated,\n isAuthLoading,\n pin,\n unpin,\n pinBusySlug,\n reorder,\n like,\n unlike,\n withMyLike,\n }\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"useCommunityTools.js","sources":["../../../../src/modules/communityTools/useCommunityTools.ts"],"sourcesContent":["import type { CommunityTool } from '@igstack/app-catalog-shared-core'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useCallback, useMemo, useState } from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { useAppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'\nimport { useAuth } from '~/modules/auth'\nimport { withLike } from './likedBy'\n\n/**\n * Everything the community page reads, and the pin writes. Pins\n * belong to the signed-in user, so they are not asked for until there is one.\n */\nexport function useCommunityTools() {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n const { isAuthenticated, isLoading: isAuthLoading } = useAuth()\n const { resources } = useAppCatalogContext()\n\n const list = useQuery(trpc.communityTools.list.queryOptions())\n const pins = useQuery(\n trpc.communityTools.pins.queryOptions(undefined, {\n enabled: isAuthenticated,\n }),\n )\n\n /** Display names of official apps by slug, for app tags. */\n const appNames = useMemo(\n () => Object.fromEntries(resources.map((r) => [r.slug, r.displayName])),\n [resources],\n )\n\n const pinsKey = trpc.communityTools.pins.queryKey()\n const refreshPins = () => queryClient.invalidateQueries({ queryKey: pinsKey })\n\n // The star and the shelf change at once; a failed save puts them back.\n const optimisticPins = (\n change: (old: CommunityTool[], slug: string) => CommunityTool[],\n ) => ({\n onMutate: async ({ slug }: { slug: string }) => {\n await queryClient.cancelQueries({ queryKey: pinsKey })\n const previous = queryClient.getQueryData<CommunityTool[]>(pinsKey)\n queryClient.setQueryData<CommunityTool[]>(pinsKey, (old) =>\n old ? change(old, slug) : old,\n )\n return { previous }\n },\n onError: (\n _error: unknown,\n _input: { slug: string },\n context: { previous?: CommunityTool[] } | undefined,\n ) => {\n queryClient.setQueryData(pinsKey, context?.previous)\n },\n // Not awaited: the pin is saved once the server answers; the refetch\n // behind it only confirms what the star already shows.\n onSettled: () => {\n void refreshPins()\n },\n })\n const pin = useMutation(\n trpc.communityTools.pin.mutationOptions(\n optimisticPins((old, slug) => {\n const tool = list.data?.find((t) => t.slug === slug)\n return tool && !old.some((t) => t.slug === slug) ? [...old, tool] : old\n }),\n ),\n )\n const unpin = useMutation(\n trpc.communityTools.unpin.mutationOptions(\n optimisticPins((old, slug) => old.filter((t) => t.slug !== slug)),\n ),\n )\n /** The tool whose pin is being saved, if any. */\n const pinBusySlug = pin.isPending\n ? pin.variables.slug\n : unpin.isPending\n ? unpin.variables.slug\n : undefined\n const reorder = useMutation(\n trpc.communityTools.reorderPins.mutationOptions({\n onMutate: ({ slugs }) => {\n // The tile moves at once; the server catches up behind it.\n queryClient.setQueryData<CommunityTool[]>(pinsKey, (old) =>\n old ? slugs.flatMap((s) => old.find((t) => t.slug === s) ?? []) : old,\n )\n },\n onSettled: refreshPins,\n }),\n )\n\n // My likes show at once, as an overlay on the tools as fetched. The feed\n // ranks from the fetched tools, so a like changes its own count and never\n // moves a row out from under the pointer. Settling marks the list stale\n // without refetching it: it re-ranks on the next refocus or remount.\n const [myLikes, setMyLikes] = useState<ReadonlyMap<string, boolean>>(\n () => new Map(),\n )\n const setMyLike = (slug: string, liked: boolean | undefined) =>\n setMyLikes((prev) => {\n const next = new Map(prev)\n if (liked === undefined) next.delete(slug)\n else next.set(slug, liked)\n return next\n })\n const likeOptions = (liked: boolean) => ({\n onMutate: ({ slug }: { slug: string }) => setMyLike(slug, liked),\n onError: (_error: unknown, { slug }: { slug: string }) =>\n setMyLike(slug, undefined),\n onSettled: () => {\n void queryClient.invalidateQueries({\n queryKey: trpc.communityTools.list.queryKey(),\n refetchType: 'none',\n })\n void refreshPins()\n },\n })\n const like = useMutation(\n trpc.communityTools.like.mutationOptions(likeOptions(true)),\n )\n const unlike = useMutation(\n trpc.communityTools.unlike.mutationOptions(likeOptions(false)),\n )\n /** The tool with my latest like or unlike, saved or still saving. */\n const withMyLike = useCallback(\n (tool: CommunityTool) => {\n const mine = myLikes.get(tool.slug)\n return mine === undefined ? tool : withLike(tool, mine)\n },\n [myLikes],\n )\n\n return {\n list,\n pins,\n appNames,\n isAuthenticated,\n isAuthLoading,\n pin,\n unpin,\n pinBusySlug,\n reorder,\n like,\n unlike,\n withMyLike,\n }\n}\n"],"names":[],"mappings":"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"}