aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web/components
diff options
context:
space:
mode:
authorMohamedBassem <me@mbassem.com>2024-09-22 14:56:19 +0000
committerMohamedBassem <me@mbassem.com>2024-09-22 15:04:20 +0000
commita770e55520245b7afc2b7a30aa6127eebcb6ea0d (patch)
tree7a2e4041e2d16413ee0e8dd060be41b58253c995 /apps/web/components
parent55f5c7f40d6569d0769a3b7a9060db5ec1d3b93b (diff)
downloadkarakeep-a770e55520245b7afc2b7a30aa6127eebcb6ea0d.tar.zst
feature(web): Show attachments and allow users to manipulate them.
Diffstat (limited to 'apps/web/components')
-rw-r--r--apps/web/components/dashboard/preview/AttachmentBox.tsx209
-rw-r--r--apps/web/components/dashboard/preview/BookmarkPreview.tsx2
-rw-r--r--apps/web/components/dashboard/settings/ImportExport.tsx2
-rw-r--r--apps/web/components/ui/file-picker-button.tsx6
4 files changed, 216 insertions, 3 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}