@igstack/app-catalog-frontend-core 6.11.0 → 6.12.1
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/api/infra/trpc.d.ts +27 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js +10 -1
- package/dist/esm/modules/appCatalog/ui/detail/CommentsFeed.js.map +1 -1
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js +5 -0
- package/dist/esm/modules/appCatalog/ui/detail/CommunityInput.js.map +1 -1
- package/dist/esm/modules/communityTools/siteOpen.d.ts +10 -0
- package/dist/esm/modules/communityTools/siteOpen.js +18 -0
- package/dist/esm/modules/communityTools/siteOpen.js.map +1 -0
- package/dist/esm/modules/communityTools/timeAgo.d.ts +2 -0
- package/dist/esm/modules/communityTools/timeAgo.js +11 -0
- package/dist/esm/modules/communityTools/timeAgo.js.map +1 -1
- package/dist/esm/modules/communityTools/ui/CardScreenshot.js +10 -12
- package/dist/esm/modules/communityTools/ui/CardScreenshot.js.map +1 -1
- package/dist/esm/modules/communityTools/ui/CardTitleBlock.d.ts +1 -1
- package/dist/esm/modules/communityTools/ui/CardTitleBlock.js +5 -13
- package/dist/esm/modules/communityTools/ui/CardTitleBlock.js.map +1 -1
- package/dist/esm/modules/communityTools/ui/CommunityPage.js +2 -4
- package/dist/esm/modules/communityTools/ui/CommunityPage.js.map +1 -1
- package/dist/esm/modules/communityTools/ui/FeedRow.d.ts +7 -5
- package/dist/esm/modules/communityTools/ui/FeedRow.js +28 -30
- package/dist/esm/modules/communityTools/ui/FeedRow.js.map +1 -1
- package/dist/esm/modules/communityTools/ui/ShelfTile.js +4 -7
- package/dist/esm/modules/communityTools/ui/ShelfTile.js.map +1 -1
- package/dist/esm/modules/communityTools/ui/ToolCard.js +15 -2
- package/dist/esm/modules/communityTools/ui/ToolCard.js.map +1 -1
- package/dist/esm/modules/communityTools/useCommunityTools.d.ts +4 -0
- package/package.json +4 -4
- package/src/modules/appCatalog/ui/detail/CommentsFeed.tsx +11 -1
- package/src/modules/appCatalog/ui/detail/CommunityInput.tsx +6 -0
- package/src/modules/communityTools/siteOpen.ts +20 -0
- package/src/modules/communityTools/timeAgo.ts +12 -0
- package/src/modules/communityTools/ui/CardScreenshot.tsx +21 -9
- package/src/modules/communityTools/ui/CardTitleBlock.tsx +4 -9
- package/src/modules/communityTools/ui/CommunityPage.tsx +2 -5
- package/src/modules/communityTools/ui/FeedRow.tsx +40 -43
- package/src/modules/communityTools/ui/ShelfTile.tsx +4 -5
- package/src/modules/communityTools/ui/ToolCard.tsx +11 -1
|
@@ -133,6 +133,15 @@ declare const TRPCProvider: import('react').FunctionComponent<{
|
|
|
133
133
|
};
|
|
134
134
|
meta: object;
|
|
135
135
|
}>;
|
|
136
|
+
recordSiteOpen: import('@trpc/server').TRPCMutationProcedure<{
|
|
137
|
+
input: {
|
|
138
|
+
slug: string;
|
|
139
|
+
};
|
|
140
|
+
output: {
|
|
141
|
+
ok: boolean;
|
|
142
|
+
};
|
|
143
|
+
meta: object;
|
|
144
|
+
}>;
|
|
136
145
|
pins: import('@trpc/server').TRPCQueryProcedure<{
|
|
137
146
|
input: void;
|
|
138
147
|
output: import('@igstack/app-catalog-shared-core').CommunityTool[];
|
|
@@ -395,6 +404,15 @@ declare const TRPCProvider: import('react').FunctionComponent<{
|
|
|
395
404
|
};
|
|
396
405
|
meta: object;
|
|
397
406
|
}>;
|
|
407
|
+
recordSiteOpen: import('@trpc/server').TRPCMutationProcedure<{
|
|
408
|
+
input: {
|
|
409
|
+
slug: string;
|
|
410
|
+
};
|
|
411
|
+
output: {
|
|
412
|
+
ok: boolean;
|
|
413
|
+
};
|
|
414
|
+
meta: object;
|
|
415
|
+
}>;
|
|
398
416
|
pins: import('@trpc/server').TRPCQueryProcedure<{
|
|
399
417
|
input: void;
|
|
400
418
|
output: import('@igstack/app-catalog-shared-core').CommunityTool[];
|
|
@@ -656,6 +674,15 @@ declare const TRPCProvider: import('react').FunctionComponent<{
|
|
|
656
674
|
};
|
|
657
675
|
meta: object;
|
|
658
676
|
}>;
|
|
677
|
+
recordSiteOpen: import('@trpc/server').TRPCMutationProcedure<{
|
|
678
|
+
input: {
|
|
679
|
+
slug: string;
|
|
680
|
+
};
|
|
681
|
+
output: {
|
|
682
|
+
ok: boolean;
|
|
683
|
+
};
|
|
684
|
+
meta: object;
|
|
685
|
+
}>;
|
|
659
686
|
pins: import('@trpc/server').TRPCQueryProcedure<{
|
|
660
687
|
input: void;
|
|
661
688
|
output: import('@igstack/app-catalog-shared-core').CommunityTool[];
|
|
@@ -64,7 +64,16 @@ function CommentsFeed({
|
|
|
64
64
|
})
|
|
65
65
|
);
|
|
66
66
|
const dismiss = useMutation(
|
|
67
|
-
trpc.feedback.dismiss.mutationOptions({
|
|
67
|
+
trpc.feedback.dismiss.mutationOptions({
|
|
68
|
+
onSuccess: () => {
|
|
69
|
+
void refresh();
|
|
70
|
+
if ("communityToolSlug" in target) {
|
|
71
|
+
void queryClient.invalidateQueries({
|
|
72
|
+
queryKey: trpc.communityTools.pathKey()
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
})
|
|
68
77
|
);
|
|
69
78
|
const saveEdit = (id) => {
|
|
70
79
|
const body = editDraft.trim();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CommentsFeed.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/CommentsFeed.tsx"],"sourcesContent":["import type {\n Claim,\n FeedbackView as Note,\n} from '@igstack/app-catalog-backend-core'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport React from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { cn } from '~/lib/utils'\nimport { Button } from '~/ui/button'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\nimport { attachmentUrl } from './communityDraft'\nimport type { CommentTarget } from './communityDraft'\nimport { formatCommunityError } from './communityError'\nimport { composeComments } from './composeComments'\nimport type { CommentEntry } from './composeComments'\n\n/** Same shape the input primitive uses, minus the fixed height. */\nconst FIELD_CLASSES =\n 'placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50'\n\nconst BADGES = {\n pending: { text: 'Pending', className: 'is-pending' },\n used: { text: 'Used as source', className: 'is-used' },\n} as const\n\n/**\n * Screenshots sent with a note: a thumbnail strip under the words. The words\n * are the substance and a screenshot is the evidence, so it should be\n * glanceable and openable, not dominate the row.\n */\nfunction AttachmentStrip({ ids }: { ids: string[] }) {\n if (ids.length === 0) return null\n return (\n <ul className=\"community-shots\">\n {ids.map((id) => (\n <li key={id}>\n <a href={attachmentUrl(id)} target=\"_blank\" rel=\"noreferrer\">\n <img\n src={attachmentUrl(id)}\n alt=\"Attached screenshot\"\n loading=\"lazy\"\n />\n </a>\n </li>\n ))}\n </ul>\n )\n}\n\n/** Who, when and where it stands, on one small line above the words. */\nfunction Meta({ entry, edited }: { entry: CommentEntry; edited: boolean }) {\n const badge = entry.badge && BADGES[entry.badge]\n return (\n <p className=\"comment__meta\">\n {entry.who && (\n <>\n <span className=\"comment__who\">{entry.who}</span>\n {' · '}\n </>\n )}\n <time dateTime={entry.at} title={entry.at}>\n {formatRelativeTime(entry.at)}\n </time>\n {edited && ' (edited)'}\n {badge && (\n <span\n className={cn('comment__badge', badge.className)}\n data-badge={entry.badge}\n >\n {badge.text}\n </span>\n )}\n </p>\n )\n}\n\n/**\n * Everything already said about the entry, under the input: one feed, newest\n * first, one entry per visitor note. See `composeComments` for how claims fold\n * into the notes they came from.\n *\n * Nobody logs in, so the server attributes each note to a pseudonym from an\n * httpOnly cookie, and that cookie is what authorises editing (for an hour) or\n * withdrawing (until someone reviews it). `canEditUntil` arrives as an absolute\n * instant so a skewed client clock cannot show a control the server would refuse.\n */\nexport function CommentsFeed({\n target,\n claims,\n labelledBy,\n}: {\n target: CommentTarget\n claims: Claim[]\n /** Id of the heading that names the list. */\n labelledBy: string\n}) {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n const listOptions = trpc.feedback.list.queryOptions(target)\n const { data, error, isPending } = useQuery(listOptions)\n const [editingId, setEditingId] = React.useState<string | null>(null)\n const [editDraft, setEditDraft] = React.useState('')\n\n const refresh = () =>\n queryClient.invalidateQueries({ queryKey: listOptions.queryKey })\n const edit = useMutation(\n trpc.feedback.edit.mutationOptions({\n onSuccess: () => {\n setEditingId(null)\n void refresh()\n },\n }),\n )\n const dismiss = useMutation(\n trpc.feedback.dismiss.mutationOptions({ onSuccess: () => void refresh() }),\n )\n const saveEdit = (id: string) => {\n const body = editDraft.trim()\n if (body) edit.mutate({ id, body })\n }\n\n const entries =\n data || error ? composeComments(data?.items ?? [], claims) : []\n const failure = error ?? edit.error ?? dismiss.error\n\n const noteBody = (note: Note) => {\n const canEdit =\n note.isMine &&\n !!note.canEditUntil &&\n Date.parse(note.canEditUntil) > Date.now()\n // Withdrawing stays available for as long as nobody has reviewed it.\n const canDismiss = note.isMine && note.status === null\n const editing = editingId === note.id\n return (\n <>\n {editing ? (\n <div className=\"mt-1 space-y-2\">\n <textarea\n autoFocus\n rows={3}\n className={FIELD_CLASSES}\n aria-label=\"Edit your note\"\n value={editDraft}\n onChange={(e) => {\n setEditDraft(e.target.value)\n if (edit.isError) edit.reset()\n }}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {\n e.preventDefault()\n saveEdit(note.id)\n }\n }}\n />\n <div className=\"flex gap-2\">\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={!editDraft.trim() || edit.isPending}\n onClick={() => saveEdit(note.id)}\n >\n Save\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => setEditingId(null)}\n >\n Cancel\n </Button>\n </div>\n </div>\n ) : (\n <p\n data-feedback-body=\"\"\n className={cn('comment__text', !note.body && 'is-empty')}\n >\n {note.body ??\n (note.attachmentIds.length > 0\n ? 'Screenshot only — no words added.'\n : 'Flagged as out of date — no detail given.')}\n </p>\n )}\n <AttachmentStrip ids={note.attachmentIds} />\n {note.reviewerReply && (\n <p className=\"comment__reply\">↳ {note.reviewerReply}</p>\n )}\n {!editing && (canEdit || canDismiss) && (\n <p className=\"comment__actions\">\n {canEdit && (\n <button\n type=\"button\"\n onClick={() => {\n setEditingId(note.id)\n setEditDraft(note.body ?? '')\n }}\n >\n Edit\n </button>\n )}\n {canDismiss && (\n <button\n type=\"button\"\n className=\"is-destructive\"\n onClick={() => dismiss.mutate({ id: note.id })}\n >\n Dismiss\n </button>\n )}\n </p>\n )}\n </>\n )\n }\n\n return (\n <>\n {isPending ? (\n <div\n className=\"comments-skeleton\"\n aria-busy=\"true\"\n aria-labelledby={labelledBy}\n >\n <span className=\"sr-only\" role=\"status\">\n Loading comments…\n </span>\n <div className=\"comments-skeleton__item\" />\n <div className=\"comments-skeleton__item\" />\n <div className=\"comments-skeleton__item\" />\n </div>\n ) : entries.length > 0 ? (\n <ul className=\"comments\" aria-labelledby={labelledBy}>\n {entries.map((entry) => (\n <li key={entry.key} className=\"comment\">\n <Meta entry={entry} edited={!!entry.note?.editedAt} />\n {entry.note?.isSystem ? (\n <p className=\"comment__text is-system\" data-system-line=\"\">\n {entry.text}\n </p>\n ) : entry.note ? (\n noteBody(entry.note)\n ) : (\n <p className=\"comment__text\">{entry.text}</p>\n )}\n </li>\n ))}\n </ul>\n ) : (\n <p className=\"comments-empty\">\n No comments yet. Be the first to share what you know.\n </p>\n )}\n {failure && (\n <p className=\"know-more-error\">{formatCommunityError(failure)}</p>\n )}\n </>\n )\n}\n"],"names":["React"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"CommentsFeed.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/CommentsFeed.tsx"],"sourcesContent":["import type {\n Claim,\n FeedbackView as Note,\n} from '@igstack/app-catalog-backend-core'\nimport { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport React from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { cn } from '~/lib/utils'\nimport { Button } from '~/ui/button'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\nimport { attachmentUrl } from './communityDraft'\nimport type { CommentTarget } from './communityDraft'\nimport { formatCommunityError } from './communityError'\nimport { composeComments } from './composeComments'\nimport type { CommentEntry } from './composeComments'\n\n/** Same shape the input primitive uses, minus the fixed height. */\nconst FIELD_CLASSES =\n 'placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50'\n\nconst BADGES = {\n pending: { text: 'Pending', className: 'is-pending' },\n used: { text: 'Used as source', className: 'is-used' },\n} as const\n\n/**\n * Screenshots sent with a note: a thumbnail strip under the words. The words\n * are the substance and a screenshot is the evidence, so it should be\n * glanceable and openable, not dominate the row.\n */\nfunction AttachmentStrip({ ids }: { ids: string[] }) {\n if (ids.length === 0) return null\n return (\n <ul className=\"community-shots\">\n {ids.map((id) => (\n <li key={id}>\n <a href={attachmentUrl(id)} target=\"_blank\" rel=\"noreferrer\">\n <img\n src={attachmentUrl(id)}\n alt=\"Attached screenshot\"\n loading=\"lazy\"\n />\n </a>\n </li>\n ))}\n </ul>\n )\n}\n\n/** Who, when and where it stands, on one small line above the words. */\nfunction Meta({ entry, edited }: { entry: CommentEntry; edited: boolean }) {\n const badge = entry.badge && BADGES[entry.badge]\n return (\n <p className=\"comment__meta\">\n {entry.who && (\n <>\n <span className=\"comment__who\">{entry.who}</span>\n {' · '}\n </>\n )}\n <time dateTime={entry.at} title={entry.at}>\n {formatRelativeTime(entry.at)}\n </time>\n {edited && ' (edited)'}\n {badge && (\n <span\n className={cn('comment__badge', badge.className)}\n data-badge={entry.badge}\n >\n {badge.text}\n </span>\n )}\n </p>\n )\n}\n\n/**\n * Everything already said about the entry, under the input: one feed, newest\n * first, one entry per visitor note. See `composeComments` for how claims fold\n * into the notes they came from.\n *\n * Nobody logs in, so the server attributes each note to a pseudonym from an\n * httpOnly cookie, and that cookie is what authorises editing (for an hour) or\n * withdrawing (until someone reviews it). `canEditUntil` arrives as an absolute\n * instant so a skewed client clock cannot show a control the server would refuse.\n */\nexport function CommentsFeed({\n target,\n claims,\n labelledBy,\n}: {\n target: CommentTarget\n claims: Claim[]\n /** Id of the heading that names the list. */\n labelledBy: string\n}) {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n const listOptions = trpc.feedback.list.queryOptions(target)\n const { data, error, isPending } = useQuery(listOptions)\n const [editingId, setEditingId] = React.useState<string | null>(null)\n const [editDraft, setEditDraft] = React.useState('')\n\n const refresh = () =>\n queryClient.invalidateQueries({ queryKey: listOptions.queryKey })\n const edit = useMutation(\n trpc.feedback.edit.mutationOptions({\n onSuccess: () => {\n setEditingId(null)\n void refresh()\n },\n }),\n )\n const dismiss = useMutation(\n trpc.feedback.dismiss.mutationOptions({\n onSuccess: () => {\n void refresh()\n // A tool's row counts its comments.\n if ('communityToolSlug' in target) {\n void queryClient.invalidateQueries({\n queryKey: trpc.communityTools.pathKey(),\n })\n }\n },\n }),\n )\n const saveEdit = (id: string) => {\n const body = editDraft.trim()\n if (body) edit.mutate({ id, body })\n }\n\n const entries =\n data || error ? composeComments(data?.items ?? [], claims) : []\n const failure = error ?? edit.error ?? dismiss.error\n\n const noteBody = (note: Note) => {\n const canEdit =\n note.isMine &&\n !!note.canEditUntil &&\n Date.parse(note.canEditUntil) > Date.now()\n // Withdrawing stays available for as long as nobody has reviewed it.\n const canDismiss = note.isMine && note.status === null\n const editing = editingId === note.id\n return (\n <>\n {editing ? (\n <div className=\"mt-1 space-y-2\">\n <textarea\n autoFocus\n rows={3}\n className={FIELD_CLASSES}\n aria-label=\"Edit your note\"\n value={editDraft}\n onChange={(e) => {\n setEditDraft(e.target.value)\n if (edit.isError) edit.reset()\n }}\n onKeyDown={(e) => {\n if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {\n e.preventDefault()\n saveEdit(note.id)\n }\n }}\n />\n <div className=\"flex gap-2\">\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={!editDraft.trim() || edit.isPending}\n onClick={() => saveEdit(note.id)}\n >\n Save\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => setEditingId(null)}\n >\n Cancel\n </Button>\n </div>\n </div>\n ) : (\n <p\n data-feedback-body=\"\"\n className={cn('comment__text', !note.body && 'is-empty')}\n >\n {note.body ??\n (note.attachmentIds.length > 0\n ? 'Screenshot only — no words added.'\n : 'Flagged as out of date — no detail given.')}\n </p>\n )}\n <AttachmentStrip ids={note.attachmentIds} />\n {note.reviewerReply && (\n <p className=\"comment__reply\">↳ {note.reviewerReply}</p>\n )}\n {!editing && (canEdit || canDismiss) && (\n <p className=\"comment__actions\">\n {canEdit && (\n <button\n type=\"button\"\n onClick={() => {\n setEditingId(note.id)\n setEditDraft(note.body ?? '')\n }}\n >\n Edit\n </button>\n )}\n {canDismiss && (\n <button\n type=\"button\"\n className=\"is-destructive\"\n onClick={() => dismiss.mutate({ id: note.id })}\n >\n Dismiss\n </button>\n )}\n </p>\n )}\n </>\n )\n }\n\n return (\n <>\n {isPending ? (\n <div\n className=\"comments-skeleton\"\n aria-busy=\"true\"\n aria-labelledby={labelledBy}\n >\n <span className=\"sr-only\" role=\"status\">\n Loading comments…\n </span>\n <div className=\"comments-skeleton__item\" />\n <div className=\"comments-skeleton__item\" />\n <div className=\"comments-skeleton__item\" />\n </div>\n ) : entries.length > 0 ? (\n <ul className=\"comments\" aria-labelledby={labelledBy}>\n {entries.map((entry) => (\n <li key={entry.key} className=\"comment\">\n <Meta entry={entry} edited={!!entry.note?.editedAt} />\n {entry.note?.isSystem ? (\n <p className=\"comment__text is-system\" data-system-line=\"\">\n {entry.text}\n </p>\n ) : entry.note ? (\n noteBody(entry.note)\n ) : (\n <p className=\"comment__text\">{entry.text}</p>\n )}\n </li>\n ))}\n </ul>\n ) : (\n <p className=\"comments-empty\">\n No comments yet. Be the first to share what you know.\n </p>\n )}\n {failure && (\n <p className=\"know-more-error\">{formatCommunityError(failure)}</p>\n )}\n </>\n )\n}\n"],"names":["React"],"mappings":"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"}
|
|
@@ -103,6 +103,11 @@ function CommunityInput({
|
|
|
103
103
|
void queryClient.invalidateQueries({
|
|
104
104
|
queryKey: trpc.feedback.list.queryKey(target)
|
|
105
105
|
});
|
|
106
|
+
if ("communityToolSlug" in target) {
|
|
107
|
+
void queryClient.invalidateQueries({
|
|
108
|
+
queryKey: trpc.communityTools.pathKey()
|
|
109
|
+
});
|
|
110
|
+
}
|
|
106
111
|
}
|
|
107
112
|
})
|
|
108
113
|
);
|
|
@@ -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 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
|
+
{"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 // A tool's row counts its comments.\n if ('communityToolSlug' in target) {\n void queryClient.invalidateQueries({\n queryKey: trpc.communityTools.pathKey(),\n })\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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"}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counts a click through to a tool's site. The page leaves in the same tab, which
|
|
3
|
+
* would cancel a tRPC call, so this posts with `keepalive` and never waits for it.
|
|
4
|
+
*/
|
|
5
|
+
export declare function recordSiteOpen(slug: string): void;
|
|
6
|
+
/** Goes to the tool's site in this tab, counting the click. */
|
|
7
|
+
export declare function openSite(tool: {
|
|
8
|
+
slug: string;
|
|
9
|
+
url: string;
|
|
10
|
+
}): void;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
function recordSiteOpen(slug) {
|
|
2
|
+
fetch(`${window.location.origin}/api/trpc/communityTools.recordSiteOpen`, {
|
|
3
|
+
method: "POST",
|
|
4
|
+
keepalive: true,
|
|
5
|
+
headers: { "content-type": "application/json" },
|
|
6
|
+
body: JSON.stringify({ slug })
|
|
7
|
+
}).catch(() => {
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
function openSite(tool) {
|
|
11
|
+
recordSiteOpen(tool.slug);
|
|
12
|
+
window.location.assign(tool.url);
|
|
13
|
+
}
|
|
14
|
+
export {
|
|
15
|
+
openSite,
|
|
16
|
+
recordSiteOpen
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=siteOpen.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"siteOpen.js","sources":["../../../../src/modules/communityTools/siteOpen.ts"],"sourcesContent":["/**\n * Counts a click through to a tool's site. The page leaves in the same tab, which\n * would cancel a tRPC call, so this posts with `keepalive` and never waits for it.\n */\nexport function recordSiteOpen(slug: string): void {\n fetch(`${window.location.origin}/api/trpc/communityTools.recordSiteOpen`, {\n method: 'POST',\n keepalive: true,\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ slug }),\n }).catch(() => {\n // A lost count never stops anyone reaching the site.\n })\n}\n\n/** Goes to the tool's site in this tab, counting the click. */\nexport function openSite(tool: { slug: string; url: string }): void {\n recordSiteOpen(tool.slug)\n window.location.assign(tool.url)\n}\n"],"names":[],"mappings":"AAIO,SAAS,eAAe,MAAoB;AACjD,QAAM,GAAG,OAAO,SAAS,MAAM,2CAA2C;AAAA,IACxE,QAAQ;AAAA,IACR,WAAW;AAAA,IACX,SAAS,EAAE,gBAAgB,mBAAA;AAAA,IAC3B,MAAM,KAAK,UAAU,EAAE,MAAM;AAAA,EAAA,CAC9B,EAAE,MAAM,MAAM;AAAA,EAEf,CAAC;AACH;AAGO,SAAS,SAAS,MAA2C;AAClE,iBAAe,KAAK,IAAI;AACxB,SAAO,SAAS,OAAO,KAAK,GAAG;AACjC;"}
|
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
/** How long ago, in calendar days and up: "today", "3 days ago", "2 months ago". */
|
|
2
2
|
export declare function timeAgo(iso: string, now?: Date): string;
|
|
3
|
+
/** The exact moment, for the tooltip of a timeAgo: "22 Oct 2026, 15:04". */
|
|
4
|
+
export declare function exactDate(iso: string): string;
|
|
@@ -12,7 +12,18 @@ function timeAgo(iso, now = /* @__PURE__ */ new Date()) {
|
|
|
12
12
|
const years = Math.floor(days / 365);
|
|
13
13
|
return years === 1 ? "1 year ago" : `${years} years ago`;
|
|
14
14
|
}
|
|
15
|
+
function exactDate(iso) {
|
|
16
|
+
return new Date(iso).toLocaleString("en-GB", {
|
|
17
|
+
day: "numeric",
|
|
18
|
+
month: "short",
|
|
19
|
+
year: "numeric",
|
|
20
|
+
hour: "2-digit",
|
|
21
|
+
minute: "2-digit",
|
|
22
|
+
hour12: false
|
|
23
|
+
});
|
|
24
|
+
}
|
|
15
25
|
export {
|
|
26
|
+
exactDate,
|
|
16
27
|
timeAgo
|
|
17
28
|
};
|
|
18
29
|
//# sourceMappingURL=timeAgo.js.map
|
|
@@ -1 +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;"}
|
|
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\n/** The exact moment, for the tooltip of a timeAgo: \"22 Oct 2026, 15:04\". */\nexport function exactDate(iso: string): string {\n return new Date(iso).toLocaleString('en-GB', {\n day: 'numeric',\n month: 'short',\n year: 'numeric',\n hour: '2-digit',\n minute: '2-digit',\n hour12: false,\n })\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;AAGO,SAAS,UAAU,KAAqB;AAC7C,SAAO,IAAI,KAAK,GAAG,EAAE,eAAe,SAAS;AAAA,IAC3C,KAAK;AAAA,IACL,OAAO;AAAA,IACP,MAAM;AAAA,IACN,MAAM;AAAA,IACN,QAAQ;AAAA,IACR,QAAQ;AAAA,EAAA,CACT;AACH;"}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { jsxs, jsx, Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { Camera, ImageOff,
|
|
2
|
+
import { Camera, ImageOff, Maximize2 } from "lucide-react";
|
|
3
3
|
import { useState } from "react";
|
|
4
4
|
import { ScreenshotGallery } from "../../appCatalog/ui/components/ScreenshotGallery.js";
|
|
5
5
|
import { cn } from "../../../lib/utils.js";
|
|
6
|
+
import { recordSiteOpen } from "../siteOpen.js";
|
|
6
7
|
import { screenshotUrl } from "./FeedRow.js";
|
|
7
8
|
const BOX = "relative flex aspect-[16/10] max-h-[320px] w-full items-center justify-center overflow-hidden rounded-[14px] border border-border bg-muted shadow-sm";
|
|
9
|
+
const UNREACHABLE = "Screenshot unavailable: the catalog's server can't reach this site.";
|
|
8
10
|
function formatCardDate(iso) {
|
|
9
11
|
return new Date(iso).toLocaleDateString("en-GB", {
|
|
10
12
|
day: "numeric",
|
|
@@ -35,7 +37,7 @@ function CardScreenshot({
|
|
|
35
37
|
"Taking a screenshot of the site…"
|
|
36
38
|
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
37
39
|
/* @__PURE__ */ jsx(ImageOff, { "aria-hidden": "true", className: "size-7" }),
|
|
38
|
-
"No screenshot. The site may need a login, or it did not load."
|
|
40
|
+
tool.serverUnreachable ? /* @__PURE__ */ jsx("span", { "data-testid": "card-server-unreachable", children: UNREACHABLE }) : "No screenshot. The site may need a login, or it did not load."
|
|
39
41
|
] })
|
|
40
42
|
}
|
|
41
43
|
),
|
|
@@ -54,9 +56,8 @@ function CardScreenshot({
|
|
|
54
56
|
"a",
|
|
55
57
|
{
|
|
56
58
|
href: tool.url,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"aria-label": `Open ${tool.name} in a new tab`,
|
|
59
|
+
onClick: () => recordSiteOpen(tool.slug),
|
|
60
|
+
"aria-label": `Open ${tool.name}`,
|
|
60
61
|
"data-testid": "card-screenshot-link",
|
|
61
62
|
className: "group/shot block size-full rounded-[14px] focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ring",
|
|
62
63
|
children: [
|
|
@@ -68,10 +69,7 @@ function CardScreenshot({
|
|
|
68
69
|
className: "size-full object-cover object-left-top"
|
|
69
70
|
}
|
|
70
71
|
),
|
|
71
|
-
/* @__PURE__ */ jsx("span", { className: "absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-focus-visible/shot:bg-black/20 group-hover/shot:bg-black/20", children: /* @__PURE__ */
|
|
72
|
-
"Open site",
|
|
73
|
-
/* @__PURE__ */ jsx(ExternalLink, { "aria-hidden": "true", className: "size-4" })
|
|
74
|
-
] }) })
|
|
72
|
+
/* @__PURE__ */ jsx("span", { className: "absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-focus-visible/shot:bg-black/20 group-hover/shot:bg-black/20", children: /* @__PURE__ */ jsx("span", { className: "rounded-lg bg-primary px-3.5 py-2 text-sm font-extrabold text-primary-foreground opacity-0 shadow-sm transition-opacity group-focus-visible/shot:opacity-100 group-hover/shot:opacity-100", children: "Open site" }) })
|
|
75
73
|
]
|
|
76
74
|
}
|
|
77
75
|
),
|
|
@@ -89,9 +87,9 @@ function CardScreenshot({
|
|
|
89
87
|
]
|
|
90
88
|
}
|
|
91
89
|
),
|
|
92
|
-
capturedAt && /* @__PURE__ */ jsxs("figcaption", { className: "mt-2 text-[12.5px] font-semibold text-muted-foreground", children: [
|
|
93
|
-
|
|
94
|
-
|
|
90
|
+
(capturedAt || tool.serverUnreachable) && /* @__PURE__ */ jsxs("figcaption", { className: "mt-2 text-[12.5px] font-semibold text-muted-foreground", children: [
|
|
91
|
+
capturedAt && `Screenshot captured ${formatCardDate(capturedAt)}`,
|
|
92
|
+
tool.serverUnreachable && /* @__PURE__ */ jsx("span", { "data-testid": "card-server-unreachable", className: "block", children: UNREACHABLE })
|
|
95
93
|
] }),
|
|
96
94
|
/* @__PURE__ */ jsx(
|
|
97
95
|
ScreenshotGallery,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CardScreenshot.js","sources":["../../../../../src/modules/communityTools/ui/CardScreenshot.tsx"],"sourcesContent":["import type { CommunityTool } from '@igstack/app-catalog-shared-core'\nimport { Camera,
|
|
1
|
+
{"version":3,"file":"CardScreenshot.js","sources":["../../../../../src/modules/communityTools/ui/CardScreenshot.tsx"],"sourcesContent":["import type { CommunityTool } from '@igstack/app-catalog-shared-core'\nimport { Camera, ImageOff, Maximize2 } from 'lucide-react'\nimport { useState } from 'react'\nimport { ScreenshotGallery } from '~/modules/appCatalog/ui/components/ScreenshotGallery'\nimport { cn } from '~/lib/utils'\nimport { recordSiteOpen } from '../siteOpen'\nimport { screenshotUrl } from './FeedRow'\n\n/**\n * The 16:10 box every state shares, so swapping one for another moves nothing.\n * Capped in height, so how to use it and who to ask stay above the fold; the\n * top of the page, where its title is, is what shows.\n */\nconst BOX =\n 'relative flex aspect-[16/10] max-h-[320px] w-full items-center justify-center overflow-hidden rounded-[14px] border border-border bg-muted shadow-sm'\n\n/** The server's checks get no answer, so there is no fresh screenshot to show. */\nconst UNREACHABLE =\n \"Screenshot unavailable: the catalog's server can't reach this site.\"\n\nexport function formatCardDate(iso: string): string {\n return new Date(iso).toLocaleDateString('en-GB', {\n day: 'numeric',\n month: 'short',\n year: 'numeric',\n })\n}\n\n/**\n * The tool's screenshot, full width: a click opens the site, like the title\n * block, and the corner button enlarges it in the screenshot viewer. A tool\n * with none yet shows a placeholder in the same box, not a link: \"still\n * capturing\" while its first visit is to come, otherwise \"no screenshot\".\n */\nexport function CardScreenshot({\n tool,\n capturing,\n}: {\n tool: CommunityTool\n /** A first screenshot is on its way and still worth waiting for. */\n capturing: boolean\n}) {\n const [open, setOpen] = useState(false)\n const { screenshotId, capturedAt } = tool\n\n if (!screenshotId) {\n return (\n <figure data-testid=\"card-screenshot\" className=\"mt-[22px]\">\n <div\n data-testid=\"card-screenshot-box\"\n data-state={capturing ? 'capturing' : 'missing'}\n aria-busy={capturing || undefined}\n className={cn(\n BOX,\n 'flex-col gap-2.5 px-6 text-center text-sm font-semibold text-muted-foreground',\n )}\n >\n {capturing ? (\n <>\n <Camera aria-hidden=\"true\" className=\"size-7 animate-pulse\" />\n Taking a screenshot of the site…\n </>\n ) : (\n <>\n <ImageOff aria-hidden=\"true\" className=\"size-7\" />\n {tool.serverUnreachable ? (\n <span data-testid=\"card-server-unreachable\">{UNREACHABLE}</span>\n ) : (\n 'No screenshot. The site may need a login, or it did not load.'\n )}\n </>\n )}\n </div>\n <figcaption className=\"mt-2 text-[12.5px] font-semibold text-muted-foreground\">\n {capturing ? 'Usually under a minute' : 'Open the site to see it'}\n </figcaption>\n </figure>\n )\n }\n\n return (\n <figure data-testid=\"card-screenshot\" className=\"mt-[22px]\">\n <div\n data-testid=\"card-screenshot-box\"\n data-state=\"captured\"\n className={BOX}\n >\n <a\n href={tool.url}\n onClick={() => recordSiteOpen(tool.slug)}\n aria-label={`Open ${tool.name}`}\n data-testid=\"card-screenshot-link\"\n className=\"group/shot block size-full rounded-[14px] focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-ring\"\n >\n <img\n src={screenshotUrl(screenshotId, 1280)}\n alt={`Screenshot of ${tool.name}`}\n className=\"size-full object-cover object-left-top\"\n />\n <span className=\"absolute inset-0 flex items-center justify-center bg-black/0 transition-colors group-focus-visible/shot:bg-black/20 group-hover/shot:bg-black/20\">\n <span className=\"rounded-lg bg-primary px-3.5 py-2 text-sm font-extrabold text-primary-foreground opacity-0 shadow-sm transition-opacity group-focus-visible/shot:opacity-100 group-hover/shot:opacity-100\">\n Open site\n </span>\n </span>\n </a>\n {/* Beside the link, not in it: enlarging never opens the site. */}\n <button\n type=\"button\"\n aria-label={`Enlarge screenshot of ${tool.name}`}\n title=\"Enlarge\"\n onClick={() => setOpen(true)}\n className=\"absolute top-3 right-3 inline-flex size-8 items-center justify-center rounded-lg bg-black/65 text-white hover:bg-black/80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\"\n >\n <Maximize2 aria-hidden=\"true\" className=\"size-4\" />\n </button>\n </div>\n {(capturedAt || tool.serverUnreachable) && (\n <figcaption className=\"mt-2 text-[12.5px] font-semibold text-muted-foreground\">\n {capturedAt && `Screenshot captured ${formatCardDate(capturedAt)}`}\n {tool.serverUnreachable && (\n <span data-testid=\"card-server-unreachable\" className=\"block\">\n {UNREACHABLE}\n </span>\n )}\n </figcaption>\n )}\n <ScreenshotGallery\n app={{ displayName: tool.name }}\n screenshotIds={[screenshotId]}\n open={open}\n onOpenChange={setOpen}\n title={`${tool.name} screenshot`}\n />\n </figure>\n )\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { CommunityTool } from '@igstack/app-catalog-shared-core';
|
|
2
2
|
/**
|
|
3
3
|
* The card's door: favicon (a globe when the site has none), name, badges and
|
|
4
|
-
* address, all one link that opens the site
|
|
4
|
+
* address, all one link that opens the site.
|
|
5
5
|
*/
|
|
6
6
|
export declare function CardTitleBlock({ tool }: {
|
|
7
7
|
tool: CommunityTool;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsxs, jsx } from "react/jsx-runtime";
|
|
2
|
-
import { Globe
|
|
2
|
+
import { Globe } from "lucide-react";
|
|
3
3
|
import { cn } from "../../../lib/utils.js";
|
|
4
|
+
import { recordSiteOpen } from "../siteOpen.js";
|
|
4
5
|
import { screenshotUrl } from "./FeedRow.js";
|
|
5
6
|
import { LifecycleBadge, UnreviewedBadge } from "./LifecycleBadge.js";
|
|
6
7
|
function CardTitleBlock({ tool }) {
|
|
@@ -9,8 +10,7 @@ function CardTitleBlock({ tool }) {
|
|
|
9
10
|
"a",
|
|
10
11
|
{
|
|
11
12
|
href: tool.url,
|
|
12
|
-
|
|
13
|
-
rel: "noopener noreferrer",
|
|
13
|
+
onClick: () => recordSiteOpen(tool.slug),
|
|
14
14
|
"data-testid": "card-title-block",
|
|
15
15
|
className: cn(
|
|
16
16
|
"group/door inline-flex min-w-0 items-center gap-[15px] rounded-[14px] border border-border bg-primary/[0.025] px-[17px] py-[11px] transition-colors",
|
|
@@ -39,16 +39,8 @@ function CardTitleBlock({ tool }) {
|
|
|
39
39
|
/* @__PURE__ */ jsx(LifecycleBadge, { lifecycle: tool.lifecycle }),
|
|
40
40
|
!tool.reviewed && /* @__PURE__ */ jsx(UnreviewedBadge, {})
|
|
41
41
|
] }),
|
|
42
|
-
/* @__PURE__ */ jsx("span", { className: "truncate font-mono text-[13px] text-muted-foreground", children: tool.host })
|
|
43
|
-
|
|
44
|
-
] }),
|
|
45
|
-
/* @__PURE__ */ jsx(
|
|
46
|
-
ExternalLink,
|
|
47
|
-
{
|
|
48
|
-
"aria-hidden": "true",
|
|
49
|
-
className: "size-[18px] shrink-0 self-start text-muted-foreground group-hover/door:text-primary"
|
|
50
|
-
}
|
|
51
|
-
)
|
|
42
|
+
/* @__PURE__ */ jsx("span", { className: "truncate font-mono text-[13px] text-muted-foreground", children: tool.host })
|
|
43
|
+
] })
|
|
52
44
|
]
|
|
53
45
|
}
|
|
54
46
|
);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CardTitleBlock.js","sources":["../../../../../src/modules/communityTools/ui/CardTitleBlock.tsx"],"sourcesContent":["import type { CommunityTool } from '@igstack/app-catalog-shared-core'\nimport {
|
|
1
|
+
{"version":3,"file":"CardTitleBlock.js","sources":["../../../../../src/modules/communityTools/ui/CardTitleBlock.tsx"],"sourcesContent":["import type { CommunityTool } from '@igstack/app-catalog-shared-core'\nimport { Globe } from 'lucide-react'\nimport { cn } from '~/lib/utils'\nimport { recordSiteOpen } from '../siteOpen'\nimport { screenshotUrl } from './FeedRow'\nimport { LifecycleBadge, UnreviewedBadge } from './LifecycleBadge'\n\n/**\n * The card's door: favicon (a globe when the site has none), name, badges and\n * address, all one link that opens the site.\n */\nexport function CardTitleBlock({ tool }: { tool: CommunityTool }) {\n const archived = tool.lifecycle.state === 'archived'\n return (\n <a\n href={tool.url}\n onClick={() => recordSiteOpen(tool.slug)}\n data-testid=\"card-title-block\"\n className={cn(\n 'group/door inline-flex min-w-0 items-center gap-[15px] rounded-[14px] border border-border bg-primary/[0.025] px-[17px] py-[11px] transition-colors',\n 'hover:border-primary/40 hover:bg-primary/[0.07] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',\n archived && 'opacity-70 grayscale',\n )}\n >\n <span className=\"inline-flex size-12 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-border bg-card text-muted-foreground\">\n {tool.faviconId ? (\n <img\n src={screenshotUrl(tool.faviconId, 64)}\n alt=\"\"\n className=\"size-7\"\n />\n ) : (\n <Globe aria-hidden=\"true\" className=\"size-6\" strokeWidth={1.75} />\n )}\n </span>\n <span className=\"flex min-w-0 flex-col gap-0.5\">\n <span className=\"flex flex-wrap items-center gap-x-3 gap-y-1\">\n <span\n data-testid=\"card-tool-name\"\n className=\"font-serif text-[26px] leading-tight font-semibold\"\n >\n {tool.name}\n </span>\n <LifecycleBadge lifecycle={tool.lifecycle} />\n {!tool.reviewed && <UnreviewedBadge />}\n </span>\n <span className=\"truncate font-mono text-[13px] text-muted-foreground\">\n {tool.host}\n </span>\n </span>\n </a>\n )\n}\n"],"names":[],"mappings":";;;;;;AAWO,SAAS,eAAe,EAAE,QAAiC;AAChE,QAAM,WAAW,KAAK,UAAU,UAAU;AAC1C,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,MAAM,KAAK;AAAA,MACX,SAAS,MAAM,eAAe,KAAK,IAAI;AAAA,MACvC,eAAY;AAAA,MACZ,WAAW;AAAA,QACT;AAAA,QACA;AAAA,QACA,YAAY;AAAA,MAAA;AAAA,MAGd,UAAA;AAAA,QAAA,oBAAC,QAAA,EAAK,WAAU,0IACb,UAAA,KAAK,YACJ;AAAA,UAAC;AAAA,UAAA;AAAA,YACC,KAAK,cAAc,KAAK,WAAW,EAAE;AAAA,YACrC,KAAI;AAAA,YACJ,WAAU;AAAA,UAAA;AAAA,QAAA,wBAGX,OAAA,EAAM,eAAY,QAAO,WAAU,UAAS,aAAa,KAAA,CAAM,GAEpE;AAAA,QACA,qBAAC,QAAA,EAAK,WAAU,iCACd,UAAA;AAAA,UAAA,qBAAC,QAAA,EAAK,WAAU,+CACd,UAAA;AAAA,YAAA;AAAA,cAAC;AAAA,cAAA;AAAA,gBACC,eAAY;AAAA,gBACZ,WAAU;AAAA,gBAET,UAAA,KAAK;AAAA,cAAA;AAAA,YAAA;AAAA,YAER,oBAAC,gBAAA,EAAe,WAAW,KAAK,UAAA,CAAW;AAAA,YAC1C,CAAC,KAAK,YAAY,oBAAC,iBAAA,CAAA,CAAgB;AAAA,UAAA,GACtC;AAAA,UACA,oBAAC,QAAA,EAAK,WAAU,wDACb,eAAK,KAAA,CACR;AAAA,QAAA,EAAA,CACF;AAAA,MAAA;AAAA,IAAA;AAAA,EAAA;AAGN;"}
|
|
@@ -16,14 +16,12 @@ import { Toast } from "./Toast.js";
|
|
|
16
16
|
import { ToolCard } from "./ToolCard.js";
|
|
17
17
|
import { ToolFormDialog } from "./ToolFormDialog.js";
|
|
18
18
|
import { ToolSearch } from "./ToolSearch.js";
|
|
19
|
+
import { openSite } from "../siteOpen.js";
|
|
19
20
|
const KBD = "rounded-[5px] border border-b-2 border-border bg-card px-1.5 py-px text-[11px] font-semibold text-muted-foreground";
|
|
20
21
|
const RESULTS_ID = "community-results-grid";
|
|
21
22
|
function optionIdOf(slug) {
|
|
22
23
|
return `community-option-${slug}`;
|
|
23
24
|
}
|
|
24
|
-
function openSite(url) {
|
|
25
|
-
window.open(url, "_blank", "noopener,noreferrer");
|
|
26
|
-
}
|
|
27
25
|
function CommunityPage({
|
|
28
26
|
tag,
|
|
29
27
|
sort,
|
|
@@ -224,7 +222,7 @@ function CommunityPage({
|
|
|
224
222
|
);
|
|
225
223
|
} else if (e.key === "Enter" && current) {
|
|
226
224
|
e.preventDefault();
|
|
227
|
-
openSite(current.tool
|
|
225
|
+
openSite(current.tool);
|
|
228
226
|
} else if (e.key === "ArrowRight" && current && e.currentTarget.selectionStart === e.currentTarget.value.length) {
|
|
229
227
|
e.preventDefault();
|
|
230
228
|
void openCard(current.tool.slug);
|