@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,118 @@
|
|
|
1
|
+
import type { Resource } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import type { CommunityTag } from '@igstack/app-catalog-shared-core'
|
|
3
|
+
import { TAG_VALUE_MAX, normalizeTopic } from '@igstack/app-catalog-shared-core'
|
|
4
|
+
import { AppWindow, Hash } from 'lucide-react'
|
|
5
|
+
import { SuggestInput } from './OwnerPicker'
|
|
6
|
+
import { tagKey } from './TagRow'
|
|
7
|
+
|
|
8
|
+
const MAX_TAGS = 8
|
|
9
|
+
const MAX_SUGGESTIONS = 6
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* App tags, picked from the catalog's own apps so they always resolve, and
|
|
13
|
+
* topic words, suggested from the tags already in use so they converge. One
|
|
14
|
+
* box offers both. Enter adds the typed text as a topic, unless it is exactly
|
|
15
|
+
* an app's name or abbreviation, or an arrow chose a suggestion.
|
|
16
|
+
*/
|
|
17
|
+
export function TagPicker({
|
|
18
|
+
id,
|
|
19
|
+
apps,
|
|
20
|
+
knownTopics,
|
|
21
|
+
value,
|
|
22
|
+
onChange,
|
|
23
|
+
onTooLong,
|
|
24
|
+
describedBy,
|
|
25
|
+
disabled,
|
|
26
|
+
}: {
|
|
27
|
+
id: string
|
|
28
|
+
/** The official apps (no sub-resources). */
|
|
29
|
+
apps: Resource[]
|
|
30
|
+
/** Every topic some tool already carries. */
|
|
31
|
+
knownTopics: string[]
|
|
32
|
+
value: CommunityTag[]
|
|
33
|
+
onChange: (tags: CommunityTag[]) => void
|
|
34
|
+
/** A typed topic over the length cap; the form says so next to the field. */
|
|
35
|
+
onTooLong: () => void
|
|
36
|
+
describedBy?: string
|
|
37
|
+
disabled?: boolean
|
|
38
|
+
}) {
|
|
39
|
+
const appName = (slug: string) =>
|
|
40
|
+
apps.find((a) => a.slug === slug)?.displayName ?? slug
|
|
41
|
+
const has = (tag: CommunityTag) =>
|
|
42
|
+
value.some((t) => tagKey(t) === tagKey(tag))
|
|
43
|
+
const add = (tag: CommunityTag) => {
|
|
44
|
+
if (!has(tag)) onChange([...value, tag])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<SuggestInput
|
|
49
|
+
id={id}
|
|
50
|
+
chips={value.map((tag) => ({
|
|
51
|
+
key: tagKey(tag),
|
|
52
|
+
label: tag.kind === 'app' ? appName(tag.value) : tag.value,
|
|
53
|
+
icon:
|
|
54
|
+
tag.kind === 'app' ? (
|
|
55
|
+
<AppWindow aria-hidden="true" className="ml-1 size-3.5" />
|
|
56
|
+
) : (
|
|
57
|
+
<Hash aria-hidden="true" className="ml-1 size-3.5" />
|
|
58
|
+
),
|
|
59
|
+
}))}
|
|
60
|
+
onRemove={(key) => onChange(value.filter((t) => tagKey(t) !== key))}
|
|
61
|
+
placeholder={
|
|
62
|
+
value.length >= MAX_TAGS
|
|
63
|
+
? 'Eight tags at most'
|
|
64
|
+
: 'An app it is for, or a topic word'
|
|
65
|
+
}
|
|
66
|
+
atLimit={value.length >= MAX_TAGS}
|
|
67
|
+
disabled={disabled}
|
|
68
|
+
describedBy={describedBy}
|
|
69
|
+
textFirst
|
|
70
|
+
suggest={(query) => {
|
|
71
|
+
const q = query.toLowerCase()
|
|
72
|
+
const topic = normalizeTopic(query)
|
|
73
|
+
const appOptions = apps
|
|
74
|
+
.filter(
|
|
75
|
+
(a) =>
|
|
76
|
+
a.displayName.toLowerCase().includes(q) ||
|
|
77
|
+
a.abbreviation?.toLowerCase().includes(q),
|
|
78
|
+
)
|
|
79
|
+
.filter((a) => !has({ kind: 'app', value: a.slug }))
|
|
80
|
+
.slice(0, MAX_SUGGESTIONS)
|
|
81
|
+
.map((a) => ({
|
|
82
|
+
key: `app:${a.slug}`,
|
|
83
|
+
label: a.displayName,
|
|
84
|
+
sub: a.abbreviation ? `App · ${a.abbreviation}` : 'App',
|
|
85
|
+
icon: <AppWindow aria-hidden="true" className="size-4" />,
|
|
86
|
+
exact:
|
|
87
|
+
a.displayName.toLowerCase() === q ||
|
|
88
|
+
a.abbreviation?.toLowerCase() === q,
|
|
89
|
+
}))
|
|
90
|
+
const topicOptions = knownTopics
|
|
91
|
+
.filter((t) => t.includes(topic))
|
|
92
|
+
.filter((t) => !has({ kind: 'topic', value: t }))
|
|
93
|
+
.slice(0, MAX_SUGGESTIONS)
|
|
94
|
+
.map((t) => ({
|
|
95
|
+
key: `topic:${t}`,
|
|
96
|
+
label: t,
|
|
97
|
+
sub: 'Topic in use',
|
|
98
|
+
icon: <Hash aria-hidden="true" className="size-4" />,
|
|
99
|
+
}))
|
|
100
|
+
return [...appOptions, ...topicOptions]
|
|
101
|
+
}}
|
|
102
|
+
onPick={(key) => {
|
|
103
|
+
const [kind, ...rest] = key.split(':')
|
|
104
|
+
add({ kind: kind as CommunityTag['kind'], value: rest.join(':') })
|
|
105
|
+
}}
|
|
106
|
+
onEnterText={(text) => {
|
|
107
|
+
const topic = normalizeTopic(text)
|
|
108
|
+
if (!topic) return false
|
|
109
|
+
if (topic.length > TAG_VALUE_MAX) {
|
|
110
|
+
onTooLong()
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
add({ kind: 'topic', value: topic })
|
|
114
|
+
return true
|
|
115
|
+
}}
|
|
116
|
+
/>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CommunityTag,
|
|
3
|
+
CommunityTool,
|
|
4
|
+
} from '@igstack/app-catalog-shared-core'
|
|
5
|
+
import { X } from 'lucide-react'
|
|
6
|
+
import { cn } from '~/lib/utils'
|
|
7
|
+
|
|
8
|
+
/** A tag as it travels in `?tag=`: `topic:sandbox`, `app:order-service`. */
|
|
9
|
+
export function tagKey(tag: CommunityTag): string {
|
|
10
|
+
return `${tag.kind}:${tag.value}`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** An app tag reads as the app's name; a topic tag as its word. */
|
|
14
|
+
export function tagLabel(
|
|
15
|
+
tag: CommunityTag,
|
|
16
|
+
appNames: Record<string, string>,
|
|
17
|
+
): string {
|
|
18
|
+
return tag.kind === 'app' ? (appNames[tag.value] ?? tag.value) : tag.value
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const TOP_TAGS = 8
|
|
22
|
+
|
|
23
|
+
export function TagChip({
|
|
24
|
+
label,
|
|
25
|
+
active = false,
|
|
26
|
+
count,
|
|
27
|
+
onClick,
|
|
28
|
+
}: {
|
|
29
|
+
label: string
|
|
30
|
+
active?: boolean
|
|
31
|
+
count?: number
|
|
32
|
+
onClick: () => void
|
|
33
|
+
}) {
|
|
34
|
+
return (
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
data-testid="tool-tag"
|
|
38
|
+
aria-pressed={active}
|
|
39
|
+
onClick={(e) => {
|
|
40
|
+
e.stopPropagation()
|
|
41
|
+
onClick()
|
|
42
|
+
}}
|
|
43
|
+
className={cn(
|
|
44
|
+
'relative z-10 inline-flex h-6 items-center gap-1 rounded-full border px-2.5 text-xs font-semibold transition-colors',
|
|
45
|
+
'focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ring',
|
|
46
|
+
active
|
|
47
|
+
? 'border-primary/40 bg-accent text-accent-foreground'
|
|
48
|
+
: 'border-border bg-card text-muted-foreground hover:border-primary/40 hover:text-foreground',
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{label}
|
|
52
|
+
{count !== undefined && (
|
|
53
|
+
<span className="font-bold text-muted-foreground/70">{count}</span>
|
|
54
|
+
)}
|
|
55
|
+
{active && <X aria-hidden="true" className="size-3" />}
|
|
56
|
+
</button>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The most-used tags above the feed, as a way in for someone who does not know
|
|
62
|
+
* what to search for. The chosen tag stays in the row even when it is not a top
|
|
63
|
+
* one, so the filter can always be cleared where it is shown.
|
|
64
|
+
*/
|
|
65
|
+
export function TagRow({
|
|
66
|
+
tools,
|
|
67
|
+
activeTag,
|
|
68
|
+
appNames,
|
|
69
|
+
onChoose,
|
|
70
|
+
}: {
|
|
71
|
+
tools: CommunityTool[]
|
|
72
|
+
activeTag: string | undefined
|
|
73
|
+
appNames: Record<string, string>
|
|
74
|
+
onChoose: (tag: string | undefined) => void
|
|
75
|
+
}) {
|
|
76
|
+
const counts = new Map<string, { tag: CommunityTag; count: number }>()
|
|
77
|
+
for (const tool of tools) {
|
|
78
|
+
for (const tag of tool.tags) {
|
|
79
|
+
const key = tagKey(tag)
|
|
80
|
+
const entry = counts.get(key) ?? { tag, count: 0 }
|
|
81
|
+
entry.count += 1
|
|
82
|
+
counts.set(key, entry)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const ranked = [...counts.values()].sort(
|
|
86
|
+
(a, b) =>
|
|
87
|
+
b.count - a.count ||
|
|
88
|
+
tagLabel(a.tag, appNames).localeCompare(tagLabel(b.tag, appNames)),
|
|
89
|
+
)
|
|
90
|
+
const shown = ranked.slice(0, TOP_TAGS)
|
|
91
|
+
const active = activeTag ? counts.get(activeTag) : undefined
|
|
92
|
+
if (active && !shown.includes(active)) shown.push(active)
|
|
93
|
+
|
|
94
|
+
if (shown.length === 0) return null
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
data-testid="tag-row"
|
|
99
|
+
aria-label="Popular tags"
|
|
100
|
+
role="group"
|
|
101
|
+
className="mb-3 flex flex-wrap items-center gap-1.5"
|
|
102
|
+
>
|
|
103
|
+
{shown.map(({ tag, count }) => {
|
|
104
|
+
const key = tagKey(tag)
|
|
105
|
+
const on = key === activeTag
|
|
106
|
+
return (
|
|
107
|
+
<TagChip
|
|
108
|
+
key={key}
|
|
109
|
+
label={tagLabel(tag, appNames)}
|
|
110
|
+
count={count}
|
|
111
|
+
active={on}
|
|
112
|
+
onClick={() => onChoose(on ? undefined : key)}
|
|
113
|
+
/>
|
|
114
|
+
)
|
|
115
|
+
})}
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { AlertTriangle, Check, Star, X } from 'lucide-react'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
import { cn } from '~/lib/utils'
|
|
4
|
+
|
|
5
|
+
export interface ToastMessage {
|
|
6
|
+
/** Changes for every new toast, so the same text twice still restarts the timer. */
|
|
7
|
+
id: number
|
|
8
|
+
tone: 'info' | 'ok' | 'error'
|
|
9
|
+
text: React.ReactNode
|
|
10
|
+
action?: { label: string; run: () => void }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const SHOW_MS = 5000
|
|
14
|
+
|
|
15
|
+
const ICONS = { info: Star, ok: Check, error: AlertTriangle } as const
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One toast at a time, bottom-centre. It leaves after 5 s unless the pointer or
|
|
19
|
+
* focus is on it. The live region is always mounted, so the first toast is
|
|
20
|
+
* announced too; an error is an alert.
|
|
21
|
+
*/
|
|
22
|
+
export function Toast({
|
|
23
|
+
toast,
|
|
24
|
+
onDismiss,
|
|
25
|
+
}: {
|
|
26
|
+
toast: ToastMessage | null
|
|
27
|
+
onDismiss: () => void
|
|
28
|
+
}) {
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
aria-live="polite"
|
|
32
|
+
className="pointer-events-none fixed inset-x-0 bottom-6 z-50 flex justify-center px-4"
|
|
33
|
+
>
|
|
34
|
+
{toast && (
|
|
35
|
+
<ToastBody key={toast.id} toast={toast} onDismiss={onDismiss} />
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Keyed by toast id, so a new toast starts un-held with a fresh timer. */
|
|
42
|
+
function ToastBody({
|
|
43
|
+
toast,
|
|
44
|
+
onDismiss,
|
|
45
|
+
}: {
|
|
46
|
+
toast: ToastMessage
|
|
47
|
+
onDismiss: () => void
|
|
48
|
+
}) {
|
|
49
|
+
const [held, setHeld] = useState(false)
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (held) return
|
|
53
|
+
const timer = setTimeout(onDismiss, SHOW_MS)
|
|
54
|
+
return () => clearTimeout(timer)
|
|
55
|
+
}, [held, onDismiss])
|
|
56
|
+
|
|
57
|
+
const Icon = ICONS[toast.tone]
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
data-testid="toast"
|
|
62
|
+
role={toast.tone === 'error' ? 'alert' : 'status'}
|
|
63
|
+
onMouseEnter={() => setHeld(true)}
|
|
64
|
+
onMouseLeave={() => setHeld(false)}
|
|
65
|
+
onFocus={() => setHeld(true)}
|
|
66
|
+
onBlur={() => setHeld(false)}
|
|
67
|
+
className={cn(
|
|
68
|
+
'pointer-events-auto inline-flex max-w-full items-center gap-2.5 rounded-xl py-2.5 pr-2.5 pl-3.5 text-sm font-semibold shadow-lg',
|
|
69
|
+
toast.tone === 'error'
|
|
70
|
+
? 'bg-destructive text-white'
|
|
71
|
+
: 'bg-foreground text-background',
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
<Icon
|
|
75
|
+
aria-hidden="true"
|
|
76
|
+
className={cn(
|
|
77
|
+
'size-4 shrink-0',
|
|
78
|
+
toast.tone === 'info' && 'fill-amber-400 text-amber-400',
|
|
79
|
+
toast.tone === 'ok' && 'text-green-400',
|
|
80
|
+
)}
|
|
81
|
+
/>
|
|
82
|
+
<span>{toast.text}</span>
|
|
83
|
+
{toast.action && (
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={() => {
|
|
87
|
+
toast.action?.run()
|
|
88
|
+
onDismiss()
|
|
89
|
+
}}
|
|
90
|
+
className="rounded-md px-2 py-1 font-bold underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-current"
|
|
91
|
+
>
|
|
92
|
+
{toast.action.label}
|
|
93
|
+
</button>
|
|
94
|
+
)}
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
aria-label="Dismiss"
|
|
98
|
+
onClick={onDismiss}
|
|
99
|
+
className="rounded-md p-1 opacity-70 hover:opacity-100 focus-visible:outline-2 focus-visible:outline-current"
|
|
100
|
+
>
|
|
101
|
+
<X aria-hidden="true" className="size-4" />
|
|
102
|
+
</button>
|
|
103
|
+
</div>
|
|
104
|
+
)
|
|
105
|
+
}
|