Skip to content

Commit

Permalink
feature(web): Show attachments and allow users to manipulate them.
Browse files Browse the repository at this point in the history
  • Loading branch information
MohamedBassem committed Sep 22, 2024
1 parent 55f5c7f commit a770e55
Show file tree
Hide file tree
Showing 9 changed files with 544 additions and 12 deletions.
209 changes: 209 additions & 0 deletions apps/web/components/dashboard/preview/AttachmentBox.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
2 changes: 2 additions & 0 deletions apps/web/components/dashboard/preview/BookmarkPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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>
Expand Down
2 changes: 2 additions & 0 deletions apps/web/components/dashboard/settings/ImportExport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -183,6 +184,7 @@ export function Import() {
</FilePickerButton>

<FilePickerButton
loading={false}
accept=".html"
multiple={false}
className="flex items-center gap-2"
Expand Down
6 changes: 3 additions & 3 deletions apps/web/components/ui/file-picker-button.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -35,7 +35,7 @@ const FilePickerButton: React.FC<FilePickerButtonProps> = ({

return (
<div>
<Button onClick={handleButtonClick} {...buttonProps} />
<ActionButton onClick={handleButtonClick} {...buttonProps} />
<input
type="file"
ref={fileInputRef}
Expand Down
45 changes: 45 additions & 0 deletions packages/shared-react/hooks/bookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
});
}
16 changes: 16 additions & 0 deletions packages/shared/types/bookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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>;
Expand All @@ -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>;
Expand All @@ -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>;
Expand All @@ -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>;
Expand Down
Loading

0 comments on commit a770e55

Please sign in to comment.