@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,112 @@
|
|
|
1
|
+
import type { CommunityTool } from '@igstack/app-catalog-shared-core'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { Skeleton } from '~/ui/skeleton'
|
|
4
|
+
import { ShelfTile } from './ShelfTile'
|
|
5
|
+
|
|
6
|
+
const GRID = 'grid grid-cols-2 gap-3.5 md:grid-cols-4'
|
|
7
|
+
|
|
8
|
+
function moved<T>(items: T[], from: number, to: number): T[] {
|
|
9
|
+
const next = [...items]
|
|
10
|
+
const [item] = next.splice(from, 1)
|
|
11
|
+
next.splice(to, 0, item!)
|
|
12
|
+
return next
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Same size as the real shelf, so nothing jumps when the pins arrive. */
|
|
16
|
+
function ShelfSkeleton() {
|
|
17
|
+
return (
|
|
18
|
+
<div data-testid="shelf-skeleton" aria-busy="true" className={GRID}>
|
|
19
|
+
{[0, 1, 2, 3].map((i) => (
|
|
20
|
+
<div
|
|
21
|
+
key={i}
|
|
22
|
+
className="overflow-hidden rounded-[14px] border border-border bg-card"
|
|
23
|
+
>
|
|
24
|
+
<Skeleton className="aspect-[16/9.5] rounded-none" />
|
|
25
|
+
<div className="flex flex-col gap-1.5 px-3 py-3">
|
|
26
|
+
<Skeleton className="h-2.5 w-2/3" />
|
|
27
|
+
<Skeleton className="h-2.5 w-2/5" />
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
))}
|
|
31
|
+
</div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The visitor's pinned tools, in the order they arranged them. Native drag and
|
|
37
|
+
* drop moves a tile; `onReorder` gets the whole new order. Nothing at all
|
|
38
|
+
* shows until there is a pin: the star on every row is the way in.
|
|
39
|
+
*/
|
|
40
|
+
export function MyToolsShelf({
|
|
41
|
+
pins,
|
|
42
|
+
isLoading,
|
|
43
|
+
onUnpin,
|
|
44
|
+
onReorder,
|
|
45
|
+
}: {
|
|
46
|
+
pins: CommunityTool[]
|
|
47
|
+
isLoading: boolean
|
|
48
|
+
onUnpin: (tool: CommunityTool) => void
|
|
49
|
+
onReorder: (slugs: string[]) => void
|
|
50
|
+
}) {
|
|
51
|
+
const [dragFrom, setDragFrom] = useState<number | null>(null)
|
|
52
|
+
|
|
53
|
+
const move = (from: number, to: number) => {
|
|
54
|
+
if (to < 0 || to >= pins.length || from === to) return
|
|
55
|
+
onReorder(moved(pins, from, to).map((t) => t.slug))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!isLoading && pins.length === 0) return null
|
|
59
|
+
|
|
60
|
+
let body: React.ReactNode
|
|
61
|
+
if (isLoading) {
|
|
62
|
+
body = <ShelfSkeleton />
|
|
63
|
+
} else {
|
|
64
|
+
body = (
|
|
65
|
+
<div className={GRID}>
|
|
66
|
+
{pins.map((tool, index) => (
|
|
67
|
+
<ShelfTile
|
|
68
|
+
key={tool.slug}
|
|
69
|
+
tool={tool}
|
|
70
|
+
dragging={dragFrom === index}
|
|
71
|
+
onUnpin={() => onUnpin(tool)}
|
|
72
|
+
onMove={(step) => move(index, index + step)}
|
|
73
|
+
dragProps={{
|
|
74
|
+
onDragStart: (e) => {
|
|
75
|
+
e.dataTransfer.effectAllowed = 'move'
|
|
76
|
+
setDragFrom(index)
|
|
77
|
+
},
|
|
78
|
+
onDragOver: (e) => {
|
|
79
|
+
if (dragFrom !== null) e.preventDefault()
|
|
80
|
+
},
|
|
81
|
+
onDrop: (e) => {
|
|
82
|
+
e.preventDefault()
|
|
83
|
+
if (dragFrom !== null) move(dragFrom, index)
|
|
84
|
+
setDragFrom(null)
|
|
85
|
+
},
|
|
86
|
+
onDragEnd: () => setDragFrom(null),
|
|
87
|
+
}}
|
|
88
|
+
/>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<section aria-labelledby="my-tools-heading" data-testid="my-tools">
|
|
96
|
+
<div className="mb-3 flex items-baseline gap-3">
|
|
97
|
+
<h2
|
|
98
|
+
id="my-tools-heading"
|
|
99
|
+
className="font-serif text-[19px] font-semibold"
|
|
100
|
+
>
|
|
101
|
+
My tools
|
|
102
|
+
</h2>
|
|
103
|
+
{pins.length > 1 && (
|
|
104
|
+
<span className="text-[12.5px] font-medium text-muted-foreground">
|
|
105
|
+
Drag to reorder
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
{body}
|
|
110
|
+
</section>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import type { Person } from '@igstack/app-catalog-backend-core'
|
|
2
|
+
import type { PersonRef } from '@igstack/app-catalog-shared-core'
|
|
3
|
+
import { Search, X } from 'lucide-react'
|
|
4
|
+
import { useId, useState } from 'react'
|
|
5
|
+
import { cn } from '~/lib/utils'
|
|
6
|
+
import { highlightMatches } from '~/util/highlightMatches'
|
|
7
|
+
import { PersonAvatar } from './FeedRow'
|
|
8
|
+
|
|
9
|
+
export const FIELD_BOX =
|
|
10
|
+
'flex min-h-10 w-full items-center gap-2 rounded-[10px] border border-border bg-card px-3 text-sm transition-[box-shadow,border-color] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/30'
|
|
11
|
+
|
|
12
|
+
export const FIELD_BAD =
|
|
13
|
+
'border-destructive ring-[3px] ring-destructive/15 focus-within:border-destructive focus-within:ring-destructive/15'
|
|
14
|
+
|
|
15
|
+
export interface SuggestChip {
|
|
16
|
+
key: string
|
|
17
|
+
label: string
|
|
18
|
+
icon?: React.ReactNode
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SuggestOption {
|
|
22
|
+
key: string
|
|
23
|
+
label: string
|
|
24
|
+
sub?: string
|
|
25
|
+
icon?: React.ReactNode
|
|
26
|
+
/** The typed text is exactly this option, so Enter takes it unasked. */
|
|
27
|
+
exact?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Chips with a text box that suggests what to add. ↑↓ move through the
|
|
32
|
+
* suggestions, Enter adds one (or hands the typed text to `onEnterText`),
|
|
33
|
+
* Backspace in an empty box takes the last chip off, Esc closes the list.
|
|
34
|
+
* With `textFirst`, nothing is highlighted until an arrow moves onto a
|
|
35
|
+
* suggestion: Enter adds the typed text, or the option it exactly names.
|
|
36
|
+
*/
|
|
37
|
+
export function SuggestInput({
|
|
38
|
+
id,
|
|
39
|
+
chips,
|
|
40
|
+
onRemove,
|
|
41
|
+
placeholder,
|
|
42
|
+
suggest,
|
|
43
|
+
onPick,
|
|
44
|
+
onEnterText,
|
|
45
|
+
invalid,
|
|
46
|
+
describedBy,
|
|
47
|
+
disabled,
|
|
48
|
+
atLimit,
|
|
49
|
+
textFirst = false,
|
|
50
|
+
}: {
|
|
51
|
+
id: string
|
|
52
|
+
chips: SuggestChip[]
|
|
53
|
+
onRemove: (key: string) => void
|
|
54
|
+
placeholder: string
|
|
55
|
+
/** What to offer for the text typed so far. */
|
|
56
|
+
suggest: (query: string) => SuggestOption[]
|
|
57
|
+
onPick: (key: string) => void
|
|
58
|
+
/** Enter with no suggestion chosen; return true when the text was used. */
|
|
59
|
+
onEnterText?: (text: string) => boolean
|
|
60
|
+
invalid?: boolean
|
|
61
|
+
describedBy?: string
|
|
62
|
+
disabled?: boolean
|
|
63
|
+
/** Nothing more can be added; chips can still come off. */
|
|
64
|
+
atLimit?: boolean
|
|
65
|
+
textFirst?: boolean
|
|
66
|
+
}) {
|
|
67
|
+
const listId = useId()
|
|
68
|
+
const [query, setQuery] = useState('')
|
|
69
|
+
const [active, setActive] = useState(0)
|
|
70
|
+
const [open, setOpen] = useState(false)
|
|
71
|
+
// An arrow moved the highlight since the text last changed.
|
|
72
|
+
const [moved, setMoved] = useState(false)
|
|
73
|
+
const options = query.trim() ? suggest(query.trim()) : []
|
|
74
|
+
const shown = open && options.length > 0
|
|
75
|
+
const activeIndex =
|
|
76
|
+
textFirst && !moved ? -1 : Math.min(active, options.length - 1)
|
|
77
|
+
const optionId = (i: number) => `${listId}-${i}`
|
|
78
|
+
|
|
79
|
+
const pick = (key: string) => {
|
|
80
|
+
onPick(key)
|
|
81
|
+
setQuery('')
|
|
82
|
+
setActive(0)
|
|
83
|
+
setMoved(false)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
87
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
88
|
+
e.preventDefault()
|
|
89
|
+
// After Esc the list is shut; an arrow brings it back.
|
|
90
|
+
if (!shown) {
|
|
91
|
+
setOpen(true)
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
const step = e.key === 'ArrowDown' ? 1 : -1
|
|
95
|
+
setActive(Math.min(options.length - 1, Math.max(0, activeIndex + step)))
|
|
96
|
+
setMoved(true)
|
|
97
|
+
} else if (e.key === 'Enter') {
|
|
98
|
+
// Never submits the form: Enter here means "add this".
|
|
99
|
+
e.preventDefault()
|
|
100
|
+
const option = !shown
|
|
101
|
+
? undefined
|
|
102
|
+
: activeIndex >= 0
|
|
103
|
+
? options[activeIndex]
|
|
104
|
+
: options.find((o) => o.exact)
|
|
105
|
+
if (option) pick(option.key)
|
|
106
|
+
else if (query.trim() && onEnterText?.(query)) setQuery('')
|
|
107
|
+
} else if (e.key === 'Backspace' && !query && chips.length > 0) {
|
|
108
|
+
onRemove(chips[chips.length - 1]!.key)
|
|
109
|
+
} else if (e.key === 'Escape' && shown) {
|
|
110
|
+
// The dialog leaves an open list's Escape to it (see ToolFormDialog).
|
|
111
|
+
e.preventDefault()
|
|
112
|
+
setOpen(false)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className="relative">
|
|
118
|
+
<div
|
|
119
|
+
className={cn(
|
|
120
|
+
FIELD_BOX,
|
|
121
|
+
'flex-wrap gap-1.5 px-2 py-[5px]',
|
|
122
|
+
invalid && FIELD_BAD,
|
|
123
|
+
)}
|
|
124
|
+
>
|
|
125
|
+
{chips.map((chip) => (
|
|
126
|
+
<span
|
|
127
|
+
key={chip.key}
|
|
128
|
+
data-testid="picker-chip"
|
|
129
|
+
className="inline-flex h-7 items-center gap-1.5 rounded-full bg-secondary pr-1 pl-[3px] text-[13px] font-semibold"
|
|
130
|
+
>
|
|
131
|
+
{chip.icon}
|
|
132
|
+
<span data-testid="picker-chip-label">{chip.label}</span>
|
|
133
|
+
<button
|
|
134
|
+
type="button"
|
|
135
|
+
aria-label={`Remove ${chip.label}`}
|
|
136
|
+
disabled={disabled}
|
|
137
|
+
onClick={() => onRemove(chip.key)}
|
|
138
|
+
className="inline-flex size-5 items-center justify-center rounded-full text-muted-foreground hover:bg-background hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
|
|
139
|
+
>
|
|
140
|
+
<X aria-hidden="true" className="size-3" />
|
|
141
|
+
</button>
|
|
142
|
+
</span>
|
|
143
|
+
))}
|
|
144
|
+
<input
|
|
145
|
+
id={id}
|
|
146
|
+
type="text"
|
|
147
|
+
role="combobox"
|
|
148
|
+
autoComplete="off"
|
|
149
|
+
aria-expanded={shown}
|
|
150
|
+
aria-controls={shown ? listId : undefined}
|
|
151
|
+
aria-autocomplete="list"
|
|
152
|
+
aria-activedescendant={
|
|
153
|
+
shown && activeIndex >= 0 ? optionId(activeIndex) : undefined
|
|
154
|
+
}
|
|
155
|
+
aria-invalid={invalid || undefined}
|
|
156
|
+
aria-describedby={describedBy}
|
|
157
|
+
disabled={disabled || atLimit}
|
|
158
|
+
placeholder={placeholder}
|
|
159
|
+
value={query}
|
|
160
|
+
onChange={(e) => {
|
|
161
|
+
setQuery(e.target.value)
|
|
162
|
+
setActive(0)
|
|
163
|
+
setMoved(false)
|
|
164
|
+
setOpen(true)
|
|
165
|
+
}}
|
|
166
|
+
onFocus={() => setOpen(true)}
|
|
167
|
+
onBlur={() => setOpen(false)}
|
|
168
|
+
onKeyDown={onKeyDown}
|
|
169
|
+
className="h-7 min-w-32 flex-1 bg-transparent px-1 outline-none placeholder:text-muted-foreground"
|
|
170
|
+
/>
|
|
171
|
+
<Search
|
|
172
|
+
aria-hidden="true"
|
|
173
|
+
className="size-4 text-muted-foreground/70"
|
|
174
|
+
/>
|
|
175
|
+
</div>
|
|
176
|
+
{shown && (
|
|
177
|
+
<ul
|
|
178
|
+
id={listId}
|
|
179
|
+
role="listbox"
|
|
180
|
+
className="absolute inset-x-0 top-full z-10 mt-0.5 flex flex-col rounded-[10px] border border-border bg-card p-1 shadow-lg"
|
|
181
|
+
>
|
|
182
|
+
{options.map((option, i) => (
|
|
183
|
+
<li
|
|
184
|
+
key={option.key}
|
|
185
|
+
id={optionId(i)}
|
|
186
|
+
role="option"
|
|
187
|
+
aria-selected={i === activeIndex}
|
|
188
|
+
// Keeps the caret in the box, so the next one can be typed at once.
|
|
189
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
190
|
+
onClick={() => pick(option.key)}
|
|
191
|
+
onMouseEnter={() => setActive(i)}
|
|
192
|
+
className={cn(
|
|
193
|
+
'flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-[7px] text-[13.5px] hover:bg-accent',
|
|
194
|
+
i === activeIndex && 'bg-accent',
|
|
195
|
+
)}
|
|
196
|
+
>
|
|
197
|
+
{option.icon}
|
|
198
|
+
<span className="flex flex-col leading-tight">
|
|
199
|
+
<b className="font-semibold">
|
|
200
|
+
{highlightMatches(option.label, query.trim())}
|
|
201
|
+
</b>
|
|
202
|
+
{option.sub && (
|
|
203
|
+
<span className="text-xs text-muted-foreground">
|
|
204
|
+
{option.sub}
|
|
205
|
+
</span>
|
|
206
|
+
)}
|
|
207
|
+
</span>
|
|
208
|
+
</li>
|
|
209
|
+
))}
|
|
210
|
+
</ul>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function personName(person: Person): string {
|
|
217
|
+
return `${person.firstName} ${person.lastName}`.trim() || person.slug
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const MAX_OWNERS = 5
|
|
221
|
+
const MAX_SUGGESTIONS = 6
|
|
222
|
+
const OWNER_NAME_MAX = 80
|
|
223
|
+
/** The option that adds the typed text as an owner outside the directory. */
|
|
224
|
+
const NAMED = 'named:'
|
|
225
|
+
|
|
226
|
+
function ownerKey(owner: PersonRef): string {
|
|
227
|
+
return owner.slug ?? `${NAMED}${owner.name}`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Owners from the person directory, searched by name or email, and people it
|
|
232
|
+
* does not know, by name: chips, remove.
|
|
233
|
+
*/
|
|
234
|
+
export function OwnerPicker({
|
|
235
|
+
id,
|
|
236
|
+
persons,
|
|
237
|
+
value,
|
|
238
|
+
onChange,
|
|
239
|
+
invalid,
|
|
240
|
+
describedBy,
|
|
241
|
+
disabled,
|
|
242
|
+
}: {
|
|
243
|
+
id: string
|
|
244
|
+
persons: Person[]
|
|
245
|
+
/** In the order they were added; `slug` is null outside the directory. */
|
|
246
|
+
value: PersonRef[]
|
|
247
|
+
onChange: (owners: PersonRef[]) => void
|
|
248
|
+
invalid?: boolean
|
|
249
|
+
describedBy?: string
|
|
250
|
+
disabled?: boolean
|
|
251
|
+
}) {
|
|
252
|
+
const full = value.length >= MAX_OWNERS
|
|
253
|
+
const taken = new Set(value.map(ownerKey))
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<SuggestInput
|
|
257
|
+
id={id}
|
|
258
|
+
chips={value.map((owner) => ({
|
|
259
|
+
key: ownerKey(owner),
|
|
260
|
+
label: owner.name,
|
|
261
|
+
icon: <PersonAvatar name={owner.name} />,
|
|
262
|
+
}))}
|
|
263
|
+
onRemove={(key) => onChange(value.filter((o) => ownerKey(o) !== key))}
|
|
264
|
+
placeholder={full ? 'Five owners at most' : 'Add a person'}
|
|
265
|
+
disabled={disabled}
|
|
266
|
+
atLimit={full}
|
|
267
|
+
invalid={invalid}
|
|
268
|
+
describedBy={describedBy}
|
|
269
|
+
suggest={(query) => {
|
|
270
|
+
const words = query.toLowerCase().split(/\s+/)
|
|
271
|
+
const people = persons
|
|
272
|
+
.filter((p) => !taken.has(p.slug))
|
|
273
|
+
.filter((p) => {
|
|
274
|
+
const text = `${personName(p)} ${p.email ?? p.slug}`.toLowerCase()
|
|
275
|
+
return words.every((w) => text.includes(w))
|
|
276
|
+
})
|
|
277
|
+
.slice(0, MAX_SUGGESTIONS)
|
|
278
|
+
.map((p) => ({
|
|
279
|
+
key: p.slug,
|
|
280
|
+
label: personName(p),
|
|
281
|
+
sub: p.email ?? p.slug,
|
|
282
|
+
icon: <PersonAvatar name={personName(p)} />,
|
|
283
|
+
}))
|
|
284
|
+
// Someone the directory does not know is still someone to ask.
|
|
285
|
+
const name = query.trim().slice(0, OWNER_NAME_MAX)
|
|
286
|
+
const known = people.some(
|
|
287
|
+
(p) => p.label.toLowerCase() === name.toLowerCase(),
|
|
288
|
+
)
|
|
289
|
+
if (known || taken.has(`${NAMED}${name}`)) return people
|
|
290
|
+
return [
|
|
291
|
+
...people,
|
|
292
|
+
{
|
|
293
|
+
key: `${NAMED}${name}`,
|
|
294
|
+
label: `Add “${name}”`,
|
|
295
|
+
sub: 'Not in the people list',
|
|
296
|
+
icon: <PersonAvatar name={name} />,
|
|
297
|
+
},
|
|
298
|
+
]
|
|
299
|
+
}}
|
|
300
|
+
onPick={(key) => {
|
|
301
|
+
if (key.startsWith(NAMED)) {
|
|
302
|
+
onChange([...value, { slug: null, name: key.slice(NAMED.length) }])
|
|
303
|
+
return
|
|
304
|
+
}
|
|
305
|
+
const person = persons.find((p) => p.slug === key)
|
|
306
|
+
if (person)
|
|
307
|
+
onChange([...value, { slug: key, name: personName(person) }])
|
|
308
|
+
}}
|
|
309
|
+
/>
|
|
310
|
+
)
|
|
311
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { Loader2, Star } from 'lucide-react'
|
|
2
|
+
import { useEffect, useState } from 'react'
|
|
3
|
+
import { cn } from '~/lib/utils'
|
|
4
|
+
|
|
5
|
+
/** A save quicker than this shows no spinner at all: the star already moved. */
|
|
6
|
+
const BUSY_AFTER_MS = 400
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The star on rows, tiles and the card: pins the tool to My tools, or takes it
|
|
10
|
+
* off. The card's is `labelled`, a bordered button that says Pin or Pinned;
|
|
11
|
+
* a feed row's is `small`, a 32 px target in its meta line.
|
|
12
|
+
*/
|
|
13
|
+
export function PinButton({
|
|
14
|
+
pinned,
|
|
15
|
+
name,
|
|
16
|
+
onToggle,
|
|
17
|
+
labelled = false,
|
|
18
|
+
small = false,
|
|
19
|
+
busy = false,
|
|
20
|
+
}: {
|
|
21
|
+
pinned: boolean
|
|
22
|
+
name: string
|
|
23
|
+
onToggle: () => void
|
|
24
|
+
labelled?: boolean
|
|
25
|
+
small?: boolean
|
|
26
|
+
/** The pin is being saved; the star shows the state it will have. */
|
|
27
|
+
busy?: boolean
|
|
28
|
+
}) {
|
|
29
|
+
const [slow, setSlow] = useState(false)
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (!busy) return
|
|
32
|
+
const timer = setTimeout(() => setSlow(true), BUSY_AFTER_MS)
|
|
33
|
+
return () => {
|
|
34
|
+
clearTimeout(timer)
|
|
35
|
+
setSlow(false)
|
|
36
|
+
}
|
|
37
|
+
}, [busy])
|
|
38
|
+
const showBusy = busy && slow
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
data-testid="pin-button"
|
|
44
|
+
aria-pressed={pinned}
|
|
45
|
+
aria-busy={showBusy || undefined}
|
|
46
|
+
// One name; aria-pressed says whether it is pinned.
|
|
47
|
+
aria-label={`Pin ${name}`}
|
|
48
|
+
onClick={(e) => {
|
|
49
|
+
e.stopPropagation()
|
|
50
|
+
onToggle()
|
|
51
|
+
}}
|
|
52
|
+
className={cn(
|
|
53
|
+
'relative z-10 inline-flex shrink-0 items-center justify-center rounded-[9px] transition-colors',
|
|
54
|
+
'hover:bg-accent hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
|
55
|
+
labelled
|
|
56
|
+
? cn(
|
|
57
|
+
'h-10 gap-[7px] rounded-[10px] border px-3.5 text-[13.5px] font-bold whitespace-nowrap',
|
|
58
|
+
pinned
|
|
59
|
+
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-700 dark:bg-amber-950 dark:text-amber-300'
|
|
60
|
+
: 'border-border bg-card text-foreground',
|
|
61
|
+
)
|
|
62
|
+
: cn(
|
|
63
|
+
'text-muted-foreground',
|
|
64
|
+
small
|
|
65
|
+
? 'size-8 rounded-lg focus-visible:outline-offset-0'
|
|
66
|
+
: 'size-[34px]',
|
|
67
|
+
),
|
|
68
|
+
)}
|
|
69
|
+
>
|
|
70
|
+
{showBusy ? (
|
|
71
|
+
<Loader2
|
|
72
|
+
aria-hidden="true"
|
|
73
|
+
className={cn('animate-spin', labelled ? 'size-4' : 'size-[18px]')}
|
|
74
|
+
/>
|
|
75
|
+
) : (
|
|
76
|
+
<Star
|
|
77
|
+
aria-hidden="true"
|
|
78
|
+
className={cn(
|
|
79
|
+
labelled ? 'size-4' : small ? 'size-[15px]' : 'size-[18px]',
|
|
80
|
+
pinned && 'fill-amber-400 text-amber-600',
|
|
81
|
+
)}
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
84
|
+
{labelled && (pinned ? 'Pinned' : 'Pin')}
|
|
85
|
+
</button>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { CommunityTool } from '@igstack/app-catalog-shared-core'
|
|
2
|
+
import { ExternalLink, GripVertical } from 'lucide-react'
|
|
3
|
+
import { cn } from '~/lib/utils'
|
|
4
|
+
import { PersonAvatar, ToolThumb } from './FeedRow'
|
|
5
|
+
import { PinButton } from './PinButton'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A pinned tool: screenshot first, then name and author. A click opens the
|
|
9
|
+
* site. Drag the tile, or press Alt+↑/↓ on it, to move it.
|
|
10
|
+
*/
|
|
11
|
+
export function ShelfTile({
|
|
12
|
+
tool,
|
|
13
|
+
dragging,
|
|
14
|
+
onUnpin,
|
|
15
|
+
onMove,
|
|
16
|
+
dragProps,
|
|
17
|
+
}: {
|
|
18
|
+
tool: CommunityTool
|
|
19
|
+
dragging: boolean
|
|
20
|
+
onUnpin: () => void
|
|
21
|
+
/** -1 moves it one place earlier, +1 one place later. */
|
|
22
|
+
onMove: (step: -1 | 1) => void
|
|
23
|
+
dragProps: React.HTMLAttributes<HTMLDivElement>
|
|
24
|
+
}) {
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
data-testid="shelf-tile"
|
|
28
|
+
draggable
|
|
29
|
+
{...dragProps}
|
|
30
|
+
className={cn(
|
|
31
|
+
'group relative overflow-hidden rounded-[14px] border border-border bg-card transition-[border-color,box-shadow]',
|
|
32
|
+
'hover:border-primary/40 hover:shadow-sm focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/30',
|
|
33
|
+
dragging && 'opacity-40',
|
|
34
|
+
)}
|
|
35
|
+
>
|
|
36
|
+
<a
|
|
37
|
+
href={tool.url}
|
|
38
|
+
target="_blank"
|
|
39
|
+
rel="noopener noreferrer"
|
|
40
|
+
aria-keyshortcuts="Alt+ArrowUp Alt+ArrowDown"
|
|
41
|
+
onKeyDown={(e) => {
|
|
42
|
+
if (!e.altKey) return
|
|
43
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
44
|
+
e.preventDefault()
|
|
45
|
+
onMove(e.key === 'ArrowUp' ? -1 : 1)
|
|
46
|
+
}
|
|
47
|
+
}}
|
|
48
|
+
className="block focus-visible:outline-none"
|
|
49
|
+
>
|
|
50
|
+
<span className="relative block aspect-[16/9.5] border-b border-border">
|
|
51
|
+
<ToolThumb tool={tool} size={512} className="size-full border-0" />
|
|
52
|
+
<span className="absolute right-2.5 bottom-2.5 inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-sm font-extrabold text-primary-foreground opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
|
53
|
+
<ExternalLink aria-hidden="true" className="size-4" />
|
|
54
|
+
Open site
|
|
55
|
+
</span>
|
|
56
|
+
</span>
|
|
57
|
+
<span className="block py-2.5 pr-12 pl-3">
|
|
58
|
+
<span
|
|
59
|
+
data-testid="shelf-tile-name"
|
|
60
|
+
className="block truncate text-sm font-bold"
|
|
61
|
+
>
|
|
62
|
+
{tool.name}
|
|
63
|
+
</span>
|
|
64
|
+
<span className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[12.5px] font-medium text-muted-foreground">
|
|
65
|
+
{tool.owners[0] && <PersonAvatar name={tool.owners[0].name} />}
|
|
66
|
+
<span className="truncate">
|
|
67
|
+
{tool.owners.map((o) => o.name).join(', ')}
|
|
68
|
+
</span>
|
|
69
|
+
</span>
|
|
70
|
+
</span>
|
|
71
|
+
</a>
|
|
72
|
+
<span
|
|
73
|
+
aria-hidden="true"
|
|
74
|
+
className="absolute top-2 right-2 inline-flex size-7 cursor-grab items-center justify-center rounded-md bg-card/90 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
|
|
75
|
+
>
|
|
76
|
+
<GripVertical className="size-4" />
|
|
77
|
+
</span>
|
|
78
|
+
<div className="absolute right-2 bottom-2">
|
|
79
|
+
<PinButton pinned name={tool.name} onToggle={onUnpin} />
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
)
|
|
83
|
+
}
|