@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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CommunityInput.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/CommunityInput.tsx"],"sourcesContent":["import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { Paperclip, X } from 'lucide-react'\nimport React from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { cn } from '~/lib/utils'\nimport { Button } from '~/ui/button'\nimport {\n attachmentUrl,\n readAttachments,\n readDraft,\n writeAttachments,\n writeDraft,\n} from './communityDraft'\nimport type { PendingImage } from './communityDraft'\nimport { formatCommunityError } from './communityError'\n\nconst MAX_HEIGHT = 200\n\n/** How long the Share highlight stays on the field, ms. */\nconst FLASH_MS = 900\n\n/** Mirrors the server's cap, which refuses anything above it regardless. */\nconst MAX_ATTACHMENTS = 3\n\nconst UPLOAD_URL = '/api/feedback-attachments/upload'\n\n/** What the card holds on to, so \"Share what you know\" can point at the field. */\nexport interface CommunityInputHandle {\n /** Focuses the field and highlights it briefly. Re-triggers on every call. */\n focus: () => void\n}\n\nconst isImage = (file: File) => file.type.startsWith('image/')\n\n/**\n * Upload on pick, not on send: the person sees whether their screenshot\n * arrived while they can still do something about it, and Send stays one fast\n * request that cannot half-fail with images in flight.\n */\nasync function upload(files: File[]): Promise<string[] | string> {\n const form = new FormData()\n for (const file of files) form.append('image', file)\n try {\n const response = await fetch(UPLOAD_URL, {\n method: 'POST',\n body: form,\n // The visitor cookie is the identity the upload is recorded against, and\n // it is httpOnly — so it has to ride along explicitly.\n credentials: 'same-origin',\n })\n const payload: unknown = await response.json().catch(() => null)\n const field = (key: string): unknown =>\n typeof payload === 'object' && payload !== null\n ? (payload as Record<string, unknown>)[key]\n : undefined\n const ids = field('ids')\n if (response.ok && Array.isArray(ids)) return ids.map(String)\n const reported = field('error')\n return typeof reported === 'string'\n ? reported\n : 'Could not attach that image'\n } catch {\n return 'Could not attach that image'\n }\n}\n\n/**\n * The card's one ask for more information, at the top of Behind this card:\n * one line with Send beside it, grey until there is something to send. The\n * field grows as the text wraps. Screenshots come in through the\n * paperclip, paste or drop, and a screenshot alone is a valid note.\n */\nexport function CommunityInput({\n appSlug,\n appName,\n ref,\n}: {\n appSlug: string\n appName: string\n ref?: React.Ref<CommunityInputHandle>\n}) {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n // Seeded from storage so a draft survives the tab panel unmounting under it.\n const [text, setText] = React.useState(() => readDraft(appSlug))\n const [images, setImages] = React.useState<PendingImage[]>(() =>\n readAttachments(appSlug),\n )\n const [sent, setSent] = React.useState(false)\n // Counts successful sends, so a click on Send hands focus back every time.\n const [sends, setSends] = React.useState(0)\n const [uploading, setUploading] = React.useState(false)\n const [uploadError, setUploadError] = React.useState<string | null>(null)\n const [dragging, setDragging] = React.useState(false)\n // The guard events read, set synchronously: `uploading` only updates on the\n // next render, so a second paste or drop in the same tick would slip past it.\n const inFlightRef = React.useRef(false)\n const dragDepthRef = React.useRef(0)\n // Cleared draft stays cleared: Esc increments this; the upload checks it\n // on completion so an in-flight upload does not re-add its image.\n const draftGenRef = React.useRef(0)\n const [height, setHeight] = React.useState(40)\n const fieldRef = React.useRef<HTMLTextAreaElement>(null)\n // A hidden twin measures the height the text needs, so the real field can\n // animate to it. Setting `height: auto` on the real one snaps instead.\n const mirrorRef = React.useRef<HTMLTextAreaElement>(null)\n const fileRef = React.useRef<HTMLInputElement>(null)\n const formRef = React.useRef<HTMLFormElement>(null)\n const flashTimerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined)\n\n // Detect coarse pointer (touch) to adjust placeholder and hide keyboard hints.\n const [isCoarse, setIsCoarse] = React.useState(() => {\n if (typeof window === 'undefined') return false\n return window.matchMedia('(pointer: coarse)').matches\n })\n React.useEffect(() => {\n if (typeof window === 'undefined') return\n const mq = window.matchMedia('(pointer: coarse)')\n const handler = () => setIsCoarse(mq.matches)\n mq.addEventListener('change', handler)\n return () => mq.removeEventListener('change', handler)\n }, [])\n\n React.useEffect(() => writeDraft(appSlug, text), [appSlug, text])\n React.useEffect(() => writeAttachments(appSlug, images), [appSlug, images])\n\n // The class goes on the form, whose className React never rewrites, and\n // is removed and re-added with a reflow between so a second call restarts\n // the highlight instead of merely extending it.\n React.useImperativeHandle(ref, () => ({\n focus: () => {\n fieldRef.current?.focus()\n const form = formRef.current\n if (!form) return\n form.classList.remove('is-flash')\n void form.offsetWidth\n form.classList.add('is-flash')\n clearTimeout(flashTimerRef.current)\n flashTimerRef.current = setTimeout(\n () => form.classList.remove('is-flash'),\n FLASH_MS,\n )\n },\n }))\n React.useEffect(() => () => clearTimeout(flashTimerRef.current), [])\n\n const add = useMutation(\n trpc.feedback.add.mutationOptions({\n onSuccess: () => {\n setText('')\n setImages([])\n setUploadError(null)\n setSent(true)\n setSends((n) => n + 1)\n void queryClient.invalidateQueries({\n queryKey: trpc.feedback.list.queryKey({ resourceSlug: appSlug }),\n })\n },\n }),\n )\n\n // Focus stays in the field so the next Tab doesn't walk the grid behind the scrim.\n React.useEffect(() => {\n if (sends) fieldRef.current?.focus()\n }, [sends])\n\n const typing = text.trim() !== ''\n const canSend = typing || images.length > 0\n\n const fit = React.useCallback(() => {\n const field = fieldRef.current\n const mirror = mirrorRef.current\n if (!field || !mirror) return\n mirror.style.width = `${field.offsetWidth}px`\n mirror.value = text || ' '\n // Compute ONE_LINE from the actual field styles (varies by viewport width).\n const styles = window.getComputedStyle(field)\n const lineHeight = parseFloat(styles.lineHeight)\n const paddingTop = parseFloat(styles.paddingTop)\n const paddingBottom = parseFloat(styles.paddingBottom)\n const borderTop = parseFloat(styles.borderTopWidth)\n const borderBottom = parseFloat(styles.borderBottomWidth)\n const oneLine =\n lineHeight + paddingTop + paddingBottom + borderTop + borderBottom\n // +2: scrollHeight excludes the border, the box height includes it.\n setHeight(Math.min(Math.max(mirror.scrollHeight + 2, oneLine), MAX_HEIGHT))\n }, [text])\n React.useLayoutEffect(fit, [fit])\n React.useEffect(() => {\n const field = fieldRef.current\n if (!field || typeof ResizeObserver === 'undefined') return\n const observer = new ResizeObserver(fit)\n observer.observe(field)\n return () => observer.disconnect()\n }, [fit])\n\n /**\n * One upload at a time: a paste or drop while one is in flight is ignored,\n * and the \"Attaching…\" line says why. The updater clamps as well, so the\n * draft can never hold more than the server accepts on send.\n */\n const attach = async (picked: File[]) => {\n const files = picked.filter(isImage)\n if (files.length === 0 || inFlightRef.current) return\n const room = MAX_ATTACHMENTS - images.length\n if (room <= 0) {\n setUploadError(`Up to ${MAX_ATTACHMENTS} images`)\n return\n }\n setUploadError(null)\n inFlightRef.current = true\n setUploading(true)\n const startGen = draftGenRef.current\n const result = await upload(files.slice(0, room))\n inFlightRef.current = false\n setUploading(false)\n // Draft was cleared (Esc) while upload was in flight: stay cleared.\n if (draftGenRef.current !== startGen) return\n if (typeof result === 'string') {\n setUploadError(result)\n return\n }\n const newImages = result.map((id, i) => ({\n id,\n name: files[i]?.name ?? 'image',\n }))\n // Merge into both state and persisted draft so unmount restores it.\n setImages((current) => {\n const updated = [...current, ...newImages].slice(0, MAX_ATTACHMENTS)\n writeAttachments(appSlug, updated)\n return updated\n })\n if (files.length > room)\n setUploadError(`Only the first ${room} were attached`)\n }\n\n /** Only a drag carrying files is ours; dragged text still drops into the field. */\n const carriesFiles = (e: React.DragEvent) =>\n [...e.dataTransfer.types].includes('Files')\n\n const submit = () => {\n if (!canSend || add.isPending || uploading) return\n const body = text.trim()\n add.mutate({\n resourceSlug: appSlug,\n ...(body ? { body } : {}),\n ...(images.length > 0 ? { attachmentIds: images.map((i) => i.id) } : {}),\n })\n }\n\n return (\n <form\n ref={formRef}\n // State rides on data attributes: the className stays static so the\n // imperative `is-flash` below survives a re-render.\n className=\"know-more\"\n data-drop={dragging || undefined}\n onSubmit={(e) => {\n e.preventDefault()\n submit()\n }}\n onDragEnter={(e) => {\n if (!carriesFiles(e)) return\n dragDepthRef.current += 1\n setDragging(true)\n }}\n onDragOver={(e) => {\n // Accepting the drop is what lets it fire.\n if (carriesFiles(e)) e.preventDefault()\n }}\n onDragLeave={(e) => {\n if (!carriesFiles(e)) return\n // Every child the drag crosses fires its own enter and leave, so only\n // the last leave means the drag left the form.\n dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)\n if (dragDepthRef.current === 0) setDragging(false)\n }}\n onDrop={(e) => {\n if (!carriesFiles(e)) return\n e.preventDefault()\n dragDepthRef.current = 0\n setDragging(false)\n void attach([...e.dataTransfer.files])\n }}\n >\n {images.length > 0 && (\n <ul className=\"know-more-thumbs\">\n {images.map((image) => (\n <li key={image.id} className=\"know-more-thumb\">\n <img src={attachmentUrl(image.id)} alt={image.name} />\n <button\n type=\"button\"\n aria-label={`Remove ${image.name}`}\n onClick={() =>\n setImages((current) =>\n current.filter((i) => i.id !== image.id),\n )\n }\n >\n <X className=\"size-2.5\" />\n </button>\n </li>\n ))}\n </ul>\n )}\n <div className=\"know-more-field\">\n <button\n type=\"button\"\n className=\"know-more-clip\"\n aria-label=\"Attach a screenshot\"\n title=\"Attach a screenshot\"\n aria-busy={uploading || undefined}\n disabled={uploading || images.length >= MAX_ATTACHMENTS}\n onClick={() => fileRef.current?.click()}\n >\n <Paperclip className=\"size-4\" />\n </button>\n <input\n ref={fileRef}\n type=\"file\"\n accept=\"image/*\"\n multiple\n tabIndex={-1}\n aria-hidden\n className=\"sr-only\"\n onChange={(e) => {\n void attach([...(e.target.files ?? [])])\n // Cleared so picking the SAME file again still fires a change.\n e.target.value = ''\n }}\n />\n <textarea\n ref={fieldRef}\n rows={1}\n value={text}\n aria-label={`Know more about ${appName}?`}\n placeholder={\n isCoarse\n ? 'Add a fact, a fix or a question'\n : 'Add a fact, a fix or a question — press Enter'\n }\n style={{\n height,\n overflowY: height >= MAX_HEIGHT ? 'auto' : 'hidden',\n }}\n onChange={(e) => {\n setText(e.target.value)\n setSent(false)\n if (add.isError) add.reset()\n }}\n onPaste={(e) => {\n const files = [...e.clipboardData.files].filter(isImage)\n if (files.length === 0) return\n e.preventDefault()\n void attach(files)\n }}\n onKeyDown={(e) => {\n if (\n e.key === 'Enter' &&\n !e.shiftKey &&\n !e.nativeEvent.isComposing\n ) {\n e.preventDefault()\n submit()\n }\n // Esc clears a draft, and only a draft: with nothing typed or\n // attached it must reach the card's layer so the card still closes.\n if (\n e.key === 'Escape' &&\n !e.nativeEvent.isComposing &&\n (text !== '' || images.length > 0)\n ) {\n e.preventDefault()\n e.stopPropagation()\n draftGenRef.current += 1\n setText('')\n setImages([])\n }\n }}\n />\n <Button\n type=\"submit\"\n size=\"sm\"\n className=\"know-more-send\"\n disabled={!canSend || add.isPending || uploading}\n >\n Send\n </Button>\n <textarea\n ref={mirrorRef}\n rows={1}\n readOnly\n tabIndex={-1}\n aria-hidden\n className=\"know-more-mirror\"\n />\n </div>\n {!isCoarse && (\n <p className={cn('know-more-hint', typing && 'is-on')}>\n Enter sends · Shift+Enter new line · Esc clears · paste or drop a\n screenshot\n </p>\n )}\n <p className=\"know-more-attaching\" aria-live=\"polite\">\n {uploading ? 'Attaching…' : ''}\n </p>\n {(add.isError || uploadError) && (\n <p role=\"alert\" className=\"know-more-error\">\n {add.isError\n ? `Could not send: ${formatCommunityError(add.error)}. Your note is still here.`\n : uploadError}\n </p>\n )}\n <p role=\"status\" className={cn('know-more-sent', sent && 'is-on')}>\n {sent ? 'Thanks — sent. It shows below.' : ''}\n </p>\n </form>\n )\n}\n"],"names":["React"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"CommunityInput.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/CommunityInput.tsx"],"sourcesContent":["import { useMutation, useQueryClient } from '@tanstack/react-query'\nimport { Paperclip, X } from 'lucide-react'\nimport React from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { cn } from '~/lib/utils'\nimport { Button } from '~/ui/button'\nimport {\n attachmentUrl,\n draftSlugOf,\n readAttachments,\n readDraft,\n writeAttachments,\n writeDraft,\n} from './communityDraft'\nimport type { CommentTarget, PendingImage } from './communityDraft'\nimport { formatCommunityError } from './communityError'\n\nconst MAX_HEIGHT = 200\n\n/** How long the Share highlight stays on the field, ms. */\nconst FLASH_MS = 900\n\n/** Mirrors the server's cap, which refuses anything above it regardless. */\nconst MAX_ATTACHMENTS = 3\n\nconst UPLOAD_URL = '/api/feedback-attachments/upload'\n\n/** What the card holds on to, so \"Share what you know\" can point at the field. */\nexport interface CommunityInputHandle {\n /** Focuses the field and highlights it briefly. Re-triggers on every call. */\n focus: () => void\n}\n\nconst isImage = (file: File) => file.type.startsWith('image/')\n\n/**\n * Upload on pick, not on send: the person sees whether their screenshot\n * arrived while they can still do something about it, and Send stays one fast\n * request that cannot half-fail with images in flight.\n */\nasync function upload(files: File[]): Promise<string[] | string> {\n const form = new FormData()\n for (const file of files) form.append('image', file)\n try {\n const response = await fetch(UPLOAD_URL, {\n method: 'POST',\n body: form,\n // The visitor cookie is the identity the upload is recorded against, and\n // it is httpOnly — so it has to ride along explicitly.\n credentials: 'same-origin',\n })\n const payload: unknown = await response.json().catch(() => null)\n const field = (key: string): unknown =>\n typeof payload === 'object' && payload !== null\n ? (payload as Record<string, unknown>)[key]\n : undefined\n const ids = field('ids')\n if (response.ok && Array.isArray(ids)) return ids.map(String)\n const reported = field('error')\n return typeof reported === 'string'\n ? reported\n : 'Could not attach that image'\n } catch {\n return 'Could not attach that image'\n }\n}\n\n/**\n * The card's one ask for more information, at the top of Behind this card:\n * one line with Send beside it, grey until there is something to send. The\n * field grows as the text wraps. Screenshots come in through the\n * paperclip, paste or drop, and a screenshot alone is a valid note.\n */\nexport function CommunityInput({\n target,\n appName,\n ref,\n}: {\n target: CommentTarget\n appName: string\n ref?: React.Ref<CommunityInputHandle>\n}) {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n const draftSlug = draftSlugOf(target)\n // Seeded from storage so a draft survives the tab panel unmounting under it.\n const [text, setText] = React.useState(() => readDraft(draftSlug))\n const [images, setImages] = React.useState<PendingImage[]>(() =>\n readAttachments(draftSlug),\n )\n const [sent, setSent] = React.useState(false)\n // Counts successful sends, so a click on Send hands focus back every time.\n const [sends, setSends] = React.useState(0)\n const [uploading, setUploading] = React.useState(false)\n const [uploadError, setUploadError] = React.useState<string | null>(null)\n const [dragging, setDragging] = React.useState(false)\n // The guard events read, set synchronously: `uploading` only updates on the\n // next render, so a second paste or drop in the same tick would slip past it.\n const inFlightRef = React.useRef(false)\n const dragDepthRef = React.useRef(0)\n // Cleared draft stays cleared: Esc increments this; the upload checks it\n // on completion so an in-flight upload does not re-add its image.\n const draftGenRef = React.useRef(0)\n const [height, setHeight] = React.useState(40)\n const fieldRef = React.useRef<HTMLTextAreaElement>(null)\n // A hidden twin measures the height the text needs, so the real field can\n // animate to it. Setting `height: auto` on the real one snaps instead.\n const mirrorRef = React.useRef<HTMLTextAreaElement>(null)\n const fileRef = React.useRef<HTMLInputElement>(null)\n const formRef = React.useRef<HTMLFormElement>(null)\n const flashTimerRef = React.useRef<ReturnType<typeof setTimeout>>(undefined)\n\n // Detect coarse pointer (touch) to adjust placeholder and hide keyboard hints.\n const [isCoarse, setIsCoarse] = React.useState(() => {\n if (typeof window === 'undefined') return false\n return window.matchMedia('(pointer: coarse)').matches\n })\n React.useEffect(() => {\n if (typeof window === 'undefined') return\n const mq = window.matchMedia('(pointer: coarse)')\n const handler = () => setIsCoarse(mq.matches)\n mq.addEventListener('change', handler)\n return () => mq.removeEventListener('change', handler)\n }, [])\n\n React.useEffect(() => writeDraft(draftSlug, text), [draftSlug, text])\n React.useEffect(\n () => writeAttachments(draftSlug, images),\n [draftSlug, images],\n )\n\n // The class goes on the form, whose className React never rewrites, and\n // is removed and re-added with a reflow between so a second call restarts\n // the highlight instead of merely extending it.\n React.useImperativeHandle(ref, () => ({\n focus: () => {\n fieldRef.current?.focus()\n const form = formRef.current\n if (!form) return\n form.classList.remove('is-flash')\n void form.offsetWidth\n form.classList.add('is-flash')\n clearTimeout(flashTimerRef.current)\n flashTimerRef.current = setTimeout(\n () => form.classList.remove('is-flash'),\n FLASH_MS,\n )\n },\n }))\n React.useEffect(() => () => clearTimeout(flashTimerRef.current), [])\n\n const add = useMutation(\n trpc.feedback.add.mutationOptions({\n onSuccess: () => {\n setText('')\n setImages([])\n setUploadError(null)\n setSent(true)\n setSends((n) => n + 1)\n void queryClient.invalidateQueries({\n queryKey: trpc.feedback.list.queryKey(target),\n })\n },\n }),\n )\n\n // Focus stays in the field so the next Tab doesn't walk the grid behind the scrim.\n React.useEffect(() => {\n if (sends) fieldRef.current?.focus()\n }, [sends])\n\n const typing = text.trim() !== ''\n const canSend = typing || images.length > 0\n\n const fit = React.useCallback(() => {\n const field = fieldRef.current\n const mirror = mirrorRef.current\n if (!field || !mirror) return\n mirror.style.width = `${field.offsetWidth}px`\n mirror.value = text || ' '\n // Compute ONE_LINE from the actual field styles (varies by viewport width).\n const styles = window.getComputedStyle(field)\n const lineHeight = parseFloat(styles.lineHeight)\n const paddingTop = parseFloat(styles.paddingTop)\n const paddingBottom = parseFloat(styles.paddingBottom)\n const borderTop = parseFloat(styles.borderTopWidth)\n const borderBottom = parseFloat(styles.borderBottomWidth)\n const oneLine =\n lineHeight + paddingTop + paddingBottom + borderTop + borderBottom\n // +2: scrollHeight excludes the border, the box height includes it.\n setHeight(Math.min(Math.max(mirror.scrollHeight + 2, oneLine), MAX_HEIGHT))\n }, [text])\n React.useLayoutEffect(fit, [fit])\n React.useEffect(() => {\n const field = fieldRef.current\n if (!field || typeof ResizeObserver === 'undefined') return\n const observer = new ResizeObserver(fit)\n observer.observe(field)\n return () => observer.disconnect()\n }, [fit])\n\n /**\n * One upload at a time: a paste or drop while one is in flight is ignored,\n * and the \"Attaching…\" line says why. The updater clamps as well, so the\n * draft can never hold more than the server accepts on send.\n */\n const attach = async (picked: File[]) => {\n const files = picked.filter(isImage)\n if (files.length === 0 || inFlightRef.current) return\n const room = MAX_ATTACHMENTS - images.length\n if (room <= 0) {\n setUploadError(`Up to ${MAX_ATTACHMENTS} images`)\n return\n }\n setUploadError(null)\n inFlightRef.current = true\n setUploading(true)\n const startGen = draftGenRef.current\n const result = await upload(files.slice(0, room))\n inFlightRef.current = false\n setUploading(false)\n // Draft was cleared (Esc) while upload was in flight: stay cleared.\n if (draftGenRef.current !== startGen) return\n if (typeof result === 'string') {\n setUploadError(result)\n return\n }\n const newImages = result.map((id, i) => ({\n id,\n name: files[i]?.name ?? 'image',\n }))\n // Merge into both state and persisted draft so unmount restores it.\n setImages((current) => {\n const updated = [...current, ...newImages].slice(0, MAX_ATTACHMENTS)\n writeAttachments(draftSlug, updated)\n return updated\n })\n if (files.length > room)\n setUploadError(`Only the first ${room} were attached`)\n }\n\n /** Only a drag carrying files is ours; dragged text still drops into the field. */\n const carriesFiles = (e: React.DragEvent) =>\n [...e.dataTransfer.types].includes('Files')\n\n const submit = () => {\n if (!canSend || add.isPending || uploading) return\n const body = text.trim()\n add.mutate({\n ...target,\n ...(body ? { body } : {}),\n ...(images.length > 0 ? { attachmentIds: images.map((i) => i.id) } : {}),\n })\n }\n\n return (\n <form\n ref={formRef}\n // State rides on data attributes: the className stays static so the\n // imperative `is-flash` below survives a re-render.\n className=\"know-more\"\n data-drop={dragging || undefined}\n onSubmit={(e) => {\n e.preventDefault()\n submit()\n }}\n onDragEnter={(e) => {\n if (!carriesFiles(e)) return\n dragDepthRef.current += 1\n setDragging(true)\n }}\n onDragOver={(e) => {\n // Accepting the drop is what lets it fire.\n if (carriesFiles(e)) e.preventDefault()\n }}\n onDragLeave={(e) => {\n if (!carriesFiles(e)) return\n // Every child the drag crosses fires its own enter and leave, so only\n // the last leave means the drag left the form.\n dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)\n if (dragDepthRef.current === 0) setDragging(false)\n }}\n onDrop={(e) => {\n if (!carriesFiles(e)) return\n e.preventDefault()\n dragDepthRef.current = 0\n setDragging(false)\n void attach([...e.dataTransfer.files])\n }}\n >\n {images.length > 0 && (\n <ul className=\"know-more-thumbs\">\n {images.map((image) => (\n <li key={image.id} className=\"know-more-thumb\">\n <img src={attachmentUrl(image.id)} alt={image.name} />\n <button\n type=\"button\"\n aria-label={`Remove ${image.name}`}\n onClick={() =>\n setImages((current) =>\n current.filter((i) => i.id !== image.id),\n )\n }\n >\n <X className=\"size-2.5\" />\n </button>\n </li>\n ))}\n </ul>\n )}\n <div className=\"know-more-field\">\n <button\n type=\"button\"\n className=\"know-more-clip\"\n aria-label=\"Attach a screenshot\"\n title=\"Attach a screenshot\"\n aria-busy={uploading || undefined}\n disabled={uploading || images.length >= MAX_ATTACHMENTS}\n onClick={() => fileRef.current?.click()}\n >\n <Paperclip className=\"size-4\" />\n </button>\n <input\n ref={fileRef}\n type=\"file\"\n accept=\"image/*\"\n multiple\n tabIndex={-1}\n aria-hidden\n className=\"sr-only\"\n onChange={(e) => {\n void attach([...(e.target.files ?? [])])\n // Cleared so picking the SAME file again still fires a change.\n e.target.value = ''\n }}\n />\n <textarea\n ref={fieldRef}\n rows={1}\n value={text}\n aria-label={`Know more about ${appName}?`}\n placeholder={\n isCoarse\n ? 'Add a fact, a fix or a question'\n : 'Add a fact, a fix or a question — press Enter'\n }\n style={{\n height,\n overflowY: height >= MAX_HEIGHT ? 'auto' : 'hidden',\n }}\n onChange={(e) => {\n setText(e.target.value)\n setSent(false)\n if (add.isError) add.reset()\n }}\n onPaste={(e) => {\n const files = [...e.clipboardData.files].filter(isImage)\n if (files.length === 0) return\n e.preventDefault()\n void attach(files)\n }}\n onKeyDown={(e) => {\n if (\n e.key === 'Enter' &&\n !e.shiftKey &&\n !e.nativeEvent.isComposing\n ) {\n e.preventDefault()\n submit()\n }\n // Esc clears a draft, and only a draft: with nothing typed or\n // attached it must reach the card's layer so the card still closes.\n if (\n e.key === 'Escape' &&\n !e.nativeEvent.isComposing &&\n (text !== '' || images.length > 0)\n ) {\n e.preventDefault()\n e.stopPropagation()\n draftGenRef.current += 1\n setText('')\n setImages([])\n }\n }}\n />\n <Button\n type=\"submit\"\n size=\"sm\"\n className=\"know-more-send\"\n disabled={!canSend || add.isPending || uploading}\n >\n Send\n </Button>\n <textarea\n ref={mirrorRef}\n rows={1}\n readOnly\n tabIndex={-1}\n aria-hidden\n className=\"know-more-mirror\"\n />\n </div>\n {!isCoarse && (\n <p className={cn('know-more-hint', typing && 'is-on')}>\n Enter sends · Shift+Enter new line · Esc clears · paste or drop a\n screenshot\n </p>\n )}\n <p className=\"know-more-attaching\" aria-live=\"polite\">\n {uploading ? 'Attaching…' : ''}\n </p>\n {(add.isError || uploadError) && (\n <p role=\"alert\" className=\"know-more-error\">\n {add.isError\n ? `Could not send: ${formatCommunityError(add.error)}. Your note is still here.`\n : uploadError}\n </p>\n )}\n <p role=\"status\" className={cn('know-more-sent', sent && 'is-on')}>\n {sent ? 'Thanks — sent. It shows below.' : ''}\n </p>\n </form>\n )\n}\n"],"names":["React"],"mappings":"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"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Claim } from '@igstack/app-catalog-backend-core';
|
|
2
2
|
import { default as React } from 'react';
|
|
3
|
+
import { CommentTarget } from './communityDraft.js';
|
|
3
4
|
import { CommunityInputHandle } from './CommunityInput.js';
|
|
4
5
|
export type CommunitySectionHandle = CommunityInputHandle;
|
|
5
6
|
/**
|
|
@@ -7,8 +8,9 @@ export type CommunitySectionHandle = CommunityInputHandle;
|
|
|
7
8
|
* then "Comments" with its help, then everything already said about the entry
|
|
8
9
|
* as one feed.
|
|
9
10
|
*/
|
|
10
|
-
export declare function CommunitySection({
|
|
11
|
-
|
|
11
|
+
export declare function CommunitySection({ target, appName, claims, ref, }: {
|
|
12
|
+
target: CommentTarget;
|
|
13
|
+
/** The entry's or the tool's name, for the input's label. */
|
|
12
14
|
appName: string;
|
|
13
15
|
claims?: Claim[];
|
|
14
16
|
ref?: React.Ref<CommunitySectionHandle>;
|
|
@@ -5,19 +5,25 @@ import { CommentsHelp } from "./CommentsHelp.js";
|
|
|
5
5
|
import { CommunityInput } from "./CommunityInput.js";
|
|
6
6
|
const NO_CLAIMS = [];
|
|
7
7
|
function CommunitySection({
|
|
8
|
-
|
|
8
|
+
target,
|
|
9
9
|
appName,
|
|
10
10
|
claims = NO_CLAIMS,
|
|
11
11
|
ref
|
|
12
12
|
}) {
|
|
13
13
|
const headingId = React__default.useId();
|
|
14
14
|
return /* @__PURE__ */ jsxs("section", { className: "behind-group", children: [
|
|
15
|
-
/* @__PURE__ */ jsx(CommunityInput, { ref,
|
|
15
|
+
/* @__PURE__ */ jsx(CommunityInput, { ref, target, appName }),
|
|
16
16
|
/* @__PURE__ */ jsxs("div", { className: "comments-head", children: [
|
|
17
17
|
/* @__PURE__ */ jsx("h2", { id: headingId, className: "behind-group__title", children: "Comments" }),
|
|
18
|
-
/* @__PURE__ */ jsx(
|
|
18
|
+
/* @__PURE__ */ jsx(
|
|
19
|
+
CommentsHelp,
|
|
20
|
+
{
|
|
21
|
+
appSlug: "resourceSlug" in target ? target.resourceSlug : void 0,
|
|
22
|
+
appName
|
|
23
|
+
}
|
|
24
|
+
)
|
|
19
25
|
] }),
|
|
20
|
-
/* @__PURE__ */ jsx(CommentsFeed, {
|
|
26
|
+
/* @__PURE__ */ jsx(CommentsFeed, { target, claims, labelledBy: headingId })
|
|
21
27
|
] });
|
|
22
28
|
}
|
|
23
29
|
export {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CommunitySection.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/CommunitySection.tsx"],"sourcesContent":["import type { Claim } from '@igstack/app-catalog-backend-core'\nimport React from 'react'\nimport { CommentsFeed } from './CommentsFeed'\nimport { CommentsHelp } from './CommentsHelp'\nimport { CommunityInput } from './CommunityInput'\nimport type { CommunityInputHandle } from './CommunityInput'\n\nexport type CommunitySectionHandle = CommunityInputHandle\n\nconst NO_CLAIMS: Claim[] = []\n\n/**\n * The top of Behind this card: one input that takes words and screenshots,\n * then \"Comments\" with its help, then everything already said about the entry\n * as one feed.\n */\nexport function CommunitySection({\n
|
|
1
|
+
{"version":3,"file":"CommunitySection.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/CommunitySection.tsx"],"sourcesContent":["import type { Claim } from '@igstack/app-catalog-backend-core'\nimport React from 'react'\nimport { CommentsFeed } from './CommentsFeed'\nimport { CommentsHelp } from './CommentsHelp'\nimport { CommunityInput } from './CommunityInput'\nimport type { CommentTarget } from './communityDraft'\nimport type { CommunityInputHandle } from './CommunityInput'\n\nexport type CommunitySectionHandle = CommunityInputHandle\n\nconst NO_CLAIMS: Claim[] = []\n\n/**\n * The top of Behind this card: one input that takes words and screenshots,\n * then \"Comments\" with its help, then everything already said about the entry\n * as one feed.\n */\nexport function CommunitySection({\n target,\n appName,\n claims = NO_CLAIMS,\n ref,\n}: {\n target: CommentTarget\n /** The entry's or the tool's name, for the input's label. */\n appName: string\n claims?: Claim[]\n ref?: React.Ref<CommunitySectionHandle>\n}) {\n const headingId = React.useId()\n return (\n <section className=\"behind-group\">\n <CommunityInput ref={ref} target={target} appName={appName} />\n <div className=\"comments-head\">\n <h2 id={headingId} className=\"behind-group__title\">\n Comments\n </h2>\n <CommentsHelp\n appSlug={'resourceSlug' in target ? target.resourceSlug : undefined}\n appName={appName}\n />\n </div>\n <CommentsFeed target={target} claims={claims} labelledBy={headingId} />\n </section>\n )\n}\n"],"names":["React"],"mappings":";;;;;AAUA,MAAM,YAAqB,CAAA;AAOpB,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA,SAAS;AAAA,EACT;AACF,GAMG;AACD,QAAM,YAAYA,eAAM,MAAA;AACxB,SACE,qBAAC,WAAA,EAAQ,WAAU,gBACjB,UAAA;AAAA,IAAA,oBAAC,gBAAA,EAAe,KAAU,QAAgB,QAAA,CAAkB;AAAA,IAC5D,qBAAC,OAAA,EAAI,WAAU,iBACb,UAAA;AAAA,MAAA,oBAAC,MAAA,EAAG,IAAI,WAAW,WAAU,uBAAsB,UAAA,YAEnD;AAAA,MACA;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,SAAS,kBAAkB,SAAS,OAAO,eAAe;AAAA,UAC1D;AAAA,QAAA;AAAA,MAAA;AAAA,IACF,GACF;AAAA,IACA,oBAAC,cAAA,EAAa,QAAgB,QAAgB,YAAY,UAAA,CAAW;AAAA,EAAA,GACvE;AAEJ;"}
|
|
@@ -28,3 +28,15 @@ export declare function readAttachments(slug: string): PendingImage[];
|
|
|
28
28
|
export declare function writeAttachments(slug: string, images: PendingImage[]): void;
|
|
29
29
|
/** The stored image, at whatever size the browser lays it out. */
|
|
30
30
|
export declare function attachmentUrl(id: string): string;
|
|
31
|
+
/** What a comment is about: an official catalog entry, or a community tool. */
|
|
32
|
+
export type CommentTarget = {
|
|
33
|
+
resourceSlug: string;
|
|
34
|
+
} | {
|
|
35
|
+
communityToolSlug: string;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The draft's storage slug. An entry keeps its bare slug, so drafts written
|
|
39
|
+
* before tools had comments still load; a tool's is prefixed, so a tool and an
|
|
40
|
+
* entry that share a slug never share a draft.
|
|
41
|
+
*/
|
|
42
|
+
export declare function draftSlugOf(target: CommentTarget): string;
|
|
@@ -40,8 +40,12 @@ function writeAttachments(slug, images) {
|
|
|
40
40
|
function attachmentUrl(id) {
|
|
41
41
|
return `/api/feedback-attachments/${id}`;
|
|
42
42
|
}
|
|
43
|
+
function draftSlugOf(target) {
|
|
44
|
+
return "resourceSlug" in target ? target.resourceSlug : `tool:${target.communityToolSlug}`;
|
|
45
|
+
}
|
|
43
46
|
export {
|
|
44
47
|
attachmentUrl,
|
|
48
|
+
draftSlugOf,
|
|
45
49
|
readAttachments,
|
|
46
50
|
readDraft,
|
|
47
51
|
writeAttachments,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"communityDraft.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/communityDraft.ts"],"sourcesContent":["/**\n * Where a half-written note lives while it is not sent.\n *\n * The input sits inside a tab panel, and a panel unmounts while its tab is not\n * the active one — so component state alone loses whatever someone typed or\n * attached the moment they look at another tab. Losing someone's words is the\n * one failure this feature cannot afford: reporting has to cost almost nothing,\n * and \"I typed it and it vanished\" is how a person learns not to bother.\n *\n * Session rather than local storage: a draft is in-flight work, not a\n * preference. Keyed by slug so two entries do not share one draft. Storage can\n * throw outright in a locked-down context; a lost draft is not worth taking the\n * card down over, so every access swallows that.\n */\n\n/** One already-uploaded image waiting to be sent with the words. */\nexport interface PendingImage {\n id: string\n name: string\n}\n\nconst draftKey = (slug: string) => `ac.feedback.draft.${slug}`\nconst imagesKey = (slug: string) => `ac.feedback.images.${slug}`\n\nexport function readDraft(slug: string): string {\n try {\n return sessionStorage.getItem(draftKey(slug)) ?? ''\n } catch {\n return ''\n }\n}\n\nexport function writeDraft(slug: string, value: string): void {\n try {\n if (value) sessionStorage.setItem(draftKey(slug), value)\n else sessionStorage.removeItem(draftKey(slug))\n } catch {\n /* see the module comment */\n }\n}\n\n/**\n * Images upload the moment they are picked, so the draft holds ids rather than\n * files. Dropping an id here does NOT delete the image; the server sweeps\n * whatever is never sent, which keeps \"remove\" instant.\n */\nexport function readAttachments(slug: string): PendingImage[] {\n try {\n const raw = sessionStorage.getItem(imagesKey(slug))\n if (!raw) return []\n const parsed: unknown = JSON.parse(raw)\n if (!Array.isArray(parsed)) return []\n return parsed.filter(\n (entry): entry is PendingImage =>\n typeof entry === 'object' &&\n entry !== null &&\n typeof (entry as PendingImage).id === 'string' &&\n typeof (entry as PendingImage).name === 'string',\n )\n } catch {\n return []\n }\n}\n\nexport function writeAttachments(slug: string, images: PendingImage[]): void {\n try {\n if (images.length > 0) {\n sessionStorage.setItem(imagesKey(slug), JSON.stringify(images))\n } else {\n sessionStorage.removeItem(imagesKey(slug))\n }\n } catch {\n /* see the module comment */\n }\n}\n\n/** The stored image, at whatever size the browser lays it out. */\nexport function attachmentUrl(id: string): string {\n return `/api/feedback-attachments/${id}`\n}\n"],"names":[],"mappings":"AAqBA,MAAM,WAAW,CAAC,SAAiB,qBAAqB,IAAI;AAC5D,MAAM,YAAY,CAAC,SAAiB,sBAAsB,IAAI;AAEvD,SAAS,UAAU,MAAsB;AAC9C,MAAI;AACF,WAAO,eAAe,QAAQ,SAAS,IAAI,CAAC,KAAK;AAAA,EACnD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,WAAW,MAAc,OAAqB;AAC5D,MAAI;AACF,QAAI,MAAO,gBAAe,QAAQ,SAAS,IAAI,GAAG,KAAK;AAAA,QAClD,gBAAe,WAAW,SAAS,IAAI,CAAC;AAAA,EAC/C,QAAQ;AAAA,EAER;AACF;AAOO,SAAS,gBAAgB,MAA8B;AAC5D,MAAI;AACF,UAAM,MAAM,eAAe,QAAQ,UAAU,IAAI,CAAC;AAClD,QAAI,CAAC,IAAK,QAAO,CAAA;AACjB,UAAM,SAAkB,KAAK,MAAM,GAAG;AACtC,QAAI,CAAC,MAAM,QAAQ,MAAM,UAAU,CAAA;AACnC,WAAO,OAAO;AAAA,MACZ,CAAC,UACC,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAAuB,OAAO,YACtC,OAAQ,MAAuB,SAAS;AAAA,IAAA;AAAA,EAE9C,QAAQ;AACN,WAAO,CAAA;AAAA,EACT;AACF;AAEO,SAAS,iBAAiB,MAAc,QAA8B;AAC3E,MAAI;AACF,QAAI,OAAO,SAAS,GAAG;AACrB,qBAAe,QAAQ,UAAU,IAAI,GAAG,KAAK,UAAU,MAAM,CAAC;AAAA,IAChE,OAAO;AACL,qBAAe,WAAW,UAAU,IAAI,CAAC;AAAA,IAC3C;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAGO,SAAS,cAAc,IAAoB;AAChD,SAAO,6BAA6B,EAAE;AACxC;"}
|
|
1
|
+
{"version":3,"file":"communityDraft.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/communityDraft.ts"],"sourcesContent":["/**\n * Where a half-written note lives while it is not sent.\n *\n * The input sits inside a tab panel, and a panel unmounts while its tab is not\n * the active one — so component state alone loses whatever someone typed or\n * attached the moment they look at another tab. Losing someone's words is the\n * one failure this feature cannot afford: reporting has to cost almost nothing,\n * and \"I typed it and it vanished\" is how a person learns not to bother.\n *\n * Session rather than local storage: a draft is in-flight work, not a\n * preference. Keyed by slug so two entries do not share one draft. Storage can\n * throw outright in a locked-down context; a lost draft is not worth taking the\n * card down over, so every access swallows that.\n */\n\n/** One already-uploaded image waiting to be sent with the words. */\nexport interface PendingImage {\n id: string\n name: string\n}\n\nconst draftKey = (slug: string) => `ac.feedback.draft.${slug}`\nconst imagesKey = (slug: string) => `ac.feedback.images.${slug}`\n\nexport function readDraft(slug: string): string {\n try {\n return sessionStorage.getItem(draftKey(slug)) ?? ''\n } catch {\n return ''\n }\n}\n\nexport function writeDraft(slug: string, value: string): void {\n try {\n if (value) sessionStorage.setItem(draftKey(slug), value)\n else sessionStorage.removeItem(draftKey(slug))\n } catch {\n /* see the module comment */\n }\n}\n\n/**\n * Images upload the moment they are picked, so the draft holds ids rather than\n * files. Dropping an id here does NOT delete the image; the server sweeps\n * whatever is never sent, which keeps \"remove\" instant.\n */\nexport function readAttachments(slug: string): PendingImage[] {\n try {\n const raw = sessionStorage.getItem(imagesKey(slug))\n if (!raw) return []\n const parsed: unknown = JSON.parse(raw)\n if (!Array.isArray(parsed)) return []\n return parsed.filter(\n (entry): entry is PendingImage =>\n typeof entry === 'object' &&\n entry !== null &&\n typeof (entry as PendingImage).id === 'string' &&\n typeof (entry as PendingImage).name === 'string',\n )\n } catch {\n return []\n }\n}\n\nexport function writeAttachments(slug: string, images: PendingImage[]): void {\n try {\n if (images.length > 0) {\n sessionStorage.setItem(imagesKey(slug), JSON.stringify(images))\n } else {\n sessionStorage.removeItem(imagesKey(slug))\n }\n } catch {\n /* see the module comment */\n }\n}\n\n/** The stored image, at whatever size the browser lays it out. */\nexport function attachmentUrl(id: string): string {\n return `/api/feedback-attachments/${id}`\n}\n\n/** What a comment is about: an official catalog entry, or a community tool. */\nexport type CommentTarget =\n | { resourceSlug: string }\n | { communityToolSlug: string }\n\n/**\n * The draft's storage slug. An entry keeps its bare slug, so drafts written\n * before tools had comments still load; a tool's is prefixed, so a tool and an\n * entry that share a slug never share a draft.\n */\nexport function draftSlugOf(target: CommentTarget): string {\n return 'resourceSlug' in target\n ? target.resourceSlug\n : `tool:${target.communityToolSlug}`\n}\n"],"names":[],"mappings":"AAqBA,MAAM,WAAW,CAAC,SAAiB,qBAAqB,IAAI;AAC5D,MAAM,YAAY,CAAC,SAAiB,sBAAsB,IAAI;AAEvD,SAAS,UAAU,MAAsB;AAC9C,MAAI;AACF,WAAO,eAAe,QAAQ,SAAS,IAAI,CAAC,KAAK;AAAA,EACnD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEO,SAAS,WAAW,MAAc,OAAqB;AAC5D,MAAI;AACF,QAAI,MAAO,gBAAe,QAAQ,SAAS,IAAI,GAAG,KAAK;AAAA,QAClD,gBAAe,WAAW,SAAS,IAAI,CAAC;AAAA,EAC/C,QAAQ;AAAA,EAER;AACF;AAOO,SAAS,gBAAgB,MAA8B;AAC5D,MAAI;AACF,UAAM,MAAM,eAAe,QAAQ,UAAU,IAAI,CAAC;AAClD,QAAI,CAAC,IAAK,QAAO,CAAA;AACjB,UAAM,SAAkB,KAAK,MAAM,GAAG;AACtC,QAAI,CAAC,MAAM,QAAQ,MAAM,UAAU,CAAA;AACnC,WAAO,OAAO;AAAA,MACZ,CAAC,UACC,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAAuB,OAAO,YACtC,OAAQ,MAAuB,SAAS;AAAA,IAAA;AAAA,EAE9C,QAAQ;AACN,WAAO,CAAA;AAAA,EACT;AACF;AAEO,SAAS,iBAAiB,MAAc,QAA8B;AAC3E,MAAI;AACF,QAAI,OAAO,SAAS,GAAG;AACrB,qBAAe,QAAQ,UAAU,IAAI,GAAG,KAAK,UAAU,MAAM,CAAC;AAAA,IAChE,OAAO;AACL,qBAAe,WAAW,UAAU,IAAI,CAAC;AAAA,IAC3C;AAAA,EACF,QAAQ;AAAA,EAER;AACF;AAGO,SAAS,cAAc,IAAoB;AAChD,SAAO,6BAA6B,EAAE;AACxC;AAYO,SAAS,YAAY,QAA+B;AACzD,SAAO,kBAAkB,SACrB,OAAO,eACP,QAAQ,OAAO,iBAAiB;AACtC;"}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const FEEDBACK_ORIGIN = "feedback:";
|
|
2
2
|
function noteBadge(note, linked) {
|
|
3
|
+
if (note.isSystem) return null;
|
|
3
4
|
if (note.status === null) return "pending";
|
|
4
5
|
if (linked.some((c) => c.status === "review")) return "pending";
|
|
5
6
|
if (note.status === "acknowledged") return null;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"composeComments.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/composeComments.ts"],"sourcesContent":["import type {\n Claim,\n FeedbackView as Note,\n} from '@igstack/app-catalog-backend-core'\n\n/** `pending` = still being checked; `used` = the card now relies on it. */\nexport type CommentBadge = 'pending' | 'used' | null\n\n/** One row of the Comments feed. */\nexport interface CommentEntry {\n key: string\n /** Null when a claim names no origin at all. */\n who: string | null\n /** Raw ISO date or instant, for the `title`. */\n at: string\n /** Null for a note sent without words; the feed says what it carried instead. */\n text: string | null\n badge: CommentBadge\n /** The visitor's row, when the entry is one — what Edit, Dismiss and screenshots hang off. */\n note?: Note\n}\n\nconst FEEDBACK_ORIGIN = 'feedback:'\n\nfunction noteBadge(note: Note, linked: Claim[]): CommentBadge {\n if (note.status === null) return 'pending'\n if (linked.some((c) => c.status === 'review')) return 'pending'\n if (note.status === 'acknowledged') return null\n if (linked.some((c) => c.status === 'active')) return 'used'\n return null\n}\n\n/** Who said a claim that has no note in the list. A URL reads as its host. */\nfunction claimWho(origin: string | undefined): string | null {\n if (!origin) return null\n if (origin.startsWith(FEEDBACK_ORIGIN)) return 'A visitor'\n if (/^https?:\\/\\//.test(origin)) {\n try {\n return new URL(origin).host\n } catch {\n return origin\n }\n }\n return origin\n}\n\nconst time = (at: string) => Date.parse(at) || 0\n\n/**\n * Everything said about an entry, one entry per visitor note, newest first.\n *\n * A claim whose origin is `feedback:<id>` belongs to that note: it sets the\n * note's badge and is not shown again. A claim with no note in the list (its\n * row is gone, or it came from a person, team or URL) stands on its own, in\n * its own words. Archived claims are not shown.\n */\nexport function composeComments(\n notes: Note[],\n claims: Claim[],\n): CommentEntry[] {\n const ids = new Set(notes.map((n) => n.id))\n const byNote = new Map<string, Claim[]>()\n const standalone: Claim[] = []\n for (const claim of claims) {\n if (claim.status === 'archived') continue\n const id = claim.origin?.startsWith(FEEDBACK_ORIGIN)\n ? claim.origin.slice(FEEDBACK_ORIGIN.length)\n : null\n if (id !== null && ids.has(id)) {\n byNote.set(id, [...(byNote.get(id) ?? []), claim])\n } else {\n standalone.push(claim)\n }\n }\n const entries: CommentEntry[] = [\n ...notes.map(\n (note): CommentEntry => ({\n key: note.id,\n who: note.authorName,\n at: note.createdAt,\n text: note.body,\n badge: noteBadge(note, byNote.get(note.id) ?? []),\n note,\n }),\n ),\n ...standalone.map(\n (claim, i): CommentEntry => ({\n key: `claim-${i}`,\n who: claimWho(claim.origin),\n at: claim.addedAt,\n text: claim.text,\n badge: claim.status === 'active' ? 'used' : 'pending',\n }),\n ),\n ]\n return entries.sort((a, b) => time(b.at) - time(a.at))\n}\n\n/** The Behind this card count: exactly the feed's Pending badges. */\nexport function countPending(notes: Note[], claims: Claim[]): number {\n return composeComments(notes, claims).filter((e) => e.badge === 'pending')\n .length\n}\n"],"names":[],"mappings":"AAsBA,MAAM,kBAAkB;AAExB,SAAS,UAAU,MAAY,QAA+B;AAC5D,MAAI,KAAK,WAAW,KAAM,QAAO;AACjC,MAAI,OAAO,KAAK,CAAC,MAAM,EAAE,WAAW,QAAQ,EAAG,QAAO;AACtD,MAAI,KAAK,WAAW,eAAgB,QAAO;AAC3C,MAAI,OAAO,KAAK,CAAC,MAAM,EAAE,WAAW,QAAQ,EAAG,QAAO;AACtD,SAAO;AACT;AAGA,SAAS,SAAS,QAA2C;AAC3D,MAAI,CAAC,OAAQ,QAAO;AACpB,MAAI,OAAO,WAAW,eAAe,EAAG,QAAO;AAC/C,MAAI,eAAe,KAAK,MAAM,GAAG;AAC/B,QAAI;AACF,aAAO,IAAI,IAAI,MAAM,EAAE;AAAA,IACzB,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;AAEA,MAAM,OAAO,CAAC,OAAe,KAAK,MAAM,EAAE,KAAK;AAUxC,SAAS,gBACd,OACA,QACgB;
|
|
1
|
+
{"version":3,"file":"composeComments.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/composeComments.ts"],"sourcesContent":["import type {\n Claim,\n FeedbackView as Note,\n} from '@igstack/app-catalog-backend-core'\n\n/** `pending` = still being checked; `used` = the card now relies on it. */\nexport type CommentBadge = 'pending' | 'used' | null\n\n/** One row of the Comments feed. */\nexport interface CommentEntry {\n key: string\n /** Null when a claim names no origin at all. */\n who: string | null\n /** Raw ISO date or instant, for the `title`. */\n at: string\n /** Null for a note sent without words; the feed says what it carried instead. */\n text: string | null\n badge: CommentBadge\n /** The visitor's row, when the entry is one — what Edit, Dismiss and screenshots hang off. */\n note?: Note\n}\n\nconst FEEDBACK_ORIGIN = 'feedback:'\n\nfunction noteBadge(note: Note, linked: Claim[]): CommentBadge {\n if (note.isSystem) return null\n if (note.status === null) return 'pending'\n if (linked.some((c) => c.status === 'review')) return 'pending'\n if (note.status === 'acknowledged') return null\n if (linked.some((c) => c.status === 'active')) return 'used'\n return null\n}\n\n/** Who said a claim that has no note in the list. A URL reads as its host. */\nfunction claimWho(origin: string | undefined): string | null {\n if (!origin) return null\n if (origin.startsWith(FEEDBACK_ORIGIN)) return 'A visitor'\n if (/^https?:\\/\\//.test(origin)) {\n try {\n return new URL(origin).host\n } catch {\n return origin\n }\n }\n return origin\n}\n\nconst time = (at: string) => Date.parse(at) || 0\n\n/**\n * Everything said about an entry, one entry per visitor note, newest first.\n *\n * A claim whose origin is `feedback:<id>` belongs to that note: it sets the\n * note's badge and is not shown again. A claim with no note in the list (its\n * row is gone, or it came from a person, team or URL) stands on its own, in\n * its own words. Archived claims are not shown.\n */\nexport function composeComments(\n notes: Note[],\n claims: Claim[],\n): CommentEntry[] {\n const ids = new Set(notes.map((n) => n.id))\n const byNote = new Map<string, Claim[]>()\n const standalone: Claim[] = []\n for (const claim of claims) {\n if (claim.status === 'archived') continue\n const id = claim.origin?.startsWith(FEEDBACK_ORIGIN)\n ? claim.origin.slice(FEEDBACK_ORIGIN.length)\n : null\n if (id !== null && ids.has(id)) {\n byNote.set(id, [...(byNote.get(id) ?? []), claim])\n } else {\n standalone.push(claim)\n }\n }\n const entries: CommentEntry[] = [\n ...notes.map(\n (note): CommentEntry => ({\n key: note.id,\n who: note.authorName,\n at: note.createdAt,\n text: note.body,\n badge: noteBadge(note, byNote.get(note.id) ?? []),\n note,\n }),\n ),\n ...standalone.map(\n (claim, i): CommentEntry => ({\n key: `claim-${i}`,\n who: claimWho(claim.origin),\n at: claim.addedAt,\n text: claim.text,\n badge: claim.status === 'active' ? 'used' : 'pending',\n }),\n ),\n ]\n return entries.sort((a, b) => time(b.at) - time(a.at))\n}\n\n/** The Behind this card count: exactly the feed's Pending badges. */\nexport function countPending(notes: Note[], claims: Claim[]): number {\n return composeComments(notes, claims).filter((e) => e.badge === 'pending')\n .length\n}\n"],"names":[],"mappings":"AAsBA,MAAM,kBAAkB;AAExB,SAAS,UAAU,MAAY,QAA+B;AAC5D,MAAI,KAAK,SAAU,QAAO;AAC1B,MAAI,KAAK,WAAW,KAAM,QAAO;AACjC,MAAI,OAAO,KAAK,CAAC,MAAM,EAAE,WAAW,QAAQ,EAAG,QAAO;AACtD,MAAI,KAAK,WAAW,eAAgB,QAAO;AAC3C,MAAI,OAAO,KAAK,CAAC,MAAM,EAAE,WAAW,QAAQ,EAAG,QAAO;AACtD,SAAO;AACT;AAGA,SAAS,SAAS,QAA2C;AAC3D,MAAI,CAAC,OAAQ,QAAO;AACpB,MAAI,OAAO,WAAW,eAAe,EAAG,QAAO;AAC/C,MAAI,eAAe,KAAK,MAAM,GAAG;AAC/B,QAAI;AACF,aAAO,IAAI,IAAI,MAAM,EAAE;AAAA,IACzB,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;AAEA,MAAM,OAAO,CAAC,OAAe,KAAK,MAAM,EAAE,KAAK;AAUxC,SAAS,gBACd,OACA,QACgB;AAtClB;AAuCE,QAAM,MAAM,IAAI,IAAI,MAAM,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;AAC1C,QAAM,6BAAa,IAAA;AACnB,QAAM,aAAsB,CAAA;AAC5B,aAAW,SAAS,QAAQ;AAC1B,QAAI,MAAM,WAAW,WAAY;AACjC,UAAM,OAAK,WAAM,WAAN,mBAAc,WAAW,oBAChC,MAAM,OAAO,MAAM,gBAAgB,MAAM,IACzC;AACJ,QAAI,OAAO,QAAQ,IAAI,IAAI,EAAE,GAAG;AAC9B,aAAO,IAAI,IAAI,CAAC,GAAI,OAAO,IAAI,EAAE,KAAK,IAAK,KAAK,CAAC;AAAA,IACnD,OAAO;AACL,iBAAW,KAAK,KAAK;AAAA,IACvB;AAAA,EACF;AACA,QAAM,UAA0B;AAAA,IAC9B,GAAG,MAAM;AAAA,MACP,CAAC,UAAwB;AAAA,QACvB,KAAK,KAAK;AAAA,QACV,KAAK,KAAK;AAAA,QACV,IAAI,KAAK;AAAA,QACT,MAAM,KAAK;AAAA,QACX,OAAO,UAAU,MAAM,OAAO,IAAI,KAAK,EAAE,KAAK,EAAE;AAAA,QAChD;AAAA,MAAA;AAAA,IACF;AAAA,IAEF,GAAG,WAAW;AAAA,MACZ,CAAC,OAAO,OAAqB;AAAA,QAC3B,KAAK,SAAS,CAAC;AAAA,QACf,KAAK,SAAS,MAAM,MAAM;AAAA,QAC1B,IAAI,MAAM;AAAA,QACV,MAAM,MAAM;AAAA,QACZ,OAAO,MAAM,WAAW,WAAW,SAAS;AAAA,MAAA;AAAA,IAC9C;AAAA,EACF;AAEF,SAAO,QAAQ,KAAK,CAAC,GAAG,MAAM,KAAK,EAAE,EAAE,IAAI,KAAK,EAAE,EAAE,CAAC;AACvD;AAGO,SAAS,aAAa,OAAe,QAAyB;AACnE,SAAO,gBAAgB,OAAO,MAAM,EAAE,OAAO,CAAC,MAAM,EAAE,UAAU,SAAS,EACtE;AACL;"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How many live community tools are tagged with this app. One query for the
|
|
3
|
+
* whole catalog, so switching cards asks nothing new. Zero while loading, and
|
|
4
|
+
* when the deployment has community tools turned off.
|
|
5
|
+
*/
|
|
6
|
+
export declare function useCommunityToolCount(appSlug: string): number;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { useTRPC } from "../../../../api/infra/trpc.js";
|
|
3
|
+
function useCommunityToolCount(appSlug) {
|
|
4
|
+
const trpc = useTRPC();
|
|
5
|
+
const { data: config } = useQuery(trpc.communityTools.config.queryOptions());
|
|
6
|
+
const { data: counts } = useQuery(
|
|
7
|
+
trpc.communityTools.countByApp.queryOptions(void 0, {
|
|
8
|
+
enabled: (config == null ? void 0 : config.enabled) === true
|
|
9
|
+
})
|
|
10
|
+
);
|
|
11
|
+
return (counts == null ? void 0 : counts[appSlug]) ?? 0;
|
|
12
|
+
}
|
|
13
|
+
export {
|
|
14
|
+
useCommunityToolCount
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=useCommunityToolCount.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useCommunityToolCount.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/useCommunityToolCount.ts"],"sourcesContent":["import { useQuery } from '@tanstack/react-query'\nimport { useTRPC } from '~/api/infra/trpc'\n\n/**\n * How many live community tools are tagged with this app. One query for the\n * whole catalog, so switching cards asks nothing new. Zero while loading, and\n * when the deployment has community tools turned off.\n */\nexport function useCommunityToolCount(appSlug: string): number {\n const trpc = useTRPC()\n const { data: config } = useQuery(trpc.communityTools.config.queryOptions())\n const { data: counts } = useQuery(\n trpc.communityTools.countByApp.queryOptions(undefined, {\n enabled: config?.enabled === true,\n }),\n )\n return counts?.[appSlug] ?? 0\n}\n"],"names":[],"mappings":";;AAQO,SAAS,sBAAsB,SAAyB;AAC7D,QAAM,OAAO,QAAA;AACb,QAAM,EAAE,MAAM,WAAW,SAAS,KAAK,eAAe,OAAO,cAAc;AAC3E,QAAM,EAAE,MAAM,OAAA,IAAW;AAAA,IACvB,KAAK,eAAe,WAAW,aAAa,QAAW;AAAA,MACrD,UAAS,iCAAQ,aAAY;AAAA,IAAA,CAC9B;AAAA,EAAA;AAEH,UAAO,iCAAS,aAAY;AAC9B;"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx } from "react/jsx-runtime";
|
|
2
2
|
import { AppCatalogProvider } from "../../context/AppCatalogContext.js";
|
|
3
3
|
import { AppCatalogFiltersProvider } from "../context/AppCatalogFiltersContext.js";
|
|
4
|
-
import {
|
|
4
|
+
import { SectionTabs } from "../components/SectionTabs.js";
|
|
5
5
|
import { MainLayout } from "../../../../ui/layout/MainLayout.js";
|
|
6
6
|
import { TopLevelProviders } from "../../../../ui/layout/TopLevelProviders.js";
|
|
7
7
|
function AppCatalogLayout({
|
|
@@ -10,7 +10,7 @@ function AppCatalogLayout({
|
|
|
10
10
|
trpcClient,
|
|
11
11
|
headerMiddle
|
|
12
12
|
}) {
|
|
13
|
-
return /* @__PURE__ */ jsx(TopLevelProviders, { queryClient, trpcClient, children: /* @__PURE__ */ jsx(AppCatalogProvider, { children: /* @__PURE__ */ jsx(AppCatalogFiltersProvider, { children: /* @__PURE__ */ jsx(MainLayout, { headerMiddle: headerMiddle ?? /* @__PURE__ */ jsx(
|
|
13
|
+
return /* @__PURE__ */ jsx(TopLevelProviders, { queryClient, trpcClient, children: /* @__PURE__ */ jsx(AppCatalogProvider, { children: /* @__PURE__ */ jsx(AppCatalogFiltersProvider, { children: /* @__PURE__ */ jsx(MainLayout, { headerMiddle: headerMiddle ?? /* @__PURE__ */ jsx(SectionTabs, {}), children: /* @__PURE__ */ jsx("div", { className: "flex flex-1 w-full justify-center min-h-0", children }) }) }) }) });
|
|
14
14
|
}
|
|
15
15
|
export {
|
|
16
16
|
AppCatalogLayout
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AppCatalogLayout.js","sources":["../../../../../../src/modules/appCatalog/ui/layout/AppCatalogLayout.tsx"],"sourcesContent":["import type { TRPCRouter } from '@igstack/app-catalog-backend-core'\nimport type { QueryClient } from '@tanstack/react-query'\nimport type { TRPCClient } from '@trpc/client'\nimport { AppCatalogProvider } from '~/modules/appCatalog/context/AppCatalogContext'\nimport { AppCatalogFiltersProvider } from '~/modules/appCatalog/ui/context/AppCatalogFiltersContext'\nimport {
|
|
1
|
+
{"version":3,"file":"AppCatalogLayout.js","sources":["../../../../../../src/modules/appCatalog/ui/layout/AppCatalogLayout.tsx"],"sourcesContent":["import type { TRPCRouter } from '@igstack/app-catalog-backend-core'\nimport type { QueryClient } from '@tanstack/react-query'\nimport type { TRPCClient } from '@trpc/client'\nimport { AppCatalogProvider } from '~/modules/appCatalog/context/AppCatalogContext'\nimport { AppCatalogFiltersProvider } from '~/modules/appCatalog/ui/context/AppCatalogFiltersContext'\nimport { SectionTabs } from '~/modules/appCatalog/ui/components/SectionTabs'\nimport { MainLayout } from '~/ui/layout/MainLayout'\nimport { TopLevelProviders } from '~/ui/layout/TopLevelProviders'\n\nexport interface AppCatalogLayoutProps {\n children: React.ReactNode\n queryClient: QueryClient\n trpcClient: TRPCClient<TRPCRouter>\n headerMiddle?: React.ReactNode\n}\n\nexport function AppCatalogLayout({\n children,\n queryClient,\n trpcClient,\n headerMiddle,\n}: AppCatalogLayoutProps) {\n return (\n <TopLevelProviders queryClient={queryClient} trpcClient={trpcClient}>\n <AppCatalogProvider>\n <AppCatalogFiltersProvider>\n <MainLayout headerMiddle={headerMiddle ?? <SectionTabs />}>\n {/* <Breadcrumb className=\"pb-4\">*/}\n {/* <BreadcrumbList>*/}\n {/* <BreadcrumbItem>*/}\n {/* <BreadcrumbLink asChild>*/}\n {/* <Link to={'/'} className=\"flex items-center gap-1\">*/}\n {/* <HomeIcon className=\"size-4\" />*/}\n {/* <span className=\"sr-only\">Home</span>*/}\n {/* </Link>*/}\n {/* </BreadcrumbLink>*/}\n {/* </BreadcrumbItem>*/}\n {/* <BreadcrumbSeparator />*/}\n {/* <BreadcrumbItem>*/}\n {/* <BreadcrumbLink asChild>*/}\n {/* <Link to=\"/catalog/apps\">App Catalog</Link>*/}\n {/* </BreadcrumbLink>*/}\n {/* </BreadcrumbItem>*/}\n {/* </BreadcrumbList>*/}\n {/* </Breadcrumb>*/}\n <div className=\"flex flex-1 w-full justify-center min-h-0\">\n {children}\n </div>\n </MainLayout>\n </AppCatalogFiltersProvider>\n </AppCatalogProvider>\n </TopLevelProviders>\n )\n}\n"],"names":[],"mappings":";;;;;;AAgBO,SAAS,iBAAiB;AAAA,EAC/B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,GAA0B;AACxB,SACE,oBAAC,qBAAkB,aAA0B,YAC3C,8BAAC,oBAAA,EACC,UAAA,oBAAC,2BAAA,EACC,UAAA,oBAAC,YAAA,EAAW,cAAc,gBAAgB,oBAAC,aAAA,CAAA,CAAY,GAmBrD,UAAA,oBAAC,OAAA,EAAI,WAAU,6CACZ,SAAA,CACH,EAAA,CACF,EAAA,CACF,EAAA,CACF,GACF;AAEJ;"}
|
|
@@ -26,11 +26,15 @@ function AuthProvider({ children }) {
|
|
|
26
26
|
if (response.ok) {
|
|
27
27
|
const data = await response.json();
|
|
28
28
|
setSession(data);
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
const userWithAdmin = (data == null ? void 0 : data.user) ? {
|
|
30
|
+
...data.user,
|
|
31
|
+
isAdmin: data.isAdmin ?? data.user.isAdmin ?? false
|
|
32
|
+
} : null;
|
|
33
|
+
setUser(userWithAdmin);
|
|
34
|
+
if (userWithAdmin) {
|
|
31
35
|
localStorage.setItem(
|
|
32
36
|
AUTH_USER_STORAGE_KEY,
|
|
33
|
-
JSON.stringify(
|
|
37
|
+
JSON.stringify(userWithAdmin)
|
|
34
38
|
);
|
|
35
39
|
} else {
|
|
36
40
|
localStorage.removeItem(AUTH_USER_STORAGE_KEY);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"AuthContext.js","sources":["../../../../src/modules/auth/AuthContext.tsx"],"sourcesContent":["import type { Session } from 'better-auth/types'\nimport { createContext, use, useEffect, useMemo, useState } from 'react'\n\ninterface User {\n id: string\n email?: string | null\n name?: string | null\n image?: string | null\n emailVerified?: boolean\n isAdmin?: boolean\n}\n\ninterface AuthContextType {\n session: Session | null\n user: User | null\n isLoading: boolean\n isAuthenticated: boolean\n}\n\nconst AuthContext = createContext<AuthContextType | undefined>(undefined)\n\nexport const AUTH_USER_STORAGE_KEY = 'ac_auth_user'\n\n/**\n * Provider component for authentication state\n * Fetches and manages session from the backend\n * Uses localStorage for optimistic rendering to avoid flickering\n */\nexport function AuthProvider({ children }: { children: React.ReactNode }) {\n const [session, setSession] = useState<Session | null>(null)\n const [user, setUser] = useState<User | null>(() => {\n // Hydrate from localStorage on mount to avoid flickering\n if (typeof window === 'undefined') return null\n try {\n const stored = localStorage.getItem(AUTH_USER_STORAGE_KEY)\n return stored ? JSON.parse(stored) : null\n } catch {\n return null\n }\n })\n const [isLoading, setIsLoading] = useState(true)\n\n useEffect(() => {\n // Use AbortController to cancel fetch on unmount (e.g., React StrictMode double-mount)\n const abortController = new AbortController()\n\n // Fetch session from backend\n async function fetchSession() {\n try {\n const baseUrl =\n typeof window !== 'undefined' ? window.location.origin : ''\n const response = await fetch(`${baseUrl}/api/auth/session`, {\n credentials: 'include',\n signal: abortController.signal,\n })\n if (response.ok) {\n const data = await response.json()\n setSession(data)\n
|
|
1
|
+
{"version":3,"file":"AuthContext.js","sources":["../../../../src/modules/auth/AuthContext.tsx"],"sourcesContent":["import type { Session } from 'better-auth/types'\nimport { createContext, use, useEffect, useMemo, useState } from 'react'\n\ninterface User {\n id: string\n email?: string | null\n name?: string | null\n image?: string | null\n emailVerified?: boolean\n isAdmin?: boolean\n}\n\ninterface AuthContextType {\n session: Session | null\n user: User | null\n isLoading: boolean\n isAuthenticated: boolean\n}\n\nconst AuthContext = createContext<AuthContextType | undefined>(undefined)\n\nexport const AUTH_USER_STORAGE_KEY = 'ac_auth_user'\n\n/**\n * Provider component for authentication state\n * Fetches and manages session from the backend\n * Uses localStorage for optimistic rendering to avoid flickering\n */\nexport function AuthProvider({ children }: { children: React.ReactNode }) {\n const [session, setSession] = useState<Session | null>(null)\n const [user, setUser] = useState<User | null>(() => {\n // Hydrate from localStorage on mount to avoid flickering\n if (typeof window === 'undefined') return null\n try {\n const stored = localStorage.getItem(AUTH_USER_STORAGE_KEY)\n return stored ? JSON.parse(stored) : null\n } catch {\n return null\n }\n })\n const [isLoading, setIsLoading] = useState(true)\n\n useEffect(() => {\n // Use AbortController to cancel fetch on unmount (e.g., React StrictMode double-mount)\n const abortController = new AbortController()\n\n // Fetch session from backend\n async function fetchSession() {\n try {\n const baseUrl =\n typeof window !== 'undefined' ? window.location.origin : ''\n const response = await fetch(`${baseUrl}/api/auth/session`, {\n credentials: 'include',\n signal: abortController.signal,\n })\n if (response.ok) {\n const data = await response.json()\n setSession(data)\n const userWithAdmin = data?.user\n ? {\n ...data.user,\n isAdmin: data.isAdmin ?? data.user.isAdmin ?? false,\n }\n : null\n setUser(userWithAdmin)\n if (userWithAdmin) {\n localStorage.setItem(\n AUTH_USER_STORAGE_KEY,\n JSON.stringify(userWithAdmin),\n )\n } else {\n localStorage.removeItem(AUTH_USER_STORAGE_KEY)\n }\n } else {\n setSession(null)\n setUser(null)\n localStorage.removeItem(AUTH_USER_STORAGE_KEY)\n }\n } catch (error) {\n // Ignore abort errors (happens on cleanup)\n if (error instanceof Error && error.name === 'AbortError') {\n return\n }\n console.error('Failed to fetch session:', error)\n setSession(null)\n setUser(null)\n localStorage.removeItem(AUTH_USER_STORAGE_KEY)\n } finally {\n setIsLoading(false)\n }\n }\n\n fetchSession()\n\n // Cleanup: abort fetch on unmount\n return () => {\n abortController.abort()\n }\n }, [])\n\n const value: AuthContextType = useMemo(\n () => ({\n session,\n user,\n isLoading,\n isAuthenticated: !!user,\n }),\n [session, user, isLoading],\n )\n\n return <AuthContext value={value}>{children}</AuthContext>\n}\n\n/**\n * Hook to access authentication state\n * @returns Authentication context containing session, user, and auth state\n */\nexport function useAuth() {\n const context = use(AuthContext)\n if (!context) {\n throw new Error('useAuth must be used within AuthProvider')\n }\n return context\n}\n\n/**\n * Hook to check if user is authenticated\n * @returns Boolean indicating if user is authenticated\n */\nexport function useIsAuthenticated() {\n const { isAuthenticated } = useAuth()\n return isAuthenticated\n}\n\n/**\n * Hook to get the current user\n * @returns Current user or null\n */\nexport function useUser() {\n const { user } = useAuth()\n return user\n}\n\n/**\n * The current user, or null when there is no `AuthProvider` above.\n *\n * `useUser()` throws without a provider, which is correct for UI that only ever\n * renders inside the app but wrong for anything a test may mount bare. Several\n * components are rendered bare on purpose (`AppDetailPanel`, `AppCatalogGrid`),\n * so assembling a plugin slot payload with `useUser()` there turns every one of\n * those tests red — \"useAuth must be used within AuthProvider\".\n *\n * Signed-out and no-provider are the same answer for a slot payload: null.\n */\nexport function useOptionalUser() {\n return use(AuthContext)?.user ?? null\n}\n"],"names":[],"mappings":";;AAmBA,MAAM,cAAc,cAA2C,MAAS;AAEjE,MAAM,wBAAwB;AAO9B,SAAS,aAAa,EAAE,YAA2C;AACxE,QAAM,CAAC,SAAS,UAAU,IAAI,SAAyB,IAAI;AAC3D,QAAM,CAAC,MAAM,OAAO,IAAI,SAAsB,MAAM;AAElD,QAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,QAAI;AACF,YAAM,SAAS,aAAa,QAAQ,qBAAqB;AACzD,aAAO,SAAS,KAAK,MAAM,MAAM,IAAI;AAAA,IACvC,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF,CAAC;AACD,QAAM,CAAC,WAAW,YAAY,IAAI,SAAS,IAAI;AAE/C,YAAU,MAAM;AAEd,UAAM,kBAAkB,IAAI,gBAAA;AAG5B,mBAAe,eAAe;AAC5B,UAAI;AACF,cAAM,UACJ,OAAO,WAAW,cAAc,OAAO,SAAS,SAAS;AAC3D,cAAM,WAAW,MAAM,MAAM,GAAG,OAAO,qBAAqB;AAAA,UAC1D,aAAa;AAAA,UACb,QAAQ,gBAAgB;AAAA,QAAA,CACzB;AACD,YAAI,SAAS,IAAI;AACf,gBAAM,OAAO,MAAM,SAAS,KAAA;AAC5B,qBAAW,IAAI;AACf,gBAAM,iBAAgB,6BAAM,QACxB;AAAA,YACE,GAAG,KAAK;AAAA,YACR,SAAS,KAAK,WAAW,KAAK,KAAK,WAAW;AAAA,UAAA,IAEhD;AACJ,kBAAQ,aAAa;AACrB,cAAI,eAAe;AACjB,yBAAa;AAAA,cACX;AAAA,cACA,KAAK,UAAU,aAAa;AAAA,YAAA;AAAA,UAEhC,OAAO;AACL,yBAAa,WAAW,qBAAqB;AAAA,UAC/C;AAAA,QACF,OAAO;AACL,qBAAW,IAAI;AACf,kBAAQ,IAAI;AACZ,uBAAa,WAAW,qBAAqB;AAAA,QAC/C;AAAA,MACF,SAAS,OAAO;AAEd,YAAI,iBAAiB,SAAS,MAAM,SAAS,cAAc;AACzD;AAAA,QACF;AACA,gBAAQ,MAAM,4BAA4B,KAAK;AAC/C,mBAAW,IAAI;AACf,gBAAQ,IAAI;AACZ,qBAAa,WAAW,qBAAqB;AAAA,MAC/C,UAAA;AACE,qBAAa,KAAK;AAAA,MACpB;AAAA,IACF;AAEA,iBAAA;AAGA,WAAO,MAAM;AACX,sBAAgB,MAAA;AAAA,IAClB;AAAA,EACF,GAAG,CAAA,CAAE;AAEL,QAAM,QAAyB;AAAA,IAC7B,OAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA,iBAAiB,CAAC,CAAC;AAAA,IAAA;AAAA,IAErB,CAAC,SAAS,MAAM,SAAS;AAAA,EAAA;AAG3B,SAAO,oBAAC,aAAA,EAAY,OAAe,SAAA,CAAS;AAC9C;AAMO,SAAS,UAAU;AACxB,QAAM,UAAU,IAAI,WAAW;AAC/B,MAAI,CAAC,SAAS;AACZ,UAAM,IAAI,MAAM,0CAA0C;AAAA,EAC5D;AACA,SAAO;AACT;AAMO,SAAS,qBAAqB;AACnC,QAAM,EAAE,gBAAA,IAAoB,QAAA;AAC5B,SAAO;AACT;AAMO,SAAS,UAAU;AACxB,QAAM,EAAE,KAAA,IAAS,QAAA;AACjB,SAAO;AACT;AAaO,SAAS,kBAAkB;;AAChC,WAAO,SAAI,WAAW,MAAf,mBAAkB,SAAQ;AACnC;"}
|
|
@@ -3,7 +3,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f
|
|
|
3
3
|
import { useAuthModal } from "../AuthModalContext.js";
|
|
4
4
|
import { LoginPage } from "./LoginPage.js";
|
|
5
5
|
function LoginModal() {
|
|
6
|
-
const { isOpen, close } = useAuthModal();
|
|
6
|
+
const { isOpen, close, redirectUrl } = useAuthModal();
|
|
7
7
|
return /* @__PURE__ */ jsx(
|
|
8
8
|
Dialog,
|
|
9
9
|
{
|
|
@@ -13,13 +13,21 @@ function LoginModal() {
|
|
|
13
13
|
close();
|
|
14
14
|
}
|
|
15
15
|
},
|
|
16
|
-
children: /* @__PURE__ */ jsxs(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
16
|
+
children: /* @__PURE__ */ jsxs(
|
|
17
|
+
DialogContent,
|
|
18
|
+
{
|
|
19
|
+
className: "w-full max-w-md",
|
|
20
|
+
"data-testid": "login-modal",
|
|
21
|
+
"data-return-to": redirectUrl,
|
|
22
|
+
children: [
|
|
23
|
+
/* @__PURE__ */ jsxs(DialogHeader, { children: [
|
|
24
|
+
/* @__PURE__ */ jsx(DialogTitle, { children: "Sign In" }),
|
|
25
|
+
/* @__PURE__ */ jsx(DialogDescription, { children: "Choose your sign-in method to authorize in App Catalog" })
|
|
26
|
+
] }),
|
|
27
|
+
/* @__PURE__ */ jsx(LoginPage, { onSuccess: close })
|
|
28
|
+
]
|
|
29
|
+
}
|
|
30
|
+
)
|
|
23
31
|
}
|
|
24
32
|
);
|
|
25
33
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"LoginModal.js","sources":["../../../../../src/modules/auth/ui/LoginModal.tsx"],"sourcesContent":["import {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n} from '~/ui/dialog'\nimport { useAuthModal } from '../AuthModalContext'\nimport { LoginPage } from './LoginPage'\n\n/**\n * LoginModal renders a dialog with login form\n * Controlled by AuthModalContext global state\n * Can be opened from anywhere using useAuthModal hook\n */\nexport function LoginModal() {\n const { isOpen, close } = useAuthModal()\n\n return (\n <Dialog\n open={isOpen}\n onOpenChange={(open) => {\n if (!open) {\n close()\n }\n }}\n >\n <DialogContent
|
|
1
|
+
{"version":3,"file":"LoginModal.js","sources":["../../../../../src/modules/auth/ui/LoginModal.tsx"],"sourcesContent":["import {\n Dialog,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n} from '~/ui/dialog'\nimport { useAuthModal } from '../AuthModalContext'\nimport { LoginPage } from './LoginPage'\n\n/**\n * LoginModal renders a dialog with login form\n * Controlled by AuthModalContext global state\n * Can be opened from anywhere using useAuthModal hook\n */\nexport function LoginModal() {\n const { isOpen, close, redirectUrl } = useAuthModal()\n\n return (\n <Dialog\n open={isOpen}\n onOpenChange={(open) => {\n if (!open) {\n close()\n }\n }}\n >\n <DialogContent\n className=\"w-full max-w-md\"\n data-testid=\"login-modal\"\n // Where the provider returns the visitor; readable in tests and devtools.\n data-return-to={redirectUrl}\n >\n <DialogHeader>\n <DialogTitle>Sign In</DialogTitle>\n <DialogDescription>\n Choose your sign-in method to authorize in App Catalog\n </DialogDescription>\n </DialogHeader>\n <LoginPage onSuccess={close} />\n </DialogContent>\n </Dialog>\n )\n}\n"],"names":[],"mappings":";;;;AAeO,SAAS,aAAa;AAC3B,QAAM,EAAE,QAAQ,OAAO,YAAA,IAAgB,aAAA;AAEvC,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,MAAM;AAAA,MACN,cAAc,CAAC,SAAS;AACtB,YAAI,CAAC,MAAM;AACT,gBAAA;AAAA,QACF;AAAA,MACF;AAAA,MAEA,UAAA;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,WAAU;AAAA,UACV,eAAY;AAAA,UAEZ,kBAAgB;AAAA,UAEhB,UAAA;AAAA,YAAA,qBAAC,cAAA,EACC,UAAA;AAAA,cAAA,oBAAC,eAAY,UAAA,UAAA,CAAO;AAAA,cACpB,oBAAC,qBAAkB,UAAA,yDAAA,CAEnB;AAAA,YAAA,GACF;AAAA,YACA,oBAAC,WAAA,EAAU,WAAW,MAAA,CAAO;AAAA,UAAA;AAAA,QAAA;AAAA,MAAA;AAAA,IAC/B;AAAA,EAAA;AAGN;"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { CommunityTool } from '@igstack/app-catalog-shared-core';
|
|
2
|
+
/**
|
|
3
|
+
* "Liked by you, A, B and 3 others": you first, then up to eight colleagues,
|
|
4
|
+
* newest first, then how many more.
|
|
5
|
+
*/
|
|
6
|
+
export declare function likedByText(tool: Pick<CommunityTool, 'likes' | 'likedByMe' | 'likedBy'>): string;
|
|
7
|
+
/** The tool as it reads once I liked it, or took the like back. */
|
|
8
|
+
export declare function withLike(tool: CommunityTool, liked: boolean): CommunityTool;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const NAMED = 8;
|
|
2
|
+
function likedByText(tool) {
|
|
3
|
+
if (tool.likes === 0) return "No likes yet";
|
|
4
|
+
const names = [
|
|
5
|
+
...tool.likedByMe ? ["you"] : [],
|
|
6
|
+
...tool.likedBy.slice(0, NAMED)
|
|
7
|
+
];
|
|
8
|
+
const others = tool.likes - names.length;
|
|
9
|
+
if (others > 0) {
|
|
10
|
+
return `Liked by ${names.join(", ")} and ${others} ${others === 1 ? "other" : "others"}`;
|
|
11
|
+
}
|
|
12
|
+
const last = names.pop();
|
|
13
|
+
return names.length > 0 ? `Liked by ${names.join(", ")} and ${last}` : `Liked by ${last}`;
|
|
14
|
+
}
|
|
15
|
+
function withLike(tool, liked) {
|
|
16
|
+
if (tool.likedByMe === liked) return tool;
|
|
17
|
+
return { ...tool, likedByMe: liked, likes: tool.likes + (liked ? 1 : -1) };
|
|
18
|
+
}
|
|
19
|
+
export {
|
|
20
|
+
likedByText,
|
|
21
|
+
withLike
|
|
22
|
+
};
|
|
23
|
+
//# sourceMappingURL=likedBy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"likedBy.js","sources":["../../../../src/modules/communityTools/likedBy.ts"],"sourcesContent":["import type { CommunityTool } from '@igstack/app-catalog-shared-core'\n\n/** How many likers the tooltip names; the rest are a count. */\nconst NAMED = 8\n\n/**\n * \"Liked by you, A, B and 3 others\": you first, then up to eight colleagues,\n * newest first, then how many more.\n */\nexport function likedByText(\n tool: Pick<CommunityTool, 'likes' | 'likedByMe' | 'likedBy'>,\n): string {\n if (tool.likes === 0) return 'No likes yet'\n const names = [\n ...(tool.likedByMe ? ['you'] : []),\n ...tool.likedBy.slice(0, NAMED),\n ]\n const others = tool.likes - names.length\n if (others > 0) {\n return `Liked by ${names.join(', ')} and ${others} ${others === 1 ? 'other' : 'others'}`\n }\n const last = names.pop()\n return names.length > 0\n ? `Liked by ${names.join(', ')} and ${last}`\n : `Liked by ${last}`\n}\n\n/** The tool as it reads once I liked it, or took the like back. */\nexport function withLike(tool: CommunityTool, liked: boolean): CommunityTool {\n if (tool.likedByMe === liked) return tool\n return { ...tool, likedByMe: liked, likes: tool.likes + (liked ? 1 : -1) }\n}\n"],"names":[],"mappings":"AAGA,MAAM,QAAQ;AAMP,SAAS,YACd,MACQ;AACR,MAAI,KAAK,UAAU,EAAG,QAAO;AAC7B,QAAM,QAAQ;AAAA,IACZ,GAAI,KAAK,YAAY,CAAC,KAAK,IAAI,CAAA;AAAA,IAC/B,GAAG,KAAK,QAAQ,MAAM,GAAG,KAAK;AAAA,EAAA;AAEhC,QAAM,SAAS,KAAK,QAAQ,MAAM;AAClC,MAAI,SAAS,GAAG;AACd,WAAO,YAAY,MAAM,KAAK,IAAI,CAAC,QAAQ,MAAM,IAAI,WAAW,IAAI,UAAU,QAAQ;AAAA,EACxF;AACA,QAAM,OAAO,MAAM,IAAA;AACnB,SAAO,MAAM,SAAS,IAClB,YAAY,MAAM,KAAK,IAAI,CAAC,QAAQ,IAAI,KACxC,YAAY,IAAI;AACtB;AAGO,SAAS,SAAS,MAAqB,OAA+B;AAC3E,MAAI,KAAK,cAAc,MAAO,QAAO;AACrC,SAAO,EAAE,GAAG,MAAM,WAAW,OAAO,OAAO,KAAK,SAAS,QAAQ,IAAI,IAAA;AACvE;"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
2
|
+
function timeAgo(iso, now = /* @__PURE__ */ new Date()) {
|
|
3
|
+
const day = (d) => Date.UTC(d.getFullYear(), d.getMonth(), d.getDate());
|
|
4
|
+
const days = Math.max(0, Math.round((day(now) - day(new Date(iso))) / DAY_MS));
|
|
5
|
+
if (days === 0) return "today";
|
|
6
|
+
if (days === 1) return "yesterday";
|
|
7
|
+
if (days < 31) return `${days} days ago`;
|
|
8
|
+
if (days < 365) {
|
|
9
|
+
const months = Math.floor(days / 30);
|
|
10
|
+
return months === 1 ? "1 month ago" : `${months} months ago`;
|
|
11
|
+
}
|
|
12
|
+
const years = Math.floor(days / 365);
|
|
13
|
+
return years === 1 ? "1 year ago" : `${years} years ago`;
|
|
14
|
+
}
|
|
15
|
+
export {
|
|
16
|
+
timeAgo
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=timeAgo.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"timeAgo.js","sources":["../../../../src/modules/communityTools/timeAgo.ts"],"sourcesContent":["const DAY_MS = 24 * 60 * 60 * 1000\n\n/** How long ago, in calendar days and up: \"today\", \"3 days ago\", \"2 months ago\". */\nexport function timeAgo(iso: string, now: Date = new Date()): string {\n const day = (d: Date) => Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())\n const days = Math.max(0, Math.round((day(now) - day(new Date(iso))) / DAY_MS))\n if (days === 0) return 'today'\n if (days === 1) return 'yesterday'\n if (days < 31) return `${days} days ago`\n if (days < 365) {\n const months = Math.floor(days / 30)\n return months === 1 ? '1 month ago' : `${months} months ago`\n }\n const years = Math.floor(days / 365)\n return years === 1 ? '1 year ago' : `${years} years ago`\n}\n"],"names":[],"mappings":"AAAA,MAAM,SAAS,KAAK,KAAK,KAAK;AAGvB,SAAS,QAAQ,KAAa,MAAY,oBAAI,QAAgB;AACnE,QAAM,MAAM,CAAC,MAAY,KAAK,IAAI,EAAE,YAAA,GAAe,EAAE,SAAA,GAAY,EAAE,SAAS;AAC5E,QAAM,OAAO,KAAK,IAAI,GAAG,KAAK,OAAO,IAAI,GAAG,IAAI,IAAI,IAAI,KAAK,GAAG,CAAC,KAAK,MAAM,CAAC;AAC7E,MAAI,SAAS,EAAG,QAAO;AACvB,MAAI,SAAS,EAAG,QAAO;AACvB,MAAI,OAAO,GAAI,QAAO,GAAG,IAAI;AAC7B,MAAI,OAAO,KAAK;AACd,UAAM,SAAS,KAAK,MAAM,OAAO,EAAE;AACnC,WAAO,WAAW,IAAI,gBAAgB,GAAG,MAAM;AAAA,EACjD;AACA,QAAM,QAAQ,KAAK,MAAM,OAAO,GAAG;AACnC,SAAO,UAAU,IAAI,eAAe,GAAG,KAAK;AAC9C;"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { CommunityTool } from '@igstack/app-catalog-shared-core';
|
|
2
|
+
export declare function formatCardDate(iso: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* The tool's screenshot, full width: a click opens the site, like the title
|
|
5
|
+
* block, and the corner button enlarges it in the screenshot viewer. A tool
|
|
6
|
+
* with none yet shows a placeholder in the same box, not a link: "still
|
|
7
|
+
* capturing" while its first visit is to come, otherwise "no screenshot".
|
|
8
|
+
*/
|
|
9
|
+
export declare function CardScreenshot({ tool, capturing, }: {
|
|
10
|
+
tool: CommunityTool;
|
|
11
|
+
/** A first screenshot is on its way and still worth waiting for. */
|
|
12
|
+
capturing: boolean;
|
|
13
|
+
}): import("react/jsx-runtime").JSX.Element;
|