@igstack/app-catalog-frontend-core 6.11.0 → 6.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/api/infra/trpc.d.ts +27 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js +10 -1
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js +5 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js.map +1 -1
- package/dist/esm/modules/communityTools/siteOpen.d.ts +10 -0
- package/dist/esm/modules/communityTools/siteOpen.js +18 -0
- package/dist/esm/modules/communityTools/siteOpen.js.map +1 -0
- package/dist/esm/modules/communityTools/timeAgo.d.ts +2 -0
- package/dist/esm/modules/communityTools/timeAgo.js +11 -0
- package/dist/esm/modules/communityTools/timeAgo.js.map +1 -1
- package/dist/esm/modules/communityTools/ui/CardScreenshot.js +10 -12
- package/dist/esm/modules/communityTools/ui/CardScreenshot.js.map +1 -1
- package/dist/esm/modules/communityTools/ui/CardTitleBlock.d.ts +1 -1
- package/dist/esm/modules/communityTools/ui/CardTitleBlock.js +5 -13
- package/dist/esm/modules/communityTools/ui/CardTitleBlock.js.map +1 -1
- package/dist/esm/modules/communityTools/ui/CommunityPage.js +2 -4
- package/dist/esm/modules/communityTools/ui/CommunityPage.js.map +1 -1
- package/dist/esm/modules/communityTools/ui/FeedRow.d.ts +7 -5
- package/dist/esm/modules/communityTools/ui/FeedRow.js +28 -30
- package/dist/esm/modules/communityTools/ui/FeedRow.js.map +1 -1
- package/dist/esm/modules/communityTools/ui/ShelfTile.js +4 -7
- package/dist/esm/modules/communityTools/ui/ShelfTile.js.map +1 -1
- package/dist/esm/modules/communityTools/ui/ToolCard.js +15 -2
- package/dist/esm/modules/communityTools/ui/ToolCard.js.map +1 -1
- package/dist/esm/modules/communityTools/useCommunityTools.d.ts +4 -0
- package/package.json +4 -4
- package/src/modules/appCatalog/ui/detail/CommentsFeed.tsx +11 -1
- package/src/modules/appCatalog/ui/detail/CommunityInput.tsx +6 -0
- package/src/modules/communityTools/siteOpen.ts +20 -0
- package/src/modules/communityTools/timeAgo.ts +12 -0
- package/src/modules/communityTools/ui/CardScreenshot.tsx +21 -9
- package/src/modules/communityTools/ui/CardTitleBlock.tsx +4 -9
- package/src/modules/communityTools/ui/CommunityPage.tsx +2 -5
- package/src/modules/communityTools/ui/FeedRow.tsx +40 -43
- package/src/modules/communityTools/ui/ShelfTile.tsx +4 -5
- package/src/modules/communityTools/ui/ToolCard.tsx +11 -1
|
@@ -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 { openSite } from '../siteOpen'\nimport { exactDate, timeAgo } from '../timeAgo'\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 openSite(tool)\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 {tool.siteUpdatedAt && (\n <span\n data-testid=\"card-site-updated\"\n title={exactDate(tool.siteUpdatedAt)}\n >\n Site updated {timeAgo(tool.siteUpdatedAt)}\n </span>\n )}\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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"}
|
|
@@ -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;
|
|
@@ -37,11 +38,13 @@ export declare function useCommunityTools(): {
|
|
|
37
38
|
reviewed: boolean;
|
|
38
39
|
screenshotId: string | null;
|
|
39
40
|
faviconId: string | null;
|
|
41
|
+
serverUnreachable: boolean;
|
|
40
42
|
}[], import('@trpc/client').TRPCClientErrorLike<{
|
|
41
43
|
transformer: false;
|
|
42
44
|
errorShape: import('@trpc/server').TRPCDefaultErrorShape;
|
|
43
45
|
}>>;
|
|
44
46
|
pins: import('@tanstack/react-query').UseQueryResult<{
|
|
47
|
+
comments: number;
|
|
45
48
|
createdAt: string;
|
|
46
49
|
slug: string;
|
|
47
50
|
name: string;
|
|
@@ -74,6 +77,7 @@ export declare function useCommunityTools(): {
|
|
|
74
77
|
reviewed: boolean;
|
|
75
78
|
screenshotId: string | null;
|
|
76
79
|
faviconId: string | null;
|
|
80
|
+
serverUnreachable: boolean;
|
|
77
81
|
}[], import('@trpc/client').TRPCClientErrorLike<{
|
|
78
82
|
transformer: false;
|
|
79
83
|
errorShape: import('@trpc/server').TRPCDefaultErrorShape;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-frontend-core",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.12.1",
|
|
4
4
|
"description": "Frontend core library for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -118,7 +118,7 @@
|
|
|
118
118
|
"tailwindcss": "^4.1.7",
|
|
119
119
|
"tiptap-markdown": "^0.8.0",
|
|
120
120
|
"tslib": "^2.3.0",
|
|
121
|
-
"@igstack/app-catalog-shared-core": "6.
|
|
121
|
+
"@igstack/app-catalog-shared-core": "6.12.1"
|
|
122
122
|
},
|
|
123
123
|
"devDependencies": {
|
|
124
124
|
"@tailwindcss/vite": "^4.0.5",
|
|
@@ -146,7 +146,7 @@
|
|
|
146
146
|
"vite-plugin-static-copy": "^3.1.4",
|
|
147
147
|
"vite-plugin-svgr": "^4.2.0",
|
|
148
148
|
"vitest": "^4.1.2",
|
|
149
|
-
"@igstack/app-catalog-backend-core": "6.
|
|
149
|
+
"@igstack/app-catalog-backend-core": "6.12.1"
|
|
150
150
|
},
|
|
151
151
|
"peerDependencies": {
|
|
152
152
|
"@tanstack/react-router": "1.170.41",
|
|
@@ -156,7 +156,7 @@
|
|
|
156
156
|
"vite": "^6.3.5",
|
|
157
157
|
"vite-plugin-svgr": "^4.2.0"
|
|
158
158
|
},
|
|
159
|
-
"gitHead": "
|
|
159
|
+
"gitHead": "36f0e878473c215afa1406185b81bc30583c75db",
|
|
160
160
|
"scripts": {
|
|
161
161
|
"build": "vite build",
|
|
162
162
|
"build:lenient": "vite build --mode lenient",
|
|
@@ -112,7 +112,17 @@ export function CommentsFeed({
|
|
|
112
112
|
}),
|
|
113
113
|
)
|
|
114
114
|
const dismiss = useMutation(
|
|
115
|
-
trpc.feedback.dismiss.mutationOptions({
|
|
115
|
+
trpc.feedback.dismiss.mutationOptions({
|
|
116
|
+
onSuccess: () => {
|
|
117
|
+
void refresh()
|
|
118
|
+
// A tool's row counts its comments.
|
|
119
|
+
if ('communityToolSlug' in target) {
|
|
120
|
+
void queryClient.invalidateQueries({
|
|
121
|
+
queryKey: trpc.communityTools.pathKey(),
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
}),
|
|
116
126
|
)
|
|
117
127
|
const saveEdit = (id: string) => {
|
|
118
128
|
const body = editDraft.trim()
|
|
@@ -160,6 +160,12 @@ export function CommunityInput({
|
|
|
160
160
|
void queryClient.invalidateQueries({
|
|
161
161
|
queryKey: trpc.feedback.list.queryKey(target),
|
|
162
162
|
})
|
|
163
|
+
// A tool's row counts its comments.
|
|
164
|
+
if ('communityToolSlug' in target) {
|
|
165
|
+
void queryClient.invalidateQueries({
|
|
166
|
+
queryKey: trpc.communityTools.pathKey(),
|
|
167
|
+
})
|
|
168
|
+
}
|
|
163
169
|
},
|
|
164
170
|
}),
|
|
165
171
|
)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counts a click through to a tool's site. The page leaves in the same tab, which
|
|
3
|
+
* would cancel a tRPC call, so this posts with `keepalive` and never waits for it.
|
|
4
|
+
*/
|
|
5
|
+
export function recordSiteOpen(slug: string): void {
|
|
6
|
+
fetch(`${window.location.origin}/api/trpc/communityTools.recordSiteOpen`, {
|
|
7
|
+
method: 'POST',
|
|
8
|
+
keepalive: true,
|
|
9
|
+
headers: { 'content-type': 'application/json' },
|
|
10
|
+
body: JSON.stringify({ slug }),
|
|
11
|
+
}).catch(() => {
|
|
12
|
+
// A lost count never stops anyone reaching the site.
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Goes to the tool's site in this tab, counting the click. */
|
|
17
|
+
export function openSite(tool: { slug: string; url: string }): void {
|
|
18
|
+
recordSiteOpen(tool.slug)
|
|
19
|
+
window.location.assign(tool.url)
|
|
20
|
+
}
|
|
@@ -14,3 +14,15 @@ export function timeAgo(iso: string, now: Date = new Date()): string {
|
|
|
14
14
|
const years = Math.floor(days / 365)
|
|
15
15
|
return years === 1 ? '1 year ago' : `${years} years ago`
|
|
16
16
|
}
|
|
17
|
+
|
|
18
|
+
/** The exact moment, for the tooltip of a timeAgo: "22 Oct 2026, 15:04". */
|
|
19
|
+
export function exactDate(iso: string): string {
|
|
20
|
+
return new Date(iso).toLocaleString('en-GB', {
|
|
21
|
+
day: 'numeric',
|
|
22
|
+
month: 'short',
|
|
23
|
+
year: 'numeric',
|
|
24
|
+
hour: '2-digit',
|
|
25
|
+
minute: '2-digit',
|
|
26
|
+
hour12: false,
|
|
27
|
+
})
|
|
28
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { CommunityTool } from '@igstack/app-catalog-shared-core'
|
|
2
|
-
import { Camera,
|
|
2
|
+
import { Camera, ImageOff, Maximize2 } from 'lucide-react'
|
|
3
3
|
import { useState } from 'react'
|
|
4
4
|
import { ScreenshotGallery } from '~/modules/appCatalog/ui/components/ScreenshotGallery'
|
|
5
5
|
import { cn } from '~/lib/utils'
|
|
6
|
+
import { recordSiteOpen } from '../siteOpen'
|
|
6
7
|
import { screenshotUrl } from './FeedRow'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -13,6 +14,10 @@ import { screenshotUrl } from './FeedRow'
|
|
|
13
14
|
const BOX =
|
|
14
15
|
'relative flex aspect-[16/10] max-h-[320px] w-full items-center justify-center overflow-hidden rounded-[14px] border border-border bg-muted shadow-sm'
|
|
15
16
|
|
|
17
|
+
/** The server's checks get no answer, so there is no fresh screenshot to show. */
|
|
18
|
+
const UNREACHABLE =
|
|
19
|
+
"Screenshot unavailable: the catalog's server can't reach this site."
|
|
20
|
+
|
|
16
21
|
export function formatCardDate(iso: string): string {
|
|
17
22
|
return new Date(iso).toLocaleDateString('en-GB', {
|
|
18
23
|
day: 'numeric',
|
|
@@ -58,7 +63,11 @@ export function CardScreenshot({
|
|
|
58
63
|
) : (
|
|
59
64
|
<>
|
|
60
65
|
<ImageOff aria-hidden="true" className="size-7" />
|
|
61
|
-
|
|
66
|
+
{tool.serverUnreachable ? (
|
|
67
|
+
<span data-testid="card-server-unreachable">{UNREACHABLE}</span>
|
|
68
|
+
) : (
|
|
69
|
+
'No screenshot. The site may need a login, or it did not load.'
|
|
70
|
+
)}
|
|
62
71
|
</>
|
|
63
72
|
)}
|
|
64
73
|
</div>
|
|
@@ -78,9 +87,8 @@ export function CardScreenshot({
|
|
|
78
87
|
>
|
|
79
88
|
<a
|
|
80
89
|
href={tool.url}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
aria-label={`Open ${tool.name} in a new tab`}
|
|
90
|
+
onClick={() => recordSiteOpen(tool.slug)}
|
|
91
|
+
aria-label={`Open ${tool.name}`}
|
|
84
92
|
data-testid="card-screenshot-link"
|
|
85
93
|
className="group/shot block size-full rounded-[14px] focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ring"
|
|
86
94
|
>
|
|
@@ -90,9 +98,8 @@ export function CardScreenshot({
|
|
|
90
98
|
className="size-full object-cover object-left-top"
|
|
91
99
|
/>
|
|
92
100
|
<span className="absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-focus-visible/shot:bg-black/20 group-hover/shot:bg-black/20">
|
|
93
|
-
<span className="
|
|
101
|
+
<span className="rounded-lg bg-primary px-3.5 py-2 text-sm font-extrabold text-primary-foreground opacity-0 shadow-sm transition-opacity group-focus-visible/shot:opacity-100 group-hover/shot:opacity-100">
|
|
94
102
|
Open site
|
|
95
|
-
<ExternalLink aria-hidden="true" className="size-4" />
|
|
96
103
|
</span>
|
|
97
104
|
</span>
|
|
98
105
|
</a>
|
|
@@ -107,9 +114,14 @@ export function CardScreenshot({
|
|
|
107
114
|
<Maximize2 aria-hidden="true" className="size-4" />
|
|
108
115
|
</button>
|
|
109
116
|
</div>
|
|
110
|
-
{capturedAt && (
|
|
117
|
+
{(capturedAt || tool.serverUnreachable) && (
|
|
111
118
|
<figcaption className="mt-2 text-[12.5px] font-semibold text-muted-foreground">
|
|
112
|
-
Screenshot captured {formatCardDate(capturedAt)}
|
|
119
|
+
{capturedAt && `Screenshot captured ${formatCardDate(capturedAt)}`}
|
|
120
|
+
{tool.serverUnreachable && (
|
|
121
|
+
<span data-testid="card-server-unreachable" className="block">
|
|
122
|
+
{UNREACHABLE}
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
113
125
|
</figcaption>
|
|
114
126
|
)}
|
|
115
127
|
<ScreenshotGallery
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
import type { CommunityTool } from '@igstack/app-catalog-shared-core'
|
|
2
|
-
import {
|
|
2
|
+
import { Globe } from 'lucide-react'
|
|
3
3
|
import { cn } from '~/lib/utils'
|
|
4
|
+
import { recordSiteOpen } from '../siteOpen'
|
|
4
5
|
import { screenshotUrl } from './FeedRow'
|
|
5
6
|
import { LifecycleBadge, UnreviewedBadge } from './LifecycleBadge'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* The card's door: favicon (a globe when the site has none), name, badges and
|
|
9
|
-
* address, all one link that opens the site
|
|
10
|
+
* address, all one link that opens the site.
|
|
10
11
|
*/
|
|
11
12
|
export function CardTitleBlock({ tool }: { tool: CommunityTool }) {
|
|
12
13
|
const archived = tool.lifecycle.state === 'archived'
|
|
13
14
|
return (
|
|
14
15
|
<a
|
|
15
16
|
href={tool.url}
|
|
16
|
-
|
|
17
|
-
rel="noopener noreferrer"
|
|
17
|
+
onClick={() => recordSiteOpen(tool.slug)}
|
|
18
18
|
data-testid="card-title-block"
|
|
19
19
|
className={cn(
|
|
20
20
|
'group/door inline-flex min-w-0 items-center gap-[15px] rounded-[14px] border border-border bg-primary/[0.025] px-[17px] py-[11px] transition-colors',
|
|
@@ -47,12 +47,7 @@ export function CardTitleBlock({ tool }: { tool: CommunityTool }) {
|
|
|
47
47
|
<span className="truncate font-mono text-[13px] text-muted-foreground">
|
|
48
48
|
{tool.host}
|
|
49
49
|
</span>
|
|
50
|
-
<span className="sr-only">(opens in a new tab)</span>
|
|
51
50
|
</span>
|
|
52
|
-
<ExternalLink
|
|
53
|
-
aria-hidden="true"
|
|
54
|
-
className="size-[18px] shrink-0 self-start text-muted-foreground group-hover/door:text-primary"
|
|
55
|
-
/>
|
|
56
51
|
</a>
|
|
57
52
|
)
|
|
58
53
|
}
|
|
@@ -29,6 +29,7 @@ import type { ToastMessage } from './Toast'
|
|
|
29
29
|
import { ToolCard } from './ToolCard'
|
|
30
30
|
import { ToolFormDialog } from './ToolFormDialog'
|
|
31
31
|
import { ToolSearch } from './ToolSearch'
|
|
32
|
+
import { openSite } from '../siteOpen'
|
|
32
33
|
|
|
33
34
|
const KBD =
|
|
34
35
|
'rounded-[5px] border border-b-2 border-border bg-card px-1.5 py-px text-[11px] font-semibold text-muted-foreground'
|
|
@@ -56,10 +57,6 @@ function optionIdOf(slug: string): string {
|
|
|
56
57
|
return `community-option-${slug}`
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
function openSite(url: string) {
|
|
60
|
-
window.open(url, '_blank', 'noopener,noreferrer')
|
|
61
|
-
}
|
|
62
|
-
|
|
63
60
|
export function CommunityPage({
|
|
64
61
|
tag,
|
|
65
62
|
sort,
|
|
@@ -311,7 +308,7 @@ export function CommunityPage({
|
|
|
311
308
|
)
|
|
312
309
|
} else if (e.key === 'Enter' && current) {
|
|
313
310
|
e.preventDefault()
|
|
314
|
-
openSite(current.tool
|
|
311
|
+
openSite(current.tool)
|
|
315
312
|
} else if (
|
|
316
313
|
e.key === 'ArrowRight' &&
|
|
317
314
|
current &&
|
|
@@ -6,14 +6,15 @@ import type {
|
|
|
6
6
|
import { Link } from '@tanstack/react-router'
|
|
7
7
|
import {
|
|
8
8
|
CornerDownLeft,
|
|
9
|
-
ExternalLink,
|
|
10
9
|
Eye,
|
|
11
10
|
Globe,
|
|
11
|
+
MessageSquare,
|
|
12
12
|
PanelRight,
|
|
13
13
|
} from 'lucide-react'
|
|
14
14
|
import { cn } from '~/lib/utils'
|
|
15
15
|
import { highlightTokens } from '~/util/highlightMatches'
|
|
16
|
-
import {
|
|
16
|
+
import { recordSiteOpen } from '../siteOpen'
|
|
17
|
+
import { exactDate, timeAgo } from '../timeAgo'
|
|
17
18
|
import { LifecycleBadge, UnreviewedBadge } from './LifecycleBadge'
|
|
18
19
|
import { LikeButton } from './LikeButton'
|
|
19
20
|
import { PinButton } from './PinButton'
|
|
@@ -96,7 +97,10 @@ export interface FeedRowProps {
|
|
|
96
97
|
tool: CommunityTool
|
|
97
98
|
/** Shown in the ranked feed; search results have no rank. */
|
|
98
99
|
rank?: number
|
|
99
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* Newest shows when it was added, Recently updated when the site changed;
|
|
102
|
+
* every other sort the site's date, or when it was added if that is unknown.
|
|
103
|
+
*/
|
|
100
104
|
sort: FeedSort
|
|
101
105
|
tokens: string[]
|
|
102
106
|
/** Set while searching: which fields the query hit. */
|
|
@@ -122,10 +126,9 @@ const PANEL = 'w-[120px]'
|
|
|
122
126
|
|
|
123
127
|
/**
|
|
124
128
|
* One tool in the feed or the search results. Everything left of the end
|
|
125
|
-
* panel (rank, picture, name, summary, meta) is one link that opens the
|
|
126
|
-
*
|
|
127
|
-
* line as its own target, above that link.
|
|
128
|
-
* the big click goes to, on hover or focus only.
|
|
129
|
+
* panel (rank, picture, name, summary, meta) is one link that opens the
|
|
130
|
+
* site; the 120 px panel opens the card. The star sits in the meta
|
|
131
|
+
* line as its own target, above that link.
|
|
129
132
|
*/
|
|
130
133
|
export function FeedRow({
|
|
131
134
|
tool,
|
|
@@ -143,13 +146,12 @@ export function FeedRow({
|
|
|
143
146
|
const archived = tool.lifecycle.state === 'archived'
|
|
144
147
|
const cell = optionId ? 'gridcell' : undefined
|
|
145
148
|
const owners = tool.owners.map((o) => o.name).join(', ')
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
const address = tool.url.replace(/^https?:\/\//, '').replace(/\/$/, '')
|
|
149
|
+
const siteUpdatedAt = sort !== 'newest' ? tool.siteUpdatedAt : null
|
|
150
|
+
const dated = siteUpdatedAt
|
|
151
|
+
? { label: 'Updated', at: siteUpdatedAt }
|
|
152
|
+
: sort !== 'updated'
|
|
153
|
+
? { label: 'Added', at: tool.createdAt }
|
|
154
|
+
: null
|
|
153
155
|
|
|
154
156
|
return (
|
|
155
157
|
<div
|
|
@@ -184,11 +186,10 @@ export function FeedRow({
|
|
|
184
186
|
<div className="flex items-center gap-2.5 text-[15.5px] font-extrabold">
|
|
185
187
|
<a
|
|
186
188
|
href={tool.url}
|
|
187
|
-
|
|
188
|
-
rel="noopener noreferrer"
|
|
189
|
+
onClick={() => recordSiteOpen(tool.slug)}
|
|
189
190
|
data-site
|
|
190
191
|
data-testid="feed-row-name"
|
|
191
|
-
aria-label={`${tool.name}, opens ${tool.host}
|
|
192
|
+
aria-label={`${tool.name}, opens ${tool.host}`}
|
|
192
193
|
className={cn(
|
|
193
194
|
'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',
|
|
194
195
|
'group-hover/row:underline group-has-[[data-details]:hover]/row:no-underline',
|
|
@@ -220,9 +221,9 @@ export function FeedRow({
|
|
|
220
221
|
</div>
|
|
221
222
|
)}
|
|
222
223
|
</div>
|
|
223
|
-
{/* Meta on the left, the
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
{/* Meta on the left, the date on the right, flush to the panel. A
|
|
225
|
+
long author list gives way first. Clicks fall through to the row
|
|
226
|
+
link, except on star and like. */}
|
|
226
227
|
<div
|
|
227
228
|
className={cn(
|
|
228
229
|
'pointer-events-none absolute right-[120px] bottom-0 z-[2] flex h-12 items-center gap-4',
|
|
@@ -255,32 +256,28 @@ export function FeedRow({
|
|
|
255
256
|
{tool.views30d}
|
|
256
257
|
<span className="sr-only"> views this month</span>
|
|
257
258
|
</span>
|
|
258
|
-
{
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
<
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
{dated}
|
|
259
|
+
{tool.comments > 0 && (
|
|
260
|
+
<span
|
|
261
|
+
data-testid="feed-row-comments"
|
|
262
|
+
className="inline-flex items-center gap-1 text-muted-foreground"
|
|
263
|
+
>
|
|
264
|
+
<MessageSquare aria-hidden="true" className="size-[15px]" />
|
|
265
|
+
{tool.comments}
|
|
266
|
+
<span className="sr-only">
|
|
267
|
+
{tool.comments === 1 ? ' comment' : ' comments'}
|
|
268
268
|
</span>
|
|
269
|
-
|
|
270
|
-
)}
|
|
271
|
-
</div>
|
|
272
|
-
<div
|
|
273
|
-
aria-hidden="true"
|
|
274
|
-
data-testid="feed-row-address"
|
|
275
|
-
className={cn(
|
|
276
|
-
'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',
|
|
277
|
-
'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',
|
|
278
|
-
selected && 'opacity-100',
|
|
269
|
+
</span>
|
|
279
270
|
)}
|
|
280
|
-
>
|
|
281
|
-
<span className="min-w-0 truncate">{address}</span>
|
|
282
|
-
<ExternalLink aria-hidden="true" className="size-3.5 shrink-0" />
|
|
283
271
|
</div>
|
|
272
|
+
{dated && (
|
|
273
|
+
<span
|
|
274
|
+
data-testid="feed-row-date"
|
|
275
|
+
title={exactDate(dated.at)}
|
|
276
|
+
className="pointer-events-auto ml-auto shrink-0 pr-4 text-[13px] font-semibold whitespace-nowrap text-muted-foreground"
|
|
277
|
+
>
|
|
278
|
+
{dated.label} {timeAgo(dated.at)}
|
|
279
|
+
</span>
|
|
280
|
+
)}
|
|
284
281
|
</div>
|
|
285
282
|
</div>
|
|
286
283
|
<div role={cell} className="flex self-stretch">
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { CommunityTool } from '@igstack/app-catalog-shared-core'
|
|
2
|
-
import {
|
|
2
|
+
import { GripVertical } from 'lucide-react'
|
|
3
3
|
import { cn } from '~/lib/utils'
|
|
4
4
|
import { PersonAvatar, ToolThumb } from './FeedRow'
|
|
5
|
+
import { recordSiteOpen } from '../siteOpen'
|
|
5
6
|
import { PinButton } from './PinButton'
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -35,8 +36,7 @@ export function ShelfTile({
|
|
|
35
36
|
>
|
|
36
37
|
<a
|
|
37
38
|
href={tool.url}
|
|
38
|
-
|
|
39
|
-
rel="noopener noreferrer"
|
|
39
|
+
onClick={() => recordSiteOpen(tool.slug)}
|
|
40
40
|
aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown"
|
|
41
41
|
onKeyDown={(e) => {
|
|
42
42
|
if (!e.altKey) return
|
|
@@ -49,8 +49,7 @@ export function ShelfTile({
|
|
|
49
49
|
>
|
|
50
50
|
<span className="relative block aspect-[16/9.5] border-b border-border">
|
|
51
51
|
<ToolThumb tool={tool} size={512} className="size-full border-0" />
|
|
52
|
-
<span className="absolute right-2.5 bottom-2.5
|
|
53
|
-
<ExternalLink aria-hidden="true" className="size-4" />
|
|
52
|
+
<span className="absolute right-2.5 bottom-2.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-extrabold text-primary-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
|
54
53
|
Open site
|
|
55
54
|
</span>
|
|
56
55
|
</span>
|
|
@@ -26,6 +26,8 @@ import { Skeleton } from '~/ui/skeleton'
|
|
|
26
26
|
import { CardScreenshot, formatCardDate } from './CardScreenshot'
|
|
27
27
|
import { CardTitleBlock } from './CardTitleBlock'
|
|
28
28
|
import { PersonAvatar } from './FeedRow'
|
|
29
|
+
import { openSite } from '../siteOpen'
|
|
30
|
+
import { exactDate, timeAgo } from '../timeAgo'
|
|
29
31
|
import { useTimeUp } from '../useTimeUp'
|
|
30
32
|
import { PinButton } from './PinButton'
|
|
31
33
|
import { TagChip, tagKey, tagLabel } from './TagRow'
|
|
@@ -184,7 +186,7 @@ export function ToolCard({
|
|
|
184
186
|
// that opens the site.
|
|
185
187
|
if (target.closest('a, button')) return
|
|
186
188
|
e.preventDefault()
|
|
187
|
-
|
|
189
|
+
openSite(tool)
|
|
188
190
|
}
|
|
189
191
|
}
|
|
190
192
|
|
|
@@ -296,6 +298,14 @@ export function ToolCard({
|
|
|
296
298
|
</b>{' '}
|
|
297
299
|
all time
|
|
298
300
|
</span>
|
|
301
|
+
{tool.siteUpdatedAt && (
|
|
302
|
+
<span
|
|
303
|
+
data-testid="card-site-updated"
|
|
304
|
+
title={exactDate(tool.siteUpdatedAt)}
|
|
305
|
+
>
|
|
306
|
+
Site updated {timeAgo(tool.siteUpdatedAt)}
|
|
307
|
+
</span>
|
|
308
|
+
)}
|
|
299
309
|
</p>
|
|
300
310
|
)}
|
|
301
311
|
{/* Who to ask, up here with the facts: below the screenshot
|