@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,111 @@
|
|
|
1
|
+
import { Search, X } from 'lucide-react'
|
|
2
|
+
import { useEffect, useRef } from 'react'
|
|
3
|
+
|
|
4
|
+
const KBD =
|
|
5
|
+
'shrink-0 rounded-[5px] border border-b-2 border-border bg-card px-1.5 py-px text-[11px] font-semibold text-muted-foreground'
|
|
6
|
+
|
|
7
|
+
function isTypingTarget(target: EventTarget | null): boolean {
|
|
8
|
+
return (
|
|
9
|
+
target instanceof HTMLElement &&
|
|
10
|
+
(target.isContentEditable ||
|
|
11
|
+
target.tagName === 'INPUT' ||
|
|
12
|
+
target.tagName === 'TEXTAREA' ||
|
|
13
|
+
target.tagName === 'SELECT')
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The community search box. It has focus when the page opens and `/` brings it
|
|
19
|
+
* back from anywhere. Esc clears it; a second Esc, on an empty box, leaves it.
|
|
20
|
+
* The arrow keys and Enter belong to the results, so they go to `onKey`.
|
|
21
|
+
*/
|
|
22
|
+
export function ToolSearch({
|
|
23
|
+
value,
|
|
24
|
+
onChange,
|
|
25
|
+
resultCount,
|
|
26
|
+
onKey,
|
|
27
|
+
resultsId,
|
|
28
|
+
activeDescendant,
|
|
29
|
+
}: {
|
|
30
|
+
value: string
|
|
31
|
+
onChange: (value: string) => void
|
|
32
|
+
resultCount: number
|
|
33
|
+
onKey: (e: React.KeyboardEvent<HTMLInputElement>) => void
|
|
34
|
+
/** The results grid, while there are results to move through. */
|
|
35
|
+
resultsId?: string
|
|
36
|
+
/** The selected result's id: the option ↑↓ move and Enter opens. */
|
|
37
|
+
activeDescendant?: string
|
|
38
|
+
}) {
|
|
39
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
40
|
+
const searching = value.trim() !== ''
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const onSlash = (e: KeyboardEvent) => {
|
|
44
|
+
if (e.key !== '/' || isTypingTarget(e.target)) return
|
|
45
|
+
e.preventDefault()
|
|
46
|
+
inputRef.current?.focus()
|
|
47
|
+
}
|
|
48
|
+
document.addEventListener('keydown', onSlash)
|
|
49
|
+
return () => document.removeEventListener('keydown', onSlash)
|
|
50
|
+
}, [])
|
|
51
|
+
|
|
52
|
+
const clear = () => {
|
|
53
|
+
onChange('')
|
|
54
|
+
inputRef.current?.focus()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
data-testid="tool-search"
|
|
60
|
+
className="flex h-[50px] min-w-0 flex-1 items-center gap-[11px] rounded-full border border-border bg-card px-4 text-muted-foreground transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-4 focus-within:ring-ring/20"
|
|
61
|
+
>
|
|
62
|
+
<Search aria-hidden="true" className="size-[19px] shrink-0" />
|
|
63
|
+
<input
|
|
64
|
+
ref={inputRef}
|
|
65
|
+
type="text"
|
|
66
|
+
value={value}
|
|
67
|
+
autoFocus
|
|
68
|
+
autoComplete="off"
|
|
69
|
+
spellCheck={false}
|
|
70
|
+
aria-label="Search community tools"
|
|
71
|
+
role="combobox"
|
|
72
|
+
aria-haspopup="grid"
|
|
73
|
+
aria-autocomplete="list"
|
|
74
|
+
aria-expanded={resultsId !== undefined}
|
|
75
|
+
aria-controls={resultsId}
|
|
76
|
+
aria-activedescendant={activeDescendant}
|
|
77
|
+
placeholder="Type anything you remember: a word, the author, part of the address"
|
|
78
|
+
onChange={(e) => onChange(e.target.value)}
|
|
79
|
+
onKeyDown={(e) => {
|
|
80
|
+
if (e.key === 'Escape') {
|
|
81
|
+
e.preventDefault()
|
|
82
|
+
e.stopPropagation()
|
|
83
|
+
if (value) onChange('')
|
|
84
|
+
else e.currentTarget.blur()
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
onKey(e)
|
|
88
|
+
}}
|
|
89
|
+
className="min-w-0 flex-1 truncate border-0 bg-transparent text-[15.5px] text-foreground outline-none placeholder:text-muted-foreground"
|
|
90
|
+
/>
|
|
91
|
+
{searching ? (
|
|
92
|
+
<>
|
|
93
|
+
<span className="shrink-0 text-[13px] font-semibold">
|
|
94
|
+
{resultCount === 1 ? '1 tool' : `${resultCount} tools`}
|
|
95
|
+
</span>
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
aria-label="Clear search"
|
|
99
|
+
onClick={clear}
|
|
100
|
+
className="shrink-0 rounded-full p-1 hover:bg-accent hover:text-foreground"
|
|
101
|
+
>
|
|
102
|
+
<X aria-hidden="true" className="size-4" />
|
|
103
|
+
</button>
|
|
104
|
+
<kbd className={KBD}>Esc</kbd>
|
|
105
|
+
</>
|
|
106
|
+
) : (
|
|
107
|
+
<kbd className={KBD}>/</kbd>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { CommunityTool } from '@igstack/app-catalog-shared-core'
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
3
|
+
import { useCallback, useMemo, useState } from 'react'
|
|
4
|
+
import { useTRPC } from '~/api/infra/trpc'
|
|
5
|
+
import { useAppCatalogContext } from '~/modules/appCatalog/context/AppCatalogContext'
|
|
6
|
+
import { useAuth } from '~/modules/auth'
|
|
7
|
+
import { withLike } from './likedBy'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Everything the community page reads, and the pin writes. Pins
|
|
11
|
+
* belong to the signed-in user, so they are not asked for until there is one.
|
|
12
|
+
*/
|
|
13
|
+
export function useCommunityTools() {
|
|
14
|
+
const trpc = useTRPC()
|
|
15
|
+
const queryClient = useQueryClient()
|
|
16
|
+
const { isAuthenticated, isLoading: isAuthLoading } = useAuth()
|
|
17
|
+
const { resources } = useAppCatalogContext()
|
|
18
|
+
|
|
19
|
+
const list = useQuery(trpc.communityTools.list.queryOptions())
|
|
20
|
+
const pins = useQuery(
|
|
21
|
+
trpc.communityTools.pins.queryOptions(undefined, {
|
|
22
|
+
enabled: isAuthenticated,
|
|
23
|
+
}),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
/** Display names of official apps by slug, for app tags. */
|
|
27
|
+
const appNames = useMemo(
|
|
28
|
+
() => Object.fromEntries(resources.map((r) => [r.slug, r.displayName])),
|
|
29
|
+
[resources],
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const pinsKey = trpc.communityTools.pins.queryKey()
|
|
33
|
+
const refreshPins = () => queryClient.invalidateQueries({ queryKey: pinsKey })
|
|
34
|
+
|
|
35
|
+
// The star and the shelf change at once; a failed save puts them back.
|
|
36
|
+
const optimisticPins = (
|
|
37
|
+
change: (old: CommunityTool[], slug: string) => CommunityTool[],
|
|
38
|
+
) => ({
|
|
39
|
+
onMutate: async ({ slug }: { slug: string }) => {
|
|
40
|
+
await queryClient.cancelQueries({ queryKey: pinsKey })
|
|
41
|
+
const previous = queryClient.getQueryData<CommunityTool[]>(pinsKey)
|
|
42
|
+
queryClient.setQueryData<CommunityTool[]>(pinsKey, (old) =>
|
|
43
|
+
old ? change(old, slug) : old,
|
|
44
|
+
)
|
|
45
|
+
return { previous }
|
|
46
|
+
},
|
|
47
|
+
onError: (
|
|
48
|
+
_error: unknown,
|
|
49
|
+
_input: { slug: string },
|
|
50
|
+
context: { previous?: CommunityTool[] } | undefined,
|
|
51
|
+
) => {
|
|
52
|
+
queryClient.setQueryData(pinsKey, context?.previous)
|
|
53
|
+
},
|
|
54
|
+
// Not awaited: the pin is saved once the server answers; the refetch
|
|
55
|
+
// behind it only confirms what the star already shows.
|
|
56
|
+
onSettled: () => {
|
|
57
|
+
void refreshPins()
|
|
58
|
+
},
|
|
59
|
+
})
|
|
60
|
+
const pin = useMutation(
|
|
61
|
+
trpc.communityTools.pin.mutationOptions(
|
|
62
|
+
optimisticPins((old, slug) => {
|
|
63
|
+
const tool = list.data?.find((t) => t.slug === slug)
|
|
64
|
+
return tool && !old.some((t) => t.slug === slug) ? [...old, tool] : old
|
|
65
|
+
}),
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
const unpin = useMutation(
|
|
69
|
+
trpc.communityTools.unpin.mutationOptions(
|
|
70
|
+
optimisticPins((old, slug) => old.filter((t) => t.slug !== slug)),
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
/** The tool whose pin is being saved, if any. */
|
|
74
|
+
const pinBusySlug = pin.isPending
|
|
75
|
+
? pin.variables.slug
|
|
76
|
+
: unpin.isPending
|
|
77
|
+
? unpin.variables.slug
|
|
78
|
+
: undefined
|
|
79
|
+
const reorder = useMutation(
|
|
80
|
+
trpc.communityTools.reorderPins.mutationOptions({
|
|
81
|
+
onMutate: ({ slugs }) => {
|
|
82
|
+
// The tile moves at once; the server catches up behind it.
|
|
83
|
+
queryClient.setQueryData<CommunityTool[]>(pinsKey, (old) =>
|
|
84
|
+
old ? slugs.flatMap((s) => old.find((t) => t.slug === s) ?? []) : old,
|
|
85
|
+
)
|
|
86
|
+
},
|
|
87
|
+
onSettled: refreshPins,
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
// My likes show at once, as an overlay on the tools as fetched. The feed
|
|
92
|
+
// ranks from the fetched tools, so a like changes its own count and never
|
|
93
|
+
// moves a row out from under the pointer. Settling marks the list stale
|
|
94
|
+
// without refetching it: it re-ranks on the next refocus or remount.
|
|
95
|
+
const [myLikes, setMyLikes] = useState<ReadonlyMap<string, boolean>>(
|
|
96
|
+
() => new Map(),
|
|
97
|
+
)
|
|
98
|
+
const setMyLike = (slug: string, liked: boolean | undefined) =>
|
|
99
|
+
setMyLikes((prev) => {
|
|
100
|
+
const next = new Map(prev)
|
|
101
|
+
if (liked === undefined) next.delete(slug)
|
|
102
|
+
else next.set(slug, liked)
|
|
103
|
+
return next
|
|
104
|
+
})
|
|
105
|
+
const likeOptions = (liked: boolean) => ({
|
|
106
|
+
onMutate: ({ slug }: { slug: string }) => setMyLike(slug, liked),
|
|
107
|
+
onError: (_error: unknown, { slug }: { slug: string }) =>
|
|
108
|
+
setMyLike(slug, undefined),
|
|
109
|
+
onSettled: () => {
|
|
110
|
+
void queryClient.invalidateQueries({
|
|
111
|
+
queryKey: trpc.communityTools.list.queryKey(),
|
|
112
|
+
refetchType: 'none',
|
|
113
|
+
})
|
|
114
|
+
void refreshPins()
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
const like = useMutation(
|
|
118
|
+
trpc.communityTools.like.mutationOptions(likeOptions(true)),
|
|
119
|
+
)
|
|
120
|
+
const unlike = useMutation(
|
|
121
|
+
trpc.communityTools.unlike.mutationOptions(likeOptions(false)),
|
|
122
|
+
)
|
|
123
|
+
/** The tool with my latest like or unlike, saved or still saving. */
|
|
124
|
+
const withMyLike = useCallback(
|
|
125
|
+
(tool: CommunityTool) => {
|
|
126
|
+
const mine = myLikes.get(tool.slug)
|
|
127
|
+
return mine === undefined ? tool : withLike(tool, mine)
|
|
128
|
+
},
|
|
129
|
+
[myLikes],
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
list,
|
|
134
|
+
pins,
|
|
135
|
+
appNames,
|
|
136
|
+
isAuthenticated,
|
|
137
|
+
isAuthLoading,
|
|
138
|
+
pin,
|
|
139
|
+
unpin,
|
|
140
|
+
pinBusySlug,
|
|
141
|
+
reorder,
|
|
142
|
+
like,
|
|
143
|
+
unlike,
|
|
144
|
+
withMyLike,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { TRPCRouter } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import { useQuery } from '@tanstack/react-query'
|
|
3
|
+
import { createTRPCOptionsProxy } from '@trpc/tanstack-react-query'
|
|
4
|
+
import { useMemo } from 'react'
|
|
5
|
+
import type { AcRouterContext } from '~/types/types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The feature switch, read with the route context's clients, for the route
|
|
9
|
+
* above the layout's providers. Same query key as the header's, so it is one
|
|
10
|
+
* request. Here and not in the route file: a deployment compiles the core's
|
|
11
|
+
* route files from its own tree, where these packages are other copies.
|
|
12
|
+
*/
|
|
13
|
+
export function useCommunityToolsConfig({
|
|
14
|
+
queryClient,
|
|
15
|
+
trpcClient,
|
|
16
|
+
}: Pick<AcRouterContext, 'queryClient' | 'trpcClient'>) {
|
|
17
|
+
const options = useMemo(
|
|
18
|
+
() =>
|
|
19
|
+
createTRPCOptionsProxy<TRPCRouter>({
|
|
20
|
+
client: trpcClient,
|
|
21
|
+
queryClient,
|
|
22
|
+
}).communityTools.config.queryOptions(),
|
|
23
|
+
[queryClient, trpcClient],
|
|
24
|
+
)
|
|
25
|
+
return useQuery(options, queryClient).data
|
|
26
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True once `ms` have passed since `key` was first shown, false until then. A
|
|
5
|
+
* new key starts the wait again. The card's cap on waiting for a screenshot.
|
|
6
|
+
*/
|
|
7
|
+
export function useTimeUp(key: string, ms: number): boolean {
|
|
8
|
+
const [upFor, setUpFor] = useState<string | null>(null)
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const timer = setTimeout(() => setUpFor(key), ms)
|
|
11
|
+
return () => clearTimeout(timer)
|
|
12
|
+
}, [key, ms])
|
|
13
|
+
return upFor === key
|
|
14
|
+
}
|
package/src/routeTree.gen.ts
CHANGED
|
@@ -11,11 +11,13 @@
|
|
|
11
11
|
import { Route as rootRouteImport } from './routes/__root'
|
|
12
12
|
import { Route as LayoutRouteImport } from './routes/_layout'
|
|
13
13
|
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
|
14
|
+
import { Route as LayoutCommunityRouteImport } from './routes/_layout/community'
|
|
14
15
|
import { Route as LayoutLoginRouteImport } from './routes/_layout/login'
|
|
15
16
|
import { Route as LayoutMcpRouteImport } from './routes/_layout/mcp'
|
|
16
17
|
import { Route as LayoutServiceDesksRouteImport } from './routes/_layout/service-desks'
|
|
17
18
|
import { Route as AuthCallbackRouteImport } from './routes/auth.callback'
|
|
18
19
|
import { Route as LayoutAppSlugRouteImport } from './routes/_layout/app.$slug'
|
|
20
|
+
import { Route as LayoutCommunitySlugRouteImport } from './routes/_layout/community.$slug'
|
|
19
21
|
import { Route as LayoutCatalogAppsIndexRouteImport } from './routes/_layout/catalog.apps.index'
|
|
20
22
|
import { Route as LayoutAppSlugSubSubSlugRouteImport } from './routes/_layout/app.$slug_.sub.$subSlug'
|
|
21
23
|
|
|
@@ -28,6 +30,11 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
|
|
28
30
|
path: '/',
|
|
29
31
|
getParentRoute: () => LayoutRoute,
|
|
30
32
|
} as any)
|
|
33
|
+
const LayoutCommunityRoute = LayoutCommunityRouteImport.update({
|
|
34
|
+
id: '/community',
|
|
35
|
+
path: '/community',
|
|
36
|
+
getParentRoute: () => LayoutRoute,
|
|
37
|
+
} as any)
|
|
31
38
|
const LayoutLoginRoute = LayoutLoginRouteImport.update({
|
|
32
39
|
id: '/login',
|
|
33
40
|
path: '/login',
|
|
@@ -53,6 +60,11 @@ const LayoutAppSlugRoute = LayoutAppSlugRouteImport.update({
|
|
|
53
60
|
path: '/app/$slug',
|
|
54
61
|
getParentRoute: () => LayoutRoute,
|
|
55
62
|
} as any)
|
|
63
|
+
const LayoutCommunitySlugRoute = LayoutCommunitySlugRouteImport.update({
|
|
64
|
+
id: '/$slug',
|
|
65
|
+
path: '/$slug',
|
|
66
|
+
getParentRoute: () => LayoutCommunityRoute,
|
|
67
|
+
} as any)
|
|
56
68
|
const LayoutCatalogAppsIndexRoute = LayoutCatalogAppsIndexRouteImport.update({
|
|
57
69
|
id: '/catalog/apps/',
|
|
58
70
|
path: '/catalog/apps/',
|
|
@@ -66,33 +78,39 @@ const LayoutAppSlugSubSubSlugRoute = LayoutAppSlugSubSubSlugRouteImport.update({
|
|
|
66
78
|
|
|
67
79
|
export interface FileRoutesByFullPath {
|
|
68
80
|
'/': typeof LayoutIndexRoute
|
|
81
|
+
'/community': typeof LayoutCommunityRouteWithChildren
|
|
69
82
|
'/login': typeof LayoutLoginRoute
|
|
70
83
|
'/mcp': typeof LayoutMcpRoute
|
|
71
84
|
'/service-desks': typeof LayoutServiceDesksRoute
|
|
72
85
|
'/auth/callback': typeof AuthCallbackRoute
|
|
73
86
|
'/app/$slug': typeof LayoutAppSlugRoute
|
|
87
|
+
'/community/$slug': typeof LayoutCommunitySlugRoute
|
|
74
88
|
'/catalog/apps/': typeof LayoutCatalogAppsIndexRoute
|
|
75
89
|
'/app/$slug/sub/$subSlug': typeof LayoutAppSlugSubSubSlugRoute
|
|
76
90
|
}
|
|
77
91
|
export interface FileRoutesByTo {
|
|
92
|
+
'/community': typeof LayoutCommunityRouteWithChildren
|
|
78
93
|
'/login': typeof LayoutLoginRoute
|
|
79
94
|
'/mcp': typeof LayoutMcpRoute
|
|
80
95
|
'/service-desks': typeof LayoutServiceDesksRoute
|
|
81
96
|
'/auth/callback': typeof AuthCallbackRoute
|
|
82
97
|
'/': typeof LayoutIndexRoute
|
|
83
98
|
'/app/$slug': typeof LayoutAppSlugRoute
|
|
99
|
+
'/community/$slug': typeof LayoutCommunitySlugRoute
|
|
84
100
|
'/catalog/apps': typeof LayoutCatalogAppsIndexRoute
|
|
85
101
|
'/app/$slug/sub/$subSlug': typeof LayoutAppSlugSubSubSlugRoute
|
|
86
102
|
}
|
|
87
103
|
export interface FileRoutesById {
|
|
88
104
|
__root__: typeof rootRouteImport
|
|
89
105
|
'/_layout': typeof LayoutRouteWithChildren
|
|
106
|
+
'/_layout/community': typeof LayoutCommunityRouteWithChildren
|
|
90
107
|
'/_layout/login': typeof LayoutLoginRoute
|
|
91
108
|
'/_layout/mcp': typeof LayoutMcpRoute
|
|
92
109
|
'/_layout/service-desks': typeof LayoutServiceDesksRoute
|
|
93
110
|
'/auth/callback': typeof AuthCallbackRoute
|
|
94
111
|
'/_layout/': typeof LayoutIndexRoute
|
|
95
112
|
'/_layout/app/$slug': typeof LayoutAppSlugRoute
|
|
113
|
+
'/_layout/community/$slug': typeof LayoutCommunitySlugRoute
|
|
96
114
|
'/_layout/catalog/apps/': typeof LayoutCatalogAppsIndexRoute
|
|
97
115
|
'/_layout/app/$slug_/sub/$subSlug': typeof LayoutAppSlugSubSubSlugRoute
|
|
98
116
|
}
|
|
@@ -100,32 +118,38 @@ export interface FileRouteTypes {
|
|
|
100
118
|
fileRoutesByFullPath: FileRoutesByFullPath
|
|
101
119
|
fullPaths:
|
|
102
120
|
| '/'
|
|
121
|
+
| '/community'
|
|
103
122
|
| '/login'
|
|
104
123
|
| '/mcp'
|
|
105
124
|
| '/service-desks'
|
|
106
125
|
| '/auth/callback'
|
|
107
126
|
| '/app/$slug'
|
|
127
|
+
| '/community/$slug'
|
|
108
128
|
| '/catalog/apps/'
|
|
109
129
|
| '/app/$slug/sub/$subSlug'
|
|
110
130
|
fileRoutesByTo: FileRoutesByTo
|
|
111
131
|
to:
|
|
132
|
+
| '/community'
|
|
112
133
|
| '/login'
|
|
113
134
|
| '/mcp'
|
|
114
135
|
| '/service-desks'
|
|
115
136
|
| '/auth/callback'
|
|
116
137
|
| '/'
|
|
117
138
|
| '/app/$slug'
|
|
139
|
+
| '/community/$slug'
|
|
118
140
|
| '/catalog/apps'
|
|
119
141
|
| '/app/$slug/sub/$subSlug'
|
|
120
142
|
id:
|
|
121
143
|
| '__root__'
|
|
122
144
|
| '/_layout'
|
|
145
|
+
| '/_layout/community'
|
|
123
146
|
| '/_layout/login'
|
|
124
147
|
| '/_layout/mcp'
|
|
125
148
|
| '/_layout/service-desks'
|
|
126
149
|
| '/auth/callback'
|
|
127
150
|
| '/_layout/'
|
|
128
151
|
| '/_layout/app/$slug'
|
|
152
|
+
| '/_layout/community/$slug'
|
|
129
153
|
| '/_layout/catalog/apps/'
|
|
130
154
|
| '/_layout/app/$slug_/sub/$subSlug'
|
|
131
155
|
fileRoutesById: FileRoutesById
|
|
@@ -151,6 +175,13 @@ declare module '@tanstack/react-router' {
|
|
|
151
175
|
preLoaderRoute: typeof LayoutIndexRouteImport
|
|
152
176
|
parentRoute: typeof LayoutRoute
|
|
153
177
|
}
|
|
178
|
+
'/_layout/community': {
|
|
179
|
+
id: '/_layout/community'
|
|
180
|
+
path: '/community'
|
|
181
|
+
fullPath: '/community'
|
|
182
|
+
preLoaderRoute: typeof LayoutCommunityRouteImport
|
|
183
|
+
parentRoute: typeof LayoutRoute
|
|
184
|
+
}
|
|
154
185
|
'/_layout/login': {
|
|
155
186
|
id: '/_layout/login'
|
|
156
187
|
path: '/login'
|
|
@@ -186,6 +217,13 @@ declare module '@tanstack/react-router' {
|
|
|
186
217
|
preLoaderRoute: typeof LayoutAppSlugRouteImport
|
|
187
218
|
parentRoute: typeof LayoutRoute
|
|
188
219
|
}
|
|
220
|
+
'/_layout/community/$slug': {
|
|
221
|
+
id: '/_layout/community/$slug'
|
|
222
|
+
path: '/$slug'
|
|
223
|
+
fullPath: '/community/$slug'
|
|
224
|
+
preLoaderRoute: typeof LayoutCommunitySlugRouteImport
|
|
225
|
+
parentRoute: typeof LayoutCommunityRoute
|
|
226
|
+
}
|
|
189
227
|
'/_layout/catalog/apps/': {
|
|
190
228
|
id: '/_layout/catalog/apps/'
|
|
191
229
|
path: '/catalog/apps'
|
|
@@ -203,7 +241,20 @@ declare module '@tanstack/react-router' {
|
|
|
203
241
|
}
|
|
204
242
|
}
|
|
205
243
|
|
|
244
|
+
interface LayoutCommunityRouteChildren {
|
|
245
|
+
LayoutCommunitySlugRoute: typeof LayoutCommunitySlugRoute
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const LayoutCommunityRouteChildren: LayoutCommunityRouteChildren = {
|
|
249
|
+
LayoutCommunitySlugRoute: LayoutCommunitySlugRoute,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const LayoutCommunityRouteWithChildren = LayoutCommunityRoute._addFileChildren(
|
|
253
|
+
LayoutCommunityRouteChildren,
|
|
254
|
+
)
|
|
255
|
+
|
|
206
256
|
interface LayoutRouteChildren {
|
|
257
|
+
LayoutCommunityRoute: typeof LayoutCommunityRouteWithChildren
|
|
207
258
|
LayoutLoginRoute: typeof LayoutLoginRoute
|
|
208
259
|
LayoutMcpRoute: typeof LayoutMcpRoute
|
|
209
260
|
LayoutServiceDesksRoute: typeof LayoutServiceDesksRoute
|
|
@@ -214,6 +265,7 @@ interface LayoutRouteChildren {
|
|
|
214
265
|
}
|
|
215
266
|
|
|
216
267
|
const LayoutRouteChildren: LayoutRouteChildren = {
|
|
268
|
+
LayoutCommunityRoute: LayoutCommunityRouteWithChildren,
|
|
217
269
|
LayoutLoginRoute: LayoutLoginRoute,
|
|
218
270
|
LayoutMcpRoute: LayoutMcpRoute,
|
|
219
271
|
LayoutServiceDesksRoute: LayoutServiceDesksRoute,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createFileRoute } from '@tanstack/react-router'
|
|
2
|
+
|
|
3
|
+
export const Route = createFileRoute('/_layout/community/$slug')({
|
|
4
|
+
// Renders nothing: the parent `/community` route owns the page, so the list
|
|
5
|
+
// stays mounted (and keeps its scroll) under the card for this slug.
|
|
6
|
+
component: () => null,
|
|
7
|
+
})
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Outlet,
|
|
3
|
+
createFileRoute,
|
|
4
|
+
stripSearchParams,
|
|
5
|
+
useParams,
|
|
6
|
+
} from '@tanstack/react-router'
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import { AppCatalogLayout } from '~/modules/appCatalog/ui/layout/AppCatalogLayout'
|
|
9
|
+
import { CommunityPage } from '~/modules/communityTools/ui/CommunityPage'
|
|
10
|
+
import { useCommunityToolsConfig } from '~/modules/communityTools/useCommunityToolsConfig'
|
|
11
|
+
import { NotFoundError } from '~/ui/error/NotFoundError'
|
|
12
|
+
import { TopLevelProvidersForErrors } from '~/ui/layout/TopLevelProvidersForErrors'
|
|
13
|
+
|
|
14
|
+
// Each defaults to absent, so a plain /community link stays plain. A value that
|
|
15
|
+
// does not parse reads as absent rather than failing the route.
|
|
16
|
+
/** The old window toggle: Newest kept its name; both view counts are Popular now. */
|
|
17
|
+
const LEGACY_WINDOW: Record<string, 'popular' | 'newest' | undefined> = {
|
|
18
|
+
newest: 'newest',
|
|
19
|
+
month: 'popular',
|
|
20
|
+
all: 'popular',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const searchSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
/** `<kind>:<value>`, e.g. `topic:sandbox` or `app:order-service`. */
|
|
26
|
+
tag: z.string().optional().catch(undefined),
|
|
27
|
+
/** Most liked when absent. */
|
|
28
|
+
sort: z.enum(['popular', 'newest', 'updated']).optional().catch(undefined),
|
|
29
|
+
/** Descending when absent. */
|
|
30
|
+
dir: z.enum(['asc']).optional().catch(undefined),
|
|
31
|
+
/** The old window toggle: `newest` still means Newest; the param is dropped. */
|
|
32
|
+
window: z.string().optional().catch(undefined),
|
|
33
|
+
})
|
|
34
|
+
.transform(({ window, ...search }) => ({
|
|
35
|
+
...search,
|
|
36
|
+
sort: search.sort ?? LEGACY_WINDOW[window ?? ''],
|
|
37
|
+
window: undefined,
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
export const Route = createFileRoute('/_layout/community')({
|
|
41
|
+
component: RouteComponent,
|
|
42
|
+
validateSearch: searchSchema,
|
|
43
|
+
search: { middlewares: [stripSearchParams(['window'])] },
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
function RouteComponent() {
|
|
47
|
+
const { queryClient, trpcClient } = Route.useRouteContext()
|
|
48
|
+
const search = Route.useSearch()
|
|
49
|
+
const navigate = Route.useNavigate()
|
|
50
|
+
// Set while a card is open: the child route's slug.
|
|
51
|
+
const { slug } = useParams({ strict: false })
|
|
52
|
+
// Read here, above the layout's providers, so a deployment with the feature
|
|
53
|
+
// off answers a typed-in /community with the usual not-found page.
|
|
54
|
+
const config = useCommunityToolsConfig({ queryClient, trpcClient })
|
|
55
|
+
|
|
56
|
+
// A tag or sort change keeps an open card open: it navigates from the
|
|
57
|
+
// current match, not from this parent route, which would drop the slug.
|
|
58
|
+
const setSearch = (patch: Partial<typeof search>) =>
|
|
59
|
+
void (slug
|
|
60
|
+
? navigate({
|
|
61
|
+
to: '/community/$slug',
|
|
62
|
+
params: { slug },
|
|
63
|
+
search: (prev) => ({ ...prev, ...patch }),
|
|
64
|
+
})
|
|
65
|
+
: navigate({
|
|
66
|
+
to: '/community',
|
|
67
|
+
search: (prev) => ({ ...prev, ...patch }),
|
|
68
|
+
}))
|
|
69
|
+
|
|
70
|
+
if (config?.enabled === false) {
|
|
71
|
+
return (
|
|
72
|
+
<TopLevelProvidersForErrors>
|
|
73
|
+
<NotFoundError />
|
|
74
|
+
</TopLevelProvidersForErrors>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<AppCatalogLayout queryClient={queryClient} trpcClient={trpcClient}>
|
|
80
|
+
<CommunityPage
|
|
81
|
+
tag={search.tag}
|
|
82
|
+
sort={search.sort ?? 'liked'}
|
|
83
|
+
dir={search.dir ?? 'desc'}
|
|
84
|
+
cardSlug={slug}
|
|
85
|
+
onTagChange={(tag) => setSearch({ tag })}
|
|
86
|
+
onSortChange={(sort, dir) =>
|
|
87
|
+
setSearch({
|
|
88
|
+
sort: sort === 'liked' ? undefined : sort,
|
|
89
|
+
dir: dir === 'asc' ? 'asc' : undefined,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
/>
|
|
93
|
+
{/* The card route renders nothing; the page above reads its slug. */}
|
|
94
|
+
<Outlet />
|
|
95
|
+
</AppCatalogLayout>
|
|
96
|
+
)
|
|
97
|
+
}
|
|
@@ -4,7 +4,7 @@ import type {
|
|
|
4
4
|
} from '@igstack/app-catalog-backend-core'
|
|
5
5
|
import { Link } from '@tanstack/react-router'
|
|
6
6
|
import { useQuery } from '@tanstack/react-query'
|
|
7
|
-
import { LogOut, X } from 'lucide-react'
|
|
7
|
+
import { LogOut, Plug, X } from 'lucide-react'
|
|
8
8
|
import React, { use } from 'react'
|
|
9
9
|
import AppCatalogLogo from '~/assets/app-catalog.svg?react'
|
|
10
10
|
import { useTRPC } from '~/api/infra/trpc'
|
|
@@ -211,11 +211,17 @@ export function Header({ middle }: HeaderProps) {
|
|
|
211
211
|
</div>
|
|
212
212
|
{middle && <div className="flex-1">{middle}</div>}
|
|
213
213
|
<div className="flex items-center gap-3 ml-auto">
|
|
214
|
+
<Button variant="outline" size="sm" asChild>
|
|
215
|
+
<Link to="/mcp">
|
|
216
|
+
<Plug />
|
|
217
|
+
MCP
|
|
218
|
+
</Link>
|
|
219
|
+
</Button>
|
|
214
220
|
<ThemeSwitcher />
|
|
215
221
|
{isLoading ? (
|
|
216
222
|
<Skeleton className="w-8 h-8 rounded-full" />
|
|
217
223
|
) : !isAuthenticated ? (
|
|
218
|
-
// The catalog is browsable without signing in;
|
|
224
|
+
// The catalog is browsable without signing in; Sign in is for the parts
|
|
219
225
|
// that need an identity (comments, admin). DEV Login is gated behind
|
|
220
226
|
// `devLoginEnabled`, so it never reaches a real deployment.
|
|
221
227
|
<div className="flex items-center gap-2">
|
|
@@ -233,7 +239,7 @@ export function Header({ middle }: HeaderProps) {
|
|
|
233
239
|
onClick={handleLoginClick}
|
|
234
240
|
className="text-sm font-medium text-muted-foreground hover:text-foreground cursor-pointer"
|
|
235
241
|
>
|
|
236
|
-
|
|
242
|
+
Sign in
|
|
237
243
|
</button>
|
|
238
244
|
</div>
|
|
239
245
|
) : (
|
|
@@ -27,3 +27,37 @@ export function highlightMatches(text: string, query: string): React.ReactNode {
|
|
|
27
27
|
</>
|
|
28
28
|
)
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/** Like `highlightMatches`, for several search words at once, in any order. */
|
|
32
|
+
export function highlightTokens(
|
|
33
|
+
text: string,
|
|
34
|
+
tokens: string[],
|
|
35
|
+
): React.ReactNode {
|
|
36
|
+
const words = tokens.filter(Boolean)
|
|
37
|
+
if (words.length === 0) return text
|
|
38
|
+
|
|
39
|
+
// Longest first, so "runbook" wins over its own prefix "run".
|
|
40
|
+
const alternation = [...words]
|
|
41
|
+
.sort((a, b) => b.length - a.length)
|
|
42
|
+
.map(escapeRegex)
|
|
43
|
+
.join('|')
|
|
44
|
+
const parts = text.split(new RegExp(`(${alternation})`, 'gi'))
|
|
45
|
+
|
|
46
|
+
let offset = 0
|
|
47
|
+
return (
|
|
48
|
+
<>
|
|
49
|
+
{parts.map((part, idx) => {
|
|
50
|
+
// An empty part shares its offset with the match after it; parity splits them.
|
|
51
|
+
const key = `${offset}-${idx % 2}`
|
|
52
|
+
offset += part.length
|
|
53
|
+
return idx % 2 === 1 ? (
|
|
54
|
+
<span key={key} className="rounded-[3px] bg-highlight">
|
|
55
|
+
{part}
|
|
56
|
+
</span>
|
|
57
|
+
) : (
|
|
58
|
+
<React.Fragment key={key}>{part}</React.Fragment>
|
|
59
|
+
)
|
|
60
|
+
})}
|
|
61
|
+
</>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Compact segmented toggle for the header (Apps | Service Desks | MCP). Rendered in
|
|
3
|
-
* the header's `middle` slot so it adds no header height. Uses router Links so
|
|
4
|
-
* each segment is deep-linkable.
|
|
5
|
-
*
|
|
6
|
-
* The active segment is derived from the current pathname rather than a plain
|
|
7
|
-
* exact-match on the Link: the "Apps" tab must stay active on the app-detail
|
|
8
|
-
* routes (`/app/<slug>`) too, since the detail panel is part of the Apps view
|
|
9
|
-
* (#23). A plain `activeOptions={{ exact: true }}` on `to="/"` left both tabs
|
|
10
|
-
* inactive on `/app/<slug>`.
|
|
11
|
-
*/
|
|
12
|
-
export declare function ViewToggle(): import("react/jsx-runtime").JSX.Element;
|