@igstack/app-catalog-frontend-core 6.10.3 → 6.11.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.
- package/dist/esm/__tests__/modules/auth/AuthContext.test.d.ts +1 -0
- package/dist/esm/__tests__/modules/communityTools/likedBy.test.d.ts +1 -0
- package/dist/esm/__tests__/modules/communityTools/timeAgo.test.d.ts +1 -0
- package/dist/esm/__tests__/modules/communityTools/useTimeUp.test.d.ts +1 -0
- package/dist/esm/api/infra/trpc.d.ts +419 -0
- package/dist/esm/api/infra/trpc.js.map +1 -1
- package/dist/esm/modules/appCatalog/hooks/useUpdateApp.d.ts +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.d.ts +2 -4
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js +29 -101
- package/dist/esm/modules/appCatalog/ui/catalog/AppDetailPanel.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/catalog/DetailDialogShell.d.ts +26 -0
- package/dist/esm/modules/appCatalog/ui/catalog/DetailDialogShell.js +108 -0
- package/dist/esm/modules/appCatalog/ui/catalog/DetailDialogShell.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.d.ts +2 -1
- package/dist/esm/modules/appCatalog/ui/components/ScreenshotGallery.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/components/SectionTabs.d.ts +8 -0
- package/dist/esm/modules/appCatalog/ui/components/SectionTabs.js +73 -0
- package/dist/esm/modules/appCatalog/ui/components/SectionTabs.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js +19 -2
- package/dist/esm/modules/appCatalog/ui/detail/AppDetails.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.d.ts +3 -2
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js +4 -4
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.d.ts +2 -1
- package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js +2 -2
- package/dist/esm/modules/appCatalog/ui/detail/CommentsHelp.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.d.ts +3 -2
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js +13 -9
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.d.ts +4 -2
- package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js +10 -4
- package/dist/esm/modules/appCatalog/ui/detail/CommunitySection.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/communityDraft.d.ts +12 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js +4 -0
- package/dist/esm/modules/appCatalog/ui/detail/communityDraft.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/composeComments.js +1 -0
- package/dist/esm/modules/appCatalog/ui/detail/composeComments.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/useCommunityToolCount.d.ts +6 -0
- package/dist/esm/modules/appCatalog/ui/detail/useCommunityToolCount.js +16 -0
- package/dist/esm/modules/appCatalog/ui/detail/useCommunityToolCount.js.map +1 -0
- package/dist/esm/modules/appCatalog/ui/layout/AppCatalogLayout.js +2 -2
- package/dist/esm/modules/appCatalog/ui/layout/AppCatalogLayout.js.map +1 -1
- package/dist/esm/modules/auth/AuthContext.js +7 -3
- package/dist/esm/modules/auth/AuthContext.js.map +1 -1
- package/dist/esm/modules/auth/ui/LoginModal.js +16 -8
- package/dist/esm/modules/auth/ui/LoginModal.js.map +1 -1
- package/dist/esm/modules/communityTools/likedBy.d.ts +8 -0
- package/dist/esm/modules/communityTools/likedBy.js +23 -0
- package/dist/esm/modules/communityTools/likedBy.js.map +1 -0
- package/dist/esm/modules/communityTools/timeAgo.d.ts +2 -0
- package/dist/esm/modules/communityTools/timeAgo.js +18 -0
- package/dist/esm/modules/communityTools/timeAgo.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/CardScreenshot.d.ts +13 -0
- package/dist/esm/modules/communityTools/ui/CardScreenshot.js +112 -0
- package/dist/esm/modules/communityTools/ui/CardScreenshot.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/CardTitleBlock.d.ts +8 -0
- package/dist/esm/modules/communityTools/ui/CardTitleBlock.js +59 -0
- package/dist/esm/modules/communityTools/ui/CardTitleBlock.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/CommunityPage.d.ts +12 -0
- package/dist/esm/modules/communityTools/ui/CommunityPage.js +547 -0
- package/dist/esm/modules/communityTools/ui/CommunityPage.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/FeedRow.d.ts +42 -0
- package/dist/esm/modules/communityTools/ui/FeedRow.js +260 -0
- package/dist/esm/modules/communityTools/ui/FeedRow.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/FeedSortControl.d.ts +17 -0
- package/dist/esm/modules/communityTools/ui/FeedSortControl.js +84 -0
- package/dist/esm/modules/communityTools/ui/FeedSortControl.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/LifecycleBadge.d.ts +7 -0
- package/dist/esm/modules/communityTools/ui/LifecycleBadge.js +50 -0
- package/dist/esm/modules/communityTools/ui/LifecycleBadge.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/LikeButton.d.ts +9 -0
- package/dist/esm/modules/communityTools/ui/LikeButton.js +56 -0
- package/dist/esm/modules/communityTools/ui/LikeButton.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/MarginPreview.d.ts +18 -0
- package/dist/esm/modules/communityTools/ui/MarginPreview.js +115 -0
- package/dist/esm/modules/communityTools/ui/MarginPreview.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/MyToolsShelf.d.ts +12 -0
- package/dist/esm/modules/communityTools/ui/MyToolsShelf.js +88 -0
- package/dist/esm/modules/communityTools/ui/MyToolsShelf.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/OwnerPicker.d.ts +56 -0
- package/dist/esm/modules/communityTools/ui/OwnerPicker.js +245 -0
- package/dist/esm/modules/communityTools/ui/OwnerPicker.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/PinButton.d.ts +14 -0
- package/dist/esm/modules/communityTools/ui/PinButton.js +72 -0
- package/dist/esm/modules/communityTools/ui/PinButton.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/ShelfTile.d.ts +13 -0
- package/dist/esm/modules/communityTools/ui/ShelfTile.js +81 -0
- package/dist/esm/modules/communityTools/ui/ShelfTile.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/TagPicker.d.ts +21 -0
- package/dist/esm/modules/communityTools/ui/TagPicker.js +87 -0
- package/dist/esm/modules/communityTools/ui/TagPicker.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/TagRow.d.ts +22 -0
- package/dist/esm/modules/communityTools/ui/TagRow.js +92 -0
- package/dist/esm/modules/communityTools/ui/TagRow.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/Toast.d.ts +19 -0
- package/dist/esm/modules/communityTools/ui/Toast.js +87 -0
- package/dist/esm/modules/communityTools/ui/Toast.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/ToolCard.d.ts +22 -0
- package/dist/esm/modules/communityTools/ui/ToolCard.js +467 -0
- package/dist/esm/modules/communityTools/ui/ToolCard.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/ToolFormDialog.d.ts +32 -0
- package/dist/esm/modules/communityTools/ui/ToolFormDialog.js +765 -0
- package/dist/esm/modules/communityTools/ui/ToolFormDialog.js.map +1 -0
- package/dist/esm/modules/communityTools/ui/ToolSearch.d.ts +15 -0
- package/dist/esm/modules/communityTools/ui/ToolSearch.js +92 -0
- package/dist/esm/modules/communityTools/ui/ToolSearch.js.map +1 -0
- package/dist/esm/modules/communityTools/useCommunityTools.d.ts +157 -0
- package/dist/esm/modules/communityTools/useCommunityTools.js +122 -0
- package/dist/esm/modules/communityTools/useCommunityTools.js.map +1 -0
- package/dist/esm/modules/communityTools/useCommunityToolsConfig.d.ts +10 -0
- package/dist/esm/modules/communityTools/useCommunityToolsConfig.js +20 -0
- package/dist/esm/modules/communityTools/useCommunityToolsConfig.js.map +1 -0
- package/dist/esm/modules/communityTools/useTimeUp.d.ts +5 -0
- package/dist/esm/modules/communityTools/useTimeUp.js +13 -0
- package/dist/esm/modules/communityTools/useTimeUp.js.map +1 -0
- package/dist/esm/node_modules/.pnpm/zod@4.3.5/node_modules/zod/v4/classic/schemas.js +2 -2
- package/dist/esm/node_modules/.pnpm/zod@4.3.5/node_modules/zod/v4/core/schemas.js +1 -1
- package/dist/esm/routeTree.gen.d.ts +96 -3
- package/dist/esm/routeTree.gen.js +19 -0
- package/dist/esm/routeTree.gen.js.map +1 -1
- package/dist/esm/routes/_layout/community.$slug.d.ts +22 -0
- package/dist/esm/routes/_layout/community._slug.js +10 -0
- package/dist/esm/routes/_layout/community._slug.js.map +1 -0
- package/dist/esm/routes/_layout/community.d.ts +23 -0
- package/dist/esm/routes/_layout/community.js +71 -0
- package/dist/esm/routes/_layout/community.js.map +1 -0
- package/dist/esm/ui/components/header/Header.js +7 -3
- package/dist/esm/ui/components/header/Header.js.map +1 -1
- package/dist/esm/util/highlightMatches.d.ts +2 -0
- package/dist/esm/util/highlightMatches.js +32 -0
- package/dist/esm/util/highlightMatches.js.map +1 -0
- package/dist/index.css +6 -0
- package/package.json +5 -5
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.appSwitch.test.tsx +3 -0
- package/src/__tests__/modules/appCatalog/ui/catalog/AppDetailPanel.enter.test.tsx +3 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.background.test.tsx +3 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.behindThisCard.test.tsx +4 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.doorAndTabs.test.tsx +3 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.escape.test.tsx +3 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.knowMore.test.tsx +3 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.sectionOrder.test.tsx +3 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.subResourceTab.test.tsx +3 -0
- package/src/__tests__/modules/appCatalog/ui/detail/AppDetails.tags.test.tsx +3 -0
- package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.feed.test.tsx +10 -3
- package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.help.test.tsx +4 -1
- package/src/__tests__/modules/appCatalog/ui/detail/CommunitySection.input.test.tsx +12 -2
- package/src/__tests__/modules/appCatalog/ui/detail/composeComments.test.ts +1 -0
- package/src/__tests__/modules/auth/AuthContext.test.tsx +55 -0
- package/src/__tests__/modules/communityTools/likedBy.test.ts +45 -0
- package/src/__tests__/modules/communityTools/timeAgo.test.ts +28 -0
- package/src/__tests__/modules/communityTools/useTimeUp.test.ts +31 -0
- package/src/__tests__/ui/components/header/Header.login.test.tsx +4 -4
- package/src/api/infra/trpc.ts +2 -0
- package/src/index.css +6 -0
- package/src/modules/appCatalog/ui/catalog/AppDetailPanel.tsx +33 -176
- package/src/modules/appCatalog/ui/catalog/DetailDialogShell.tsx +199 -0
- package/src/modules/appCatalog/ui/components/ScreenshotGallery.tsx +2 -1
- package/src/modules/appCatalog/ui/components/SectionTabs.tsx +81 -0
- package/src/modules/appCatalog/ui/detail/AppDetails.tsx +17 -2
- package/src/modules/appCatalog/ui/detail/CommentsFeed.tsx +9 -4
- package/src/modules/appCatalog/ui/detail/CommentsHelp.tsx +6 -3
- package/src/modules/appCatalog/ui/detail/CommunityInput.tsx +15 -10
- package/src/modules/appCatalog/ui/detail/CommunitySection.tsx +10 -5
- package/src/modules/appCatalog/ui/detail/communityDraft.ts +16 -0
- package/src/modules/appCatalog/ui/detail/composeComments.ts +1 -0
- package/src/modules/appCatalog/ui/detail/useCommunityToolCount.ts +18 -0
- package/src/modules/appCatalog/ui/layout/AppCatalogLayout.tsx +2 -2
- package/src/modules/auth/AuthContext.tsx +9 -3
- package/src/modules/auth/ui/LoginModal.tsx +7 -2
- package/src/modules/communityTools/likedBy.ts +32 -0
- package/src/modules/communityTools/timeAgo.ts +16 -0
- package/src/modules/communityTools/ui/CardScreenshot.tsx +124 -0
- package/src/modules/communityTools/ui/CardTitleBlock.tsx +58 -0
- package/src/modules/communityTools/ui/CommunityPage.tsx +626 -0
- package/src/modules/communityTools/ui/FeedRow.tsx +310 -0
- package/src/modules/communityTools/ui/FeedSortControl.tsx +97 -0
- package/src/modules/communityTools/ui/LifecycleBadge.tsx +44 -0
- package/src/modules/communityTools/ui/LikeButton.tsx +56 -0
- package/src/modules/communityTools/ui/MarginPreview.tsx +143 -0
- package/src/modules/communityTools/ui/MyToolsShelf.tsx +112 -0
- package/src/modules/communityTools/ui/OwnerPicker.tsx +311 -0
- package/src/modules/communityTools/ui/PinButton.tsx +87 -0
- package/src/modules/communityTools/ui/ShelfTile.tsx +83 -0
- package/src/modules/communityTools/ui/TagPicker.tsx +118 -0
- package/src/modules/communityTools/ui/TagRow.tsx +118 -0
- package/src/modules/communityTools/ui/Toast.tsx +105 -0
- package/src/modules/communityTools/ui/ToolCard.tsx +519 -0
- package/src/modules/communityTools/ui/ToolFormDialog.tsx +922 -0
- package/src/modules/communityTools/ui/ToolSearch.tsx +111 -0
- package/src/modules/communityTools/useCommunityTools.ts +146 -0
- package/src/modules/communityTools/useCommunityToolsConfig.ts +26 -0
- package/src/modules/communityTools/useTimeUp.ts +14 -0
- package/src/routeTree.gen.ts +52 -0
- package/src/routes/_layout/community.$slug.tsx +7 -0
- package/src/routes/_layout/community.tsx +97 -0
- package/src/ui/components/header/Header.tsx +9 -3
- package/src/util/highlightMatches.tsx +34 -0
- package/dist/esm/modules/appCatalog/ui/components/ViewToggle.d.ts +0 -12
- package/dist/esm/modules/appCatalog/ui/components/ViewToggle.js +0 -56
- package/dist/esm/modules/appCatalog/ui/components/ViewToggle.js.map +0 -1
- package/src/modules/appCatalog/ui/components/ViewToggle.tsx +0 -59
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
import type { Claim } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import type { CommunityTool } from '@igstack/app-catalog-shared-core'
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
4
|
+
import {
|
|
5
|
+
Archive,
|
|
6
|
+
ArrowLeft,
|
|
7
|
+
ArrowRight,
|
|
8
|
+
Check,
|
|
9
|
+
Eye,
|
|
10
|
+
Pencil,
|
|
11
|
+
} from 'lucide-react'
|
|
12
|
+
import { useEffect, useId, useRef, useState } from 'react'
|
|
13
|
+
import { useTRPC } from '~/api/infra/trpc'
|
|
14
|
+
import {
|
|
15
|
+
DIALOG_ROUND_BUTTON,
|
|
16
|
+
DetailDialogShell,
|
|
17
|
+
} from '~/modules/appCatalog/ui/catalog/DetailDialogShell'
|
|
18
|
+
import { MarkdownText } from '~/modules/appCatalog/ui/components/MarkdownText'
|
|
19
|
+
import { CommentsFeed } from '~/modules/appCatalog/ui/detail/CommentsFeed'
|
|
20
|
+
import { CommentsHelp } from '~/modules/appCatalog/ui/detail/CommentsHelp'
|
|
21
|
+
import { CommunityInput } from '~/modules/appCatalog/ui/detail/CommunityInput'
|
|
22
|
+
import type { CommunityInputHandle } from '~/modules/appCatalog/ui/detail/CommunityInput'
|
|
23
|
+
import { cn } from '~/lib/utils'
|
|
24
|
+
import { useAuth } from '~/modules/auth'
|
|
25
|
+
import { Skeleton } from '~/ui/skeleton'
|
|
26
|
+
import { CardScreenshot, formatCardDate } from './CardScreenshot'
|
|
27
|
+
import { CardTitleBlock } from './CardTitleBlock'
|
|
28
|
+
import { PersonAvatar } from './FeedRow'
|
|
29
|
+
import { useTimeUp } from '../useTimeUp'
|
|
30
|
+
import { PinButton } from './PinButton'
|
|
31
|
+
import { TagChip, tagKey, tagLabel } from './TagRow'
|
|
32
|
+
import { ArchiveConfirm, GHOST_BUTTON, ToolFormDialog } from './ToolFormDialog'
|
|
33
|
+
|
|
34
|
+
const KBD =
|
|
35
|
+
'rounded-[5px] border border-b-2 border-border bg-card px-1.5 py-px text-[11px] font-semibold text-muted-foreground'
|
|
36
|
+
|
|
37
|
+
const SECTION = 'mt-6 border-t border-border pt-[22px]'
|
|
38
|
+
|
|
39
|
+
const LABEL =
|
|
40
|
+
'text-[11.5px] font-bold tracking-wider text-muted-foreground uppercase'
|
|
41
|
+
|
|
42
|
+
const NO_CLAIMS: Claim[] = []
|
|
43
|
+
|
|
44
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
45
|
+
|
|
46
|
+
/** How often a card whose first screenshot is on its way asks again. */
|
|
47
|
+
const CAPTURE_POLL_MS = 5000
|
|
48
|
+
|
|
49
|
+
/** How long one open card waits for a first screenshot before it stops asking. */
|
|
50
|
+
const CAPTURE_WAIT_MS = 2 * 60 * 1000
|
|
51
|
+
|
|
52
|
+
export interface ToolCardProps {
|
|
53
|
+
slug: string
|
|
54
|
+
/** The list the card was opened from, in its order: ←/→ step through it. */
|
|
55
|
+
siblings: string[]
|
|
56
|
+
onClose: () => void
|
|
57
|
+
onNavigate: (slug: string) => void
|
|
58
|
+
pinned: boolean
|
|
59
|
+
/** Its pin is being saved. */
|
|
60
|
+
pinBusy?: boolean
|
|
61
|
+
onTogglePin: (tool: CommunityTool) => void
|
|
62
|
+
/** Display names of official apps by slug, for app tags. */
|
|
63
|
+
appNames: Record<string, string>
|
|
64
|
+
/** A tag chip on the card: close it and filter the feed to that tag. */
|
|
65
|
+
onChooseTag: (tag: string) => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Typing belongs to the field: the card's keys stay out of it. */
|
|
69
|
+
function isTyping(target: HTMLElement): boolean {
|
|
70
|
+
return (
|
|
71
|
+
target.isContentEditable ||
|
|
72
|
+
target.closest('input, textarea, select') !== null
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* A community tool over the Community page, in the official card's shell: no
|
|
78
|
+
* tabs, one scroll. Title and screenshot to recognise it, how to use it, who
|
|
79
|
+
* to ask, and the comments.
|
|
80
|
+
*/
|
|
81
|
+
export function ToolCard({
|
|
82
|
+
slug,
|
|
83
|
+
siblings,
|
|
84
|
+
onClose,
|
|
85
|
+
onNavigate,
|
|
86
|
+
pinned,
|
|
87
|
+
pinBusy,
|
|
88
|
+
onTogglePin,
|
|
89
|
+
appNames,
|
|
90
|
+
onChooseTag,
|
|
91
|
+
}: ToolCardProps) {
|
|
92
|
+
const { user } = useAuth()
|
|
93
|
+
const isAdmin = user?.isAdmin === true
|
|
94
|
+
const trpc = useTRPC()
|
|
95
|
+
const queryClient = useQueryClient()
|
|
96
|
+
// The feed already holds every tool, so the card paints at once from it
|
|
97
|
+
// and the tool's own query only refreshes it.
|
|
98
|
+
const list = useQuery(trpc.communityTools.list.queryOptions())
|
|
99
|
+
const fromList = list.data?.find((t) => t.slug === slug)
|
|
100
|
+
// Whatever the server says, an open card stops waiting after a while: a
|
|
101
|
+
// capture that never reports back must not keep it asking forever.
|
|
102
|
+
const gaveUpOnCapture = useTimeUp(slug, CAPTURE_WAIT_MS)
|
|
103
|
+
const get = useQuery({
|
|
104
|
+
...trpc.communityTools.get.queryOptions({ slug }),
|
|
105
|
+
placeholderData: fromList,
|
|
106
|
+
// Until the first visit lands, keep asking, so the screenshot replaces
|
|
107
|
+
// the placeholder without a reload.
|
|
108
|
+
refetchInterval: (query) =>
|
|
109
|
+
query.state.data?.capturePending && !gaveUpOnCapture
|
|
110
|
+
? CAPTURE_POLL_MS
|
|
111
|
+
: false,
|
|
112
|
+
})
|
|
113
|
+
const tool = get.data
|
|
114
|
+
|
|
115
|
+
// One view per open. The ref keeps StrictMode's second effect from counting
|
|
116
|
+
// twice; stepping to another tool is another open.
|
|
117
|
+
const [counts, setCounts] = useState<{
|
|
118
|
+
slug: string
|
|
119
|
+
views30d: number
|
|
120
|
+
viewsAll: number
|
|
121
|
+
} | null>(null)
|
|
122
|
+
// The hook's own onSuccess, not one passed to `mutate`: StrictMode's
|
|
123
|
+
// remount detaches the observer a `mutate` callback hangs on, so that one
|
|
124
|
+
// never fires and the count stayed one short.
|
|
125
|
+
const { mutate: recordView } = useMutation(
|
|
126
|
+
trpc.communityTools.recordView.mutationOptions({
|
|
127
|
+
onSuccess: (c, { slug: viewed }) => {
|
|
128
|
+
setCounts({ slug: viewed, ...c })
|
|
129
|
+
},
|
|
130
|
+
}),
|
|
131
|
+
)
|
|
132
|
+
const recordedForRef = useRef<string | null>(null)
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
if (recordedForRef.current === slug) return
|
|
135
|
+
recordedForRef.current = slug
|
|
136
|
+
recordView({ slug })
|
|
137
|
+
}, [slug, recordView])
|
|
138
|
+
const views = counts?.slug === slug ? counts : tool
|
|
139
|
+
|
|
140
|
+
const [editing, setEditing] = useState(false)
|
|
141
|
+
const [archiving, setArchiving] = useState(false)
|
|
142
|
+
const refreshTools = () =>
|
|
143
|
+
queryClient.invalidateQueries({
|
|
144
|
+
queryKey: trpc.communityTools.pathKey(),
|
|
145
|
+
})
|
|
146
|
+
const approve = useMutation(
|
|
147
|
+
trpc.communityTools.approve.mutationOptions({ onSuccess: refreshTools }),
|
|
148
|
+
)
|
|
149
|
+
const archive = useMutation(
|
|
150
|
+
trpc.communityTools.archive.mutationOptions({
|
|
151
|
+
onSuccess: () => {
|
|
152
|
+
setArchiving(false)
|
|
153
|
+
return refreshTools()
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
const adminError = approve.error ?? archive.error
|
|
158
|
+
|
|
159
|
+
const commentsRef = useRef<CommunityInputHandle>(null)
|
|
160
|
+
const commentsHeadingId = useId()
|
|
161
|
+
const commentTarget = { communityToolSlug: slug }
|
|
162
|
+
const comments = useQuery(trpc.feedback.list.queryOptions(commentTarget))
|
|
163
|
+
const commentCount = comments.data?.items.length ?? 0
|
|
164
|
+
|
|
165
|
+
const index = siblings.indexOf(slug)
|
|
166
|
+
const prev = index > 0 ? siblings[index - 1] : undefined
|
|
167
|
+
const next = index >= 0 ? siblings[index + 1] : undefined
|
|
168
|
+
const nameOf = (s: string) => list.data?.find((t) => t.slug === s)?.name ?? s
|
|
169
|
+
|
|
170
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
171
|
+
// React bubbles through portals: keys in the screenshot viewer are not ours.
|
|
172
|
+
const target = e.target as HTMLElement
|
|
173
|
+
if (!e.currentTarget.contains(target)) return
|
|
174
|
+
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
|
175
|
+
if (isTyping(target)) return
|
|
176
|
+
if (e.key === 'ArrowLeft' && prev) {
|
|
177
|
+
e.preventDefault()
|
|
178
|
+
onNavigate(prev)
|
|
179
|
+
} else if (e.key === 'ArrowRight' && next) {
|
|
180
|
+
e.preventDefault()
|
|
181
|
+
onNavigate(next)
|
|
182
|
+
} else if (e.key === 'Enter' && tool) {
|
|
183
|
+
// A link or button answers Enter itself; the title block is the link
|
|
184
|
+
// that opens the site.
|
|
185
|
+
if (target.closest('a, button')) return
|
|
186
|
+
e.preventDefault()
|
|
187
|
+
window.open(tool.url, '_blank', 'noopener,noreferrer')
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const controls = index >= 0 && siblings.length > 1 && (
|
|
192
|
+
<>
|
|
193
|
+
<span className="flex items-center gap-1 text-[13px] font-semibold text-muted-foreground">
|
|
194
|
+
<button
|
|
195
|
+
type="button"
|
|
196
|
+
aria-label={prev ? `Previous tool: ${nameOf(prev)}` : 'Previous tool'}
|
|
197
|
+
disabled={!prev}
|
|
198
|
+
onClick={() => prev && onNavigate(prev)}
|
|
199
|
+
className={cn(DIALOG_ROUND_BUTTON, 'disabled:opacity-40')}
|
|
200
|
+
>
|
|
201
|
+
<ArrowLeft className="size-4" />
|
|
202
|
+
</button>
|
|
203
|
+
<span data-testid="card-position">
|
|
204
|
+
{index + 1} of {siblings.length}
|
|
205
|
+
</span>
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
aria-label={next ? `Next tool: ${nameOf(next)}` : 'Next tool'}
|
|
209
|
+
disabled={!next}
|
|
210
|
+
onClick={() => next && onNavigate(next)}
|
|
211
|
+
className={cn(DIALOG_ROUND_BUTTON, 'disabled:opacity-40')}
|
|
212
|
+
>
|
|
213
|
+
<ArrowRight className="size-4" />
|
|
214
|
+
</button>
|
|
215
|
+
</span>
|
|
216
|
+
<i aria-hidden="true" className="mx-1 h-5 w-px bg-border" />
|
|
217
|
+
</>
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<DetailDialogShell
|
|
222
|
+
label={`${tool?.name ?? 'Community tool'} details`}
|
|
223
|
+
shownKey={slug}
|
|
224
|
+
maxWidthClass="max-w-[min(920px,94vw)]"
|
|
225
|
+
focusSelector='[data-testid="card-title-block"]'
|
|
226
|
+
controls={controls}
|
|
227
|
+
testId="tool-card"
|
|
228
|
+
onClose={onClose}
|
|
229
|
+
onKeyDown={onKeyDown}
|
|
230
|
+
>
|
|
231
|
+
{tool ? (
|
|
232
|
+
<>
|
|
233
|
+
<div className="border-b border-border pb-[22px]">
|
|
234
|
+
<div className="flex items-end justify-between gap-4 pr-44">
|
|
235
|
+
<CardTitleBlock tool={tool} />
|
|
236
|
+
<div className="-mr-40 flex shrink-0 items-center gap-2">
|
|
237
|
+
{tool.canEdit && (
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
data-testid="card-edit-button"
|
|
241
|
+
onClick={() => setEditing(true)}
|
|
242
|
+
className={cn(GHOST_BUTTON, 'h-10 px-3.5 text-[13.5px]')}
|
|
243
|
+
>
|
|
244
|
+
<Pencil aria-hidden="true" className="size-4" />
|
|
245
|
+
Edit
|
|
246
|
+
</button>
|
|
247
|
+
)}
|
|
248
|
+
<PinButton
|
|
249
|
+
labelled
|
|
250
|
+
pinned={pinned}
|
|
251
|
+
busy={pinBusy}
|
|
252
|
+
name={tool.name}
|
|
253
|
+
onToggle={() => onTogglePin(tool)}
|
|
254
|
+
/>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
<div
|
|
258
|
+
className={cn(
|
|
259
|
+
tool.lifecycle.state === 'archived' && 'opacity-60',
|
|
260
|
+
)}
|
|
261
|
+
>
|
|
262
|
+
{tool.summary && (
|
|
263
|
+
<p className="mt-3.5 text-[15px] text-foreground/80">
|
|
264
|
+
{tool.summary}
|
|
265
|
+
</p>
|
|
266
|
+
)}
|
|
267
|
+
{tool.tags.length > 0 && (
|
|
268
|
+
<div
|
|
269
|
+
data-testid="card-tags"
|
|
270
|
+
className="mt-2.5 flex flex-wrap gap-1.5"
|
|
271
|
+
>
|
|
272
|
+
{tool.tags.map((tag) => (
|
|
273
|
+
<TagChip
|
|
274
|
+
key={tagKey(tag)}
|
|
275
|
+
label={tagLabel(tag, appNames)}
|
|
276
|
+
onClick={() => onChooseTag(tagKey(tag))}
|
|
277
|
+
/>
|
|
278
|
+
))}
|
|
279
|
+
</div>
|
|
280
|
+
)}
|
|
281
|
+
{views && (
|
|
282
|
+
<p
|
|
283
|
+
data-testid="card-views"
|
|
284
|
+
className="mt-2.5 flex gap-[18px] text-[13px] font-semibold text-muted-foreground"
|
|
285
|
+
>
|
|
286
|
+
<span className="inline-flex items-center gap-1.5">
|
|
287
|
+
<Eye aria-hidden="true" className="size-[15px]" />
|
|
288
|
+
<b className="text-foreground">
|
|
289
|
+
{views.views30d.toLocaleString('en-US')}
|
|
290
|
+
</b>{' '}
|
|
291
|
+
opens this month
|
|
292
|
+
</span>
|
|
293
|
+
<span>
|
|
294
|
+
<b className="text-foreground">
|
|
295
|
+
{views.viewsAll.toLocaleString('en-US')}
|
|
296
|
+
</b>{' '}
|
|
297
|
+
all time
|
|
298
|
+
</span>
|
|
299
|
+
</p>
|
|
300
|
+
)}
|
|
301
|
+
{/* Who to ask, up here with the facts: below the screenshot
|
|
302
|
+
it fell under the fold. */}
|
|
303
|
+
<p
|
|
304
|
+
data-testid="card-people"
|
|
305
|
+
className="mt-2.5 flex flex-wrap items-center gap-x-[18px] gap-y-1.5 text-[13px] font-semibold text-muted-foreground"
|
|
306
|
+
>
|
|
307
|
+
{tool.owners.length > 0 && (
|
|
308
|
+
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-1.5">
|
|
309
|
+
<span className={LABEL}>Owners</span>
|
|
310
|
+
{tool.owners.map((owner) => (
|
|
311
|
+
<span
|
|
312
|
+
key={owner.slug ?? `named:${owner.name}`}
|
|
313
|
+
className="inline-flex items-center gap-1.5 text-foreground"
|
|
314
|
+
>
|
|
315
|
+
<PersonAvatar name={owner.name} />
|
|
316
|
+
<span data-testid="card-owner">{owner.name}</span>
|
|
317
|
+
</span>
|
|
318
|
+
))}
|
|
319
|
+
</span>
|
|
320
|
+
)}
|
|
321
|
+
<span className="inline-flex items-center gap-1.5">
|
|
322
|
+
<span className={LABEL}>Added by</span>
|
|
323
|
+
<span className="text-foreground">{tool.addedBy.name}</span>
|
|
324
|
+
<span className="font-normal">
|
|
325
|
+
{formatCardDate(tool.createdAt)}
|
|
326
|
+
</span>
|
|
327
|
+
</span>
|
|
328
|
+
</p>
|
|
329
|
+
</div>
|
|
330
|
+
<LifecycleNote
|
|
331
|
+
tool={tool}
|
|
332
|
+
onTellOwner={() => commentsRef.current?.focus()}
|
|
333
|
+
/>
|
|
334
|
+
{isAdmin && (
|
|
335
|
+
<div
|
|
336
|
+
data-testid="card-admin"
|
|
337
|
+
className="mt-3.5 flex flex-col gap-2.5 rounded-[10px] border border-dashed border-border px-3 py-2.5"
|
|
338
|
+
>
|
|
339
|
+
{archiving ? (
|
|
340
|
+
<ArchiveConfirm
|
|
341
|
+
busy={archive.isPending}
|
|
342
|
+
onCancel={() => setArchiving(false)}
|
|
343
|
+
onArchive={(reason) => archive.mutate({ slug, reason })}
|
|
344
|
+
/>
|
|
345
|
+
) : (
|
|
346
|
+
<div className="flex flex-wrap items-center gap-2.5">
|
|
347
|
+
<span className="mr-auto text-[12.5px] font-bold tracking-wider text-muted-foreground uppercase">
|
|
348
|
+
Admin
|
|
349
|
+
</span>
|
|
350
|
+
{!tool.reviewed && (
|
|
351
|
+
<button
|
|
352
|
+
type="button"
|
|
353
|
+
disabled={approve.isPending}
|
|
354
|
+
aria-busy={approve.isPending || undefined}
|
|
355
|
+
onClick={() => approve.mutate({ slug })}
|
|
356
|
+
className={GHOST_BUTTON}
|
|
357
|
+
>
|
|
358
|
+
<Check aria-hidden="true" className="size-4" />
|
|
359
|
+
{approve.isPending ? 'Approving…' : 'Approve'}
|
|
360
|
+
</button>
|
|
361
|
+
)}
|
|
362
|
+
{tool.lifecycle.state !== 'archived' && (
|
|
363
|
+
<button
|
|
364
|
+
type="button"
|
|
365
|
+
onClick={() => setArchiving(true)}
|
|
366
|
+
className={GHOST_BUTTON}
|
|
367
|
+
>
|
|
368
|
+
<Archive aria-hidden="true" className="size-4" />
|
|
369
|
+
Archive
|
|
370
|
+
</button>
|
|
371
|
+
)}
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
{adminError && (
|
|
375
|
+
<p
|
|
376
|
+
role="alert"
|
|
377
|
+
className="text-[13px] font-semibold text-destructive"
|
|
378
|
+
>
|
|
379
|
+
{adminError.message}
|
|
380
|
+
</p>
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
<CardScreenshot
|
|
387
|
+
tool={tool}
|
|
388
|
+
capturing={tool.capturePending && !gaveUpOnCapture}
|
|
389
|
+
/>
|
|
390
|
+
|
|
391
|
+
{tool.descriptionMd.trim() && (
|
|
392
|
+
<section
|
|
393
|
+
data-testid="card-how-to-use"
|
|
394
|
+
className={cn(SECTION, 'text-[14.5px]')}
|
|
395
|
+
>
|
|
396
|
+
<h3 className="mb-2 font-serif text-[17px] font-semibold">
|
|
397
|
+
How to use
|
|
398
|
+
</h3>
|
|
399
|
+
<div className="prose prose-sm dark:prose-invert max-w-[68ch]">
|
|
400
|
+
<MarkdownText>{tool.descriptionMd}</MarkdownText>
|
|
401
|
+
</div>
|
|
402
|
+
</section>
|
|
403
|
+
)}
|
|
404
|
+
|
|
405
|
+
{/* The card's own order: what was said first, the input last. The
|
|
406
|
+
official card's CommunitySection puts the input on top. */}
|
|
407
|
+
<section data-testid="card-comments" className={SECTION}>
|
|
408
|
+
<div className="comments-head">
|
|
409
|
+
<h2 id={commentsHeadingId} className="behind-group__title">
|
|
410
|
+
Comments
|
|
411
|
+
</h2>
|
|
412
|
+
{commentCount > 0 && (
|
|
413
|
+
<span
|
|
414
|
+
data-testid="card-comment-count"
|
|
415
|
+
className="rounded-full bg-muted px-2 text-xs leading-5 font-bold text-muted-foreground"
|
|
416
|
+
>
|
|
417
|
+
{commentCount}
|
|
418
|
+
</span>
|
|
419
|
+
)}
|
|
420
|
+
<CommentsHelp appName={tool.name} />
|
|
421
|
+
</div>
|
|
422
|
+
<CommentsFeed
|
|
423
|
+
target={commentTarget}
|
|
424
|
+
claims={NO_CLAIMS}
|
|
425
|
+
labelledBy={commentsHeadingId}
|
|
426
|
+
/>
|
|
427
|
+
<div className="mt-3.5">
|
|
428
|
+
<CommunityInput
|
|
429
|
+
ref={commentsRef}
|
|
430
|
+
target={commentTarget}
|
|
431
|
+
appName={tool.name}
|
|
432
|
+
/>
|
|
433
|
+
</div>
|
|
434
|
+
</section>
|
|
435
|
+
|
|
436
|
+
<p className="mt-[22px] flex flex-wrap items-center gap-x-4 gap-y-1.5 text-[12.5px] font-semibold text-muted-foreground">
|
|
437
|
+
<span className="inline-flex items-center gap-1.5">
|
|
438
|
+
<kbd className={KBD}>Esc</kbd> close
|
|
439
|
+
</span>
|
|
440
|
+
<span className="inline-flex items-center gap-1.5">
|
|
441
|
+
<kbd className={KBD}>←</kbd>
|
|
442
|
+
<kbd className={KBD}>→</kbd> previous / next tool
|
|
443
|
+
</span>
|
|
444
|
+
<span className="inline-flex items-center gap-1.5">
|
|
445
|
+
<kbd className={KBD}>Enter</kbd> open site
|
|
446
|
+
<span className="font-normal">
|
|
447
|
+
(in the comment box, Enter sends)
|
|
448
|
+
</span>
|
|
449
|
+
</span>
|
|
450
|
+
</p>
|
|
451
|
+
{editing && (
|
|
452
|
+
<ToolFormDialog
|
|
453
|
+
mode="edit"
|
|
454
|
+
initial={tool}
|
|
455
|
+
onClose={() => setEditing(false)}
|
|
456
|
+
onDone={() => setEditing(false)}
|
|
457
|
+
/>
|
|
458
|
+
)}
|
|
459
|
+
</>
|
|
460
|
+
) : get.isError ? (
|
|
461
|
+
<p className="py-10 text-center text-muted-foreground">
|
|
462
|
+
This tool is not listed any more.
|
|
463
|
+
</p>
|
|
464
|
+
) : (
|
|
465
|
+
<div aria-busy="true" className="flex flex-col gap-4">
|
|
466
|
+
<Skeleton className="h-[72px] w-2/3 rounded-[14px]" />
|
|
467
|
+
<Skeleton className="h-4 w-1/2" />
|
|
468
|
+
<Skeleton className="aspect-[16/10] w-full rounded-[14px]" />
|
|
469
|
+
</div>
|
|
470
|
+
)}
|
|
471
|
+
</DetailDialogShell>
|
|
472
|
+
)
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Why a tool that is not active is shown the way it is. */
|
|
476
|
+
function LifecycleNote({
|
|
477
|
+
tool,
|
|
478
|
+
onTellOwner,
|
|
479
|
+
}: {
|
|
480
|
+
tool: CommunityTool
|
|
481
|
+
onTellOwner: () => void
|
|
482
|
+
}) {
|
|
483
|
+
const { lifecycle } = tool
|
|
484
|
+
const [now] = useState(Date.now)
|
|
485
|
+
if (lifecycle.state === 'not-working') {
|
|
486
|
+
const since = new Date(now - lifecycle.days * DAY_MS).toISOString()
|
|
487
|
+
return (
|
|
488
|
+
<p
|
|
489
|
+
data-testid="lifecycle-note"
|
|
490
|
+
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"
|
|
491
|
+
>
|
|
492
|
+
The daily check has not reached this site since{' '}
|
|
493
|
+
{formatCardDate(since).replace(/ \d{4}$/, '')}. It may be down or moved.{' '}
|
|
494
|
+
<button
|
|
495
|
+
type="button"
|
|
496
|
+
onClick={onTellOwner}
|
|
497
|
+
className="font-bold underline underline-offset-2"
|
|
498
|
+
>
|
|
499
|
+
Tell the owner
|
|
500
|
+
</button>
|
|
501
|
+
</p>
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
if (lifecycle.state === 'archived') {
|
|
505
|
+
const on = formatCardDate(lifecycle.at)
|
|
506
|
+
return (
|
|
507
|
+
<p
|
|
508
|
+
data-testid="lifecycle-note"
|
|
509
|
+
className="mt-3 rounded-[10px] bg-muted px-3 py-[9px] text-[13.5px] text-muted-foreground"
|
|
510
|
+
>
|
|
511
|
+
{lifecycle.byName
|
|
512
|
+
? `Archived by ${lifecycle.byName} on ${on}${lifecycle.reason ? `: ${lifecycle.reason}` : ''}.`
|
|
513
|
+
: `Archived automatically on ${on} after 30 days not working.`}{' '}
|
|
514
|
+
Kept here so old links still land somewhere.
|
|
515
|
+
</p>
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
return null
|
|
519
|
+
}
|