diff options
| -rw-r--r-- | apps/web/components/dashboard/preview/AttachmentBox.tsx | 209 | ||||
| -rw-r--r-- | apps/web/components/dashboard/preview/BookmarkPreview.tsx | 2 | ||||
| -rw-r--r-- | apps/web/components/dashboard/settings/ImportExport.tsx | 2 | ||||
| -rw-r--r-- | apps/web/components/ui/file-picker-button.tsx | 6 | ||||
| -rw-r--r-- | packages/shared-react/hooks/bookmarks.ts | 45 | ||||
| -rw-r--r-- | packages/shared/types/bookmarks.ts | 16 | ||||
| -rw-r--r-- | packages/trpc/lib/attachments.ts | 42 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.test.ts | 97 | ||||
| -rw-r--r-- | packages/trpc/routers/bookmarks.ts | 137 |
9 files changed, 544 insertions, 12 deletions
diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx new file mode 100644 index 00000000..b2460165 --- /dev/null +++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx @@ -0,0 +1,209 @@ +import Link from "next/link"; +import { ActionButton } from "@/components/ui/action-button"; +import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog"; +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import FilePickerButton from "@/components/ui/file-picker-button"; +import { toast } from "@/components/ui/use-toast"; +import useUpload from "@/lib/hooks/upload-file"; +import { + Archive, + Camera, + ChevronsDownUp, + Download, + Image, + Pencil, + Plus, + Trash2, +} from "lucide-react"; + +import { + useAttachBookmarkAsset, + useDetachBookmarkAsset, + useReplaceBookmarkAsset, +} from "@hoarder/shared-react/hooks/bookmarks"; +import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils"; +import { ZAssetType, ZBookmark } from "@hoarder/shared/types/bookmarks"; +import { + humanFriendlyNameForAssertType, + isAllowedToAttachAsset, +} from "@hoarder/trpc/lib/attachments"; + +export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) { + const typeToIcon: Record<ZAssetType, React.ReactNode> = { + screenshot: <Camera className="size-4" />, + fullPageArchive: <Archive className="size-4" />, + bannerImage: <Image className="size-4" />, + }; + + const { mutate: attachAsset, isPending: isAttaching } = + useAttachBookmarkAsset({ + onSuccess: () => { + toast({ + description: "Attachment has been attached!", + }); + }, + onError: (e) => { + toast({ + description: e.message, + variant: "destructive", + }); + }, + }); + + const { mutate: replaceAsset, isPending: isReplacing } = + useReplaceBookmarkAsset({ + onSuccess: () => { + toast({ + description: "Attachment has been replaced!", + }); + }, + onError: (e) => { + toast({ + description: e.message, + variant: "destructive", + }); + }, + }); + + const { mutate: detachAsset, isPending: isDetaching } = + useDetachBookmarkAsset({ + onSuccess: () => { + toast({ + description: "Attachment has been detached!", + }); + }, + onError: (e) => { + toast({ + description: e.message, + variant: "destructive", + }); + }, + }); + + const { mutate: uploadAsset } = useUpload({ + onError: (e) => { + toast({ + description: e.error, + variant: "destructive", + }); + }, + }); + + if (!bookmark.assets.length) { + return null; + } + bookmark.assets.sort((a, b) => a.assetType.localeCompare(b.assetType)); + + return ( + <Collapsible> + <CollapsibleTrigger className="flex w-full items-center justify-between gap-2 text-sm text-gray-400"> + Attachments + <ChevronsDownUp className="size-4" /> + </CollapsibleTrigger> + <CollapsibleContent className="flex flex-col gap-1 py-2 text-sm"> + {bookmark.assets.map((asset) => ( + <div key={asset.id} className="flex items-center justify-between"> + <Link + target="_blank" + href={getAssetUrl(asset.id)} + className="flex items-center gap-1" + > + {typeToIcon[asset.assetType]} + <p>{humanFriendlyNameForAssertType(asset.assetType)}</p> + </Link> + <div className="flex gap-2"> + <Link + title="Download" + target="_blank" + href={getAssetUrl(asset.id)} + className="flex items-center gap-1" + download={humanFriendlyNameForAssertType(asset.assetType)} + > + <Download className="size-4" /> + </Link> + {isAllowedToAttachAsset(asset.assetType) && ( + <FilePickerButton + title="Replace" + loading={isReplacing} + accept=".jgp,.JPG,.jpeg,.png,.webp" + multiple={false} + variant="none" + size="none" + className="flex items-center gap-2" + onFileSelect={(file) => + uploadAsset(file, { + onSuccess: (resp) => { + replaceAsset({ + bookmarkId: bookmark.id, + oldAssetId: asset.id, + newAssetId: resp.assetId, + }); + }, + }) + } + > + <Pencil className="size-4" /> + </FilePickerButton> + )} + <ActionConfirmingDialog + title="Delete Attachment?" + description={`Are you sure you want to delete the attachment of the bookmark?`} + actionButton={(setDialogOpen) => ( + <ActionButton + loading={isDetaching} + variant="destructive" + onClick={() => + detachAsset( + { bookmarkId: bookmark.id, assetId: asset.id }, + { onSettled: () => setDialogOpen(false) }, + ) + } + > + <Trash2 className="mr-2 size-4" /> + Delete + </ActionButton> + )} + > + <Button variant="none" size="none" title="Delete"> + <Trash2 className="size-4" /> + </Button> + </ActionConfirmingDialog> + </div> + </div> + ))} + {!bookmark.assets.some((asset) => asset.assetType == "bannerImage") && ( + <FilePickerButton + title="Attach a Banner" + loading={isAttaching} + accept=".jgp,.JPG,.jpeg,.png,.webp" + multiple={false} + variant="ghost" + size="none" + className="flex w-full items-center justify-center gap-2" + onFileSelect={(file) => + uploadAsset(file, { + onSuccess: (resp) => { + attachAsset({ + bookmarkId: bookmark.id, + asset: { + id: resp.assetId, + assetType: "bannerImage", + }, + }); + }, + }) + } + > + <Plus className="size-4" /> + Attach a Banner + </FilePickerButton> + )} + </CollapsibleContent> + </Collapsible> + ); +} diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx index 13d3c9d8..1f099725 100644 --- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx +++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx @@ -25,6 +25,7 @@ import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks"; import ActionBar from "./ActionBar"; import { AssetContentSection } from "./AssetContentSection"; +import AttachmentBox from "./AttachmentBox"; import { EditableTitle } from "./EditableTitle"; import LinkContentSection from "./LinkContentSection"; import { NoteEditor } from "./NoteEditor"; @@ -153,6 +154,7 @@ export default function BookmarkPreview({ <p className="pt-2 text-sm text-gray-400">Note</p> <NoteEditor bookmark={bookmark} /> </div> + <AttachmentBox bookmark={bookmark} /> <ActionBar bookmark={bookmark} /> </div> </div> diff --git a/apps/web/components/dashboard/settings/ImportExport.tsx b/apps/web/components/dashboard/settings/ImportExport.tsx index 4827df93..a19db7fd 100644 --- a/apps/web/components/dashboard/settings/ImportExport.tsx +++ b/apps/web/components/dashboard/settings/ImportExport.tsx @@ -171,6 +171,7 @@ export function Import() { <div className="flex flex-col gap-3"> <div className="flex flex-row gap-2"> <FilePickerButton + loading={false} accept=".html" multiple={false} className="flex items-center gap-2" @@ -183,6 +184,7 @@ export function Import() { </FilePickerButton> <FilePickerButton + loading={false} accept=".html" multiple={false} className="flex items-center gap-2" diff --git a/apps/web/components/ui/file-picker-button.tsx b/apps/web/components/ui/file-picker-button.tsx index ccac1643..95e7bbcd 100644 --- a/apps/web/components/ui/file-picker-button.tsx +++ b/apps/web/components/ui/file-picker-button.tsx @@ -1,8 +1,8 @@ import React, { ChangeEvent, useRef } from "react"; -import { Button, ButtonProps } from "./button"; +import { ActionButton, ActionButtonProps } from "./action-button"; -interface FilePickerButtonProps extends Omit<ButtonProps, "onClick"> { +interface FilePickerButtonProps extends Omit<ActionButtonProps, "onClick"> { onFileSelect?: (file: File) => void; accept?: string; multiple?: boolean; @@ -35,7 +35,7 @@ const FilePickerButton: React.FC<FilePickerButtonProps> = ({ return ( <div> - <Button onClick={handleButtonClick} {...buttonProps} /> + <ActionButton onClick={handleButtonClick} {...buttonProps} /> <input type="file" ref={fileInputRef} diff --git a/packages/shared-react/hooks/bookmarks.ts b/packages/shared-react/hooks/bookmarks.ts index 43f97fc1..cba4d107 100644 --- a/packages/shared-react/hooks/bookmarks.ts +++ b/packages/shared-react/hooks/bookmarks.ts @@ -175,3 +175,48 @@ export function useBookmarkPostCreationHook() { return Promise.all(promises); }; } + +export function useAttachBookmarkAsset( + ...opts: Parameters<typeof api.bookmarks.attachAsset.useMutation> +) { + const apiUtils = api.useUtils(); + return api.bookmarks.attachAsset.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.bookmarks.getBookmarks.invalidate(); + apiUtils.bookmarks.searchBookmarks.invalidate(); + apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} + +export function useReplaceBookmarkAsset( + ...opts: Parameters<typeof api.bookmarks.replaceAsset.useMutation> +) { + const apiUtils = api.useUtils(); + return api.bookmarks.replaceAsset.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.bookmarks.getBookmarks.invalidate(); + apiUtils.bookmarks.searchBookmarks.invalidate(); + apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} + +export function useDetachBookmarkAsset( + ...opts: Parameters<typeof api.bookmarks.detachAsset.useMutation> +) { + const apiUtils = api.useUtils(); + return api.bookmarks.detachAsset.useMutation({ + ...opts[0], + onSuccess: (res, req, meta) => { + apiUtils.bookmarks.getBookmarks.invalidate(); + apiUtils.bookmarks.searchBookmarks.invalidate(); + apiUtils.bookmarks.getBookmark.invalidate({ bookmarkId: req.bookmarkId }); + return opts[0]?.onSuccess?.(res, req, meta); + }, + }); +} diff --git a/packages/shared/types/bookmarks.ts b/packages/shared/types/bookmarks.ts index beefbfb9..c15146f3 100644 --- a/packages/shared/types/bookmarks.ts +++ b/packages/shared/types/bookmarks.ts @@ -11,6 +11,18 @@ export const enum BookmarkTypes { UNKNOWN = "unknown", } +export const zAssetTypesSchema = z.enum([ + "screenshot", + "bannerImage", + "fullPageArchive", +]); +export type ZAssetType = z.infer<typeof zAssetTypesSchema>; + +export const zAssetSchema = z.object({ + id: z.string(), + assetType: zAssetTypesSchema, +}); + export const zBookmarkedLinkSchema = z.object({ type: z.literal(BookmarkTypes.LINK), url: z.string().url(), @@ -63,6 +75,7 @@ export const zBookmarkSchema = zBareBookmarkSchema.merge( z.object({ tags: z.array(zBookmarkTagSchema), content: zBookmarkContentSchema, + assets: z.array(zAssetSchema), }), ); export type ZBookmark = z.infer<typeof zBookmarkSchema>; @@ -71,6 +84,7 @@ const zBookmarkTypeLinkSchema = zBareBookmarkSchema.merge( z.object({ tags: z.array(zBookmarkTagSchema), content: zBookmarkedLinkSchema, + assets: z.array(zAssetSchema), }), ); export type ZBookmarkTypeLink = z.infer<typeof zBookmarkTypeLinkSchema>; @@ -79,6 +93,7 @@ const zBookmarkTypeTextSchema = zBareBookmarkSchema.merge( z.object({ tags: z.array(zBookmarkTagSchema), content: zBookmarkedTextSchema, + assets: z.array(zAssetSchema), }), ); export type ZBookmarkTypeText = z.infer<typeof zBookmarkTypeTextSchema>; @@ -87,6 +102,7 @@ const zBookmarkTypeAssetSchema = zBareBookmarkSchema.merge( z.object({ tags: z.array(zBookmarkTagSchema), content: zBookmarkedAssetSchema, + assets: z.array(zAssetSchema), }), ); export type ZBookmarkTypeAsset = z.infer<typeof zBookmarkTypeAssetSchema>; diff --git a/packages/trpc/lib/attachments.ts b/packages/trpc/lib/attachments.ts new file mode 100644 index 00000000..6fe1ef40 --- /dev/null +++ b/packages/trpc/lib/attachments.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +import { AssetTypes } from "@hoarder/db/schema"; +import { ZAssetType, zAssetTypesSchema } from "@hoarder/shared/types/bookmarks"; + +export function mapDBAssetTypeToUserType(assetType: AssetTypes): ZAssetType { + const map: Record<AssetTypes, z.infer<typeof zAssetTypesSchema>> = { + [AssetTypes.LINK_SCREENSHOT]: "screenshot", + [AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchive", + [AssetTypes.LINK_BANNER_IMAGE]: "bannerImage", + }; + return map[assetType]; +} + +export function mapSchemaAssetTypeToDB( + assetType: z.infer<typeof zAssetTypesSchema>, +): AssetTypes { + const map: Record<ZAssetType, AssetTypes> = { + screenshot: AssetTypes.LINK_SCREENSHOT, + fullPageArchive: AssetTypes.LINK_FULL_PAGE_ARCHIVE, + bannerImage: AssetTypes.LINK_BANNER_IMAGE, + }; + return map[assetType]; +} + +export function humanFriendlyNameForAssertType(type: ZAssetType) { + const map: Record<ZAssetType, string> = { + screenshot: "Screenshot", + fullPageArchive: "Full Page Archive", + bannerImage: "Banner Image", + }; + return map[type]; +} + +export function isAllowedToAttachAsset(type: ZAssetType) { + const map: Record<ZAssetType, boolean> = { + screenshot: true, + fullPageArchive: false, + bannerImage: true, + }; + return map[type]; +} diff --git a/packages/trpc/routers/bookmarks.test.ts b/packages/trpc/routers/bookmarks.test.ts index 802bd992..5219e522 100644 --- a/packages/trpc/routers/bookmarks.test.ts +++ b/packages/trpc/routers/bookmarks.test.ts @@ -1,7 +1,7 @@ import { assert, beforeEach, describe, expect, test } from "vitest"; -import { bookmarks } from "@hoarder/db/schema"; -import { BookmarkTypes } from "@hoarder/shared/types/bookmarks"; +import { assets, AssetTypes, bookmarks } from "@hoarder/db/schema"; +import { BookmarkTypes, ZAssetType } from "@hoarder/shared/types/bookmarks"; import type { CustomTestContext } from "../testUtils"; import { defaultBeforeEach } from "../testUtils"; @@ -326,4 +326,97 @@ describe("Bookmark Routes", () => { await validateWithLimit(10); await validateWithLimit(100); }); + + test<CustomTestContext>("mutate assets", async ({ apiCallers, db }) => { + const api = apiCallers[0].bookmarks; + + const bookmark = await api.createBookmark({ + url: "https://google.com", + type: BookmarkTypes.LINK, + }); + await Promise.all([ + db.insert(assets).values({ + id: "asset1", + assetType: AssetTypes.LINK_SCREENSHOT, + bookmarkId: bookmark.id, + }), + db.insert(assets).values({ + id: "asset2", + assetType: AssetTypes.LINK_BANNER_IMAGE, + bookmarkId: bookmark.id, + }), + db.insert(assets).values({ + id: "asset3", + assetType: AssetTypes.LINK_FULL_PAGE_ARCHIVE, + bookmarkId: bookmark.id, + }), + ]); + + const validateAssets = async ( + expected: { id: string; assetType: ZAssetType }[], + ) => { + const b = await api.getBookmark({ bookmarkId: bookmark.id }); + b.assets.sort((a, b) => a.id.localeCompare(b.id)); + expect(b.assets).toEqual(expected); + }; + + await api.attachAsset({ + bookmarkId: bookmark.id, + asset: { + id: "asset4", + assetType: "screenshot", + }, + }); + + await validateAssets([ + { id: "asset1", assetType: "screenshot" }, + { id: "asset2", assetType: "bannerImage" }, + { id: "asset3", assetType: "fullPageArchive" }, + { id: "asset4", assetType: "screenshot" }, + ]); + + await api.replaceAsset({ + bookmarkId: bookmark.id, + oldAssetId: "asset1", + newAssetId: "asset5", + }); + + await validateAssets([ + { id: "asset2", assetType: "bannerImage" }, + { id: "asset3", assetType: "fullPageArchive" }, + { id: "asset4", assetType: "screenshot" }, + { id: "asset5", assetType: "screenshot" }, + ]); + + await api.detachAsset({ + bookmarkId: bookmark.id, + assetId: "asset4", + }); + + await validateAssets([ + { id: "asset2", assetType: "bannerImage" }, + { id: "asset3", assetType: "fullPageArchive" }, + { id: "asset5", assetType: "screenshot" }, + ]); + + // You're not allowed to attach/replace a fullPageArchive + await expect( + async () => + await api.replaceAsset({ + bookmarkId: bookmark.id, + oldAssetId: "asset3", + newAssetId: "asset4", + }), + ).rejects.toThrow(/You can't attach this type of asset/); + await expect( + async () => + await api.attachAsset({ + bookmarkId: bookmark.id, + asset: { + id: "asset6", + assetType: "fullPageArchive", + }, + }), + ).rejects.toThrow(/You can't attach this type of asset/); + }); }); diff --git a/packages/trpc/routers/bookmarks.ts b/packages/trpc/routers/bookmarks.ts index eb189def..312c3acc 100644 --- a/packages/trpc/routers/bookmarks.ts +++ b/packages/trpc/routers/bookmarks.ts @@ -31,6 +31,7 @@ import { getSearchIdxClient } from "@hoarder/shared/search"; import { BookmarkTypes, DEFAULT_NUM_BOOKMARKS_PER_PAGE, + zAssetSchema, zBareBookmarkSchema, zBookmarkSchema, zGetBookmarksRequestSchema, @@ -41,6 +42,11 @@ import { import type { AuthedContext, Context } from "../index"; import { authedProcedure, router } from "../index"; +import { + isAllowedToAttachAsset, + mapDBAssetTypeToUserType, + mapSchemaAssetTypeToDB, +} from "../lib/attachments"; export const ensureBookmarkOwnership = experimental_trpcMiddleware<{ ctx: Context; @@ -79,13 +85,12 @@ interface Asset { assetType: AssetTypes; } -const ASSET_TYE_MAPPING: Record<AssetTypes, string> = { - [AssetTypes.LINK_SCREENSHOT]: "screenshotAssetId", - [AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchiveAssetId", - [AssetTypes.LINK_BANNER_IMAGE]: "imageAssetId", -}; - function mapAssetsToBookmarkFields(assets: Asset | Asset[] = []) { + const ASSET_TYE_MAPPING: Record<AssetTypes, string> = { + [AssetTypes.LINK_SCREENSHOT]: "screenshotAssetId", + [AssetTypes.LINK_FULL_PAGE_ARCHIVE]: "fullPageArchiveAssetId", + [AssetTypes.LINK_BANNER_IMAGE]: "imageAssetId", + }; const assetsArray = Array.isArray(assets) ? assets : [assets]; return assetsArray.reduce((result: Record<string, string>, asset: Asset) => { result[ASSET_TYE_MAPPING[asset.assetType]] = asset.id; @@ -208,6 +213,10 @@ function toZodSchema(bookmark: BookmarkQueryReturnType): ZBookmark { ...t.tag, })), content, + assets: assets.map((a) => ({ + id: a.id, + assetType: mapDBAssetTypeToUserType(a.assetType), + })), ...rest, }; } @@ -301,6 +310,7 @@ export const bookmarksAppRouter = router({ return { alreadyExists: false, tags: [] as ZBookmarkTags[], + assets: [], content, ...bookmark, }; @@ -599,6 +609,7 @@ export const bookmarksAppRouter = router({ ...row.bookmarksSq, content, tags: [], + assets: [], }; } @@ -617,11 +628,18 @@ export const bookmarksAppRouter = router({ }); } - if (row.assets) { + if ( + row.assets && + !acc[bookmarkId].assets.some((a) => a.id == row.assets!.id) + ) { acc[bookmarkId].content = { ...acc[bookmarkId].content, ...mapAssetsToBookmarkFields(row.assets), }; + acc[bookmarkId].assets.push({ + id: row.assets.id, + assetType: mapDBAssetTypeToUserType(row.assets.assetType), + }); } return acc; @@ -787,4 +805,109 @@ export const bookmarksAppRouter = router({ }; }); }), + + attachAsset: authedProcedure + .input( + z.object({ + bookmarkId: z.string(), + asset: zAssetSchema, + }), + ) + .output(zAssetSchema) + .use(ensureBookmarkOwnership) + .mutation(async ({ input, ctx }) => { + if (!isAllowedToAttachAsset(input.asset.assetType)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You can't attach this type of asset", + }); + } + await ctx.db + .insert(assets) + .values({ + id: input.asset.id, + assetType: mapSchemaAssetTypeToDB(input.asset.assetType), + bookmarkId: input.bookmarkId, + }) + .returning(); + return input.asset; + }), + replaceAsset: authedProcedure + .input( + z.object({ + bookmarkId: z.string(), + oldAssetId: z.string(), + newAssetId: z.string(), + }), + ) + .output(z.void()) + .use(ensureBookmarkOwnership) + .mutation(async ({ input, ctx }) => { + const oldAsset = await ctx.db + .select() + .from(assets) + .where( + and( + eq(assets.id, input.oldAssetId), + eq(assets.bookmarkId, input.bookmarkId), + ), + ) + .limit(1); + if (!oldAsset.length) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + if ( + !isAllowedToAttachAsset(mapDBAssetTypeToUserType(oldAsset[0].assetType)) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "You can't attach this type of asset", + }); + } + + const result = await ctx.db + .update(assets) + .set({ + id: input.newAssetId, + bookmarkId: input.bookmarkId, + }) + .where( + and( + eq(assets.id, input.oldAssetId), + eq(assets.bookmarkId, input.bookmarkId), + ), + ); + if (result.changes == 0) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + await deleteAsset({ + userId: ctx.user.id, + assetId: input.oldAssetId, + }).catch(() => ({})); + }), + detachAsset: authedProcedure + .input( + z.object({ + bookmarkId: z.string(), + assetId: z.string(), + }), + ) + .output(z.void()) + .use(ensureBookmarkOwnership) + .mutation(async ({ input, ctx }) => { + const result = await ctx.db + .delete(assets) + .where( + and( + eq(assets.id, input.assetId), + eq(assets.bookmarkId, input.bookmarkId), + ), + ); + if (result.changes == 0) { + throw new TRPCError({ code: "NOT_FOUND" }); + } + await deleteAsset({ userId: ctx.user.id, assetId: input.assetId }).catch( + () => ({}), + ); + }), }); |
