aboutsummaryrefslogtreecommitdiffstats
path: root/apps
diff options
context:
space:
mode:
Diffstat (limited to 'apps')
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts4
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts2
-rw-r--r--apps/web/app/settings/assets/page.tsx182
-rw-r--r--apps/web/app/settings/layout.tsx6
-rw-r--r--apps/web/components/dashboard/preview/AttachmentBox.tsx35
-rw-r--r--apps/web/lib/attachments.tsx14
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json11
-rw-r--r--apps/web/lib/utils.ts12
8 files changed, 233 insertions, 33 deletions
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts
index 3fc50801..88e203de 100644
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts
+++ b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts
@@ -12,7 +12,7 @@ export const PUT = (
req,
bodySchema: z.object({ assetId: z.string() }),
handler: async ({ api, body }) => {
- await api.bookmarks.replaceAsset({
+ await api.assets.replaceAsset({
bookmarkId: params.params.bookmarkId,
oldAssetId: params.params.assetId,
newAssetId: body!.assetId,
@@ -28,7 +28,7 @@ export const DELETE = (
buildHandler({
req,
handler: async ({ api }) => {
- await api.bookmarks.detachAsset({
+ await api.assets.detachAsset({
bookmarkId: params.params.bookmarkId,
assetId: params.params.assetId,
});
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts
index e5284a39..156876b6 100644
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts
+++ b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts
@@ -27,7 +27,7 @@ export const POST = (
req,
bodySchema: zAssetSchema,
handler: async ({ api, body }) => {
- const asset = await api.bookmarks.attachAsset({
+ const asset = await api.assets.attachAsset({
bookmarkId: params.params.bookmarkId,
asset: body!,
});
diff --git a/apps/web/app/settings/assets/page.tsx b/apps/web/app/settings/assets/page.tsx
new file mode 100644
index 00000000..a6c13525
--- /dev/null
+++ b/apps/web/app/settings/assets/page.tsx
@@ -0,0 +1,182 @@
+"use client";
+
+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 { FullPageSpinner } from "@/components/ui/full-page-spinner";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import { toast } from "@/components/ui/use-toast";
+import { ASSET_TYPE_TO_ICON } from "@/lib/attachments";
+import { useTranslation } from "@/lib/i18n/client";
+import { api } from "@/lib/trpc";
+import { formatBytes } from "@/lib/utils";
+import { ExternalLink, Trash2 } from "lucide-react";
+
+import { useDetachBookmarkAsset } from "@hoarder/shared-react/hooks/assets";
+import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils";
+import {
+ humanFriendlyNameForAssertType,
+ isAllowedToDetachAsset,
+} from "@hoarder/trpc/lib/attachments";
+
+export default function AssetsSettingsPage() {
+ const { t } = useTranslation();
+ const { mutate: detachAsset, isPending: isDetaching } =
+ useDetachBookmarkAsset({
+ onSuccess: () => {
+ toast({
+ description: "Asset has been deleted!",
+ });
+ },
+ onError: (e) => {
+ toast({
+ description: e.message,
+ variant: "destructive",
+ });
+ },
+ });
+ const {
+ data,
+ isLoading: isAssetsLoading,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ } = api.assets.list.useInfiniteQuery(
+ {
+ limit: 20,
+ },
+ {
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ },
+ );
+
+ const assets = data?.pages.flatMap((page) => page.assets) ?? [];
+
+ if (isAssetsLoading) {
+ return <FullPageSpinner />;
+ }
+
+ return (
+ <div className="rounded-md border bg-background p-4">
+ <div className="flex flex-col gap-2">
+ <div className="mb-2 text-lg font-medium">
+ {t("settings.manage_assets.manage_assets")}
+ </div>
+ {assets.length === 0 && (
+ <p className="rounded-md bg-muted p-2 text-sm text-muted-foreground">
+ {t("settings.manage_assets.no_assets")}
+ </p>
+ )}
+ {assets.length > 0 && (
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead>{t("settings.manage_assets.asset_type")}</TableHead>
+ <TableHead>{t("common.size")}</TableHead>
+ <TableHead>{t("settings.manage_assets.asset_link")}</TableHead>
+ <TableHead>
+ {t("settings.manage_assets.bookmark_link")}
+ </TableHead>
+ <TableHead>{t("common.actions")}</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {assets.map((asset) => (
+ <TableRow key={asset.id}>
+ <TableCell className="flex items-center gap-2">
+ {ASSET_TYPE_TO_ICON[asset.assetType]}
+ <span>
+ {humanFriendlyNameForAssertType(asset.assetType)}
+ </span>
+ </TableCell>
+ <TableCell>{formatBytes(asset.size)}</TableCell>
+ <TableCell>
+ <Link
+ href={getAssetUrl(asset.id)}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="flex items-center gap-1"
+ >
+ <ExternalLink className="size-4" />
+ <span>View Asset</span>
+ </Link>
+ </TableCell>
+ <TableCell>
+ {asset.bookmarkId ? (
+ <Link
+ href={`/dashboard/preview/${asset.bookmarkId}`}
+ className="flex items-center gap-1"
+ >
+ <ExternalLink className="size-4" />
+ <span>View Bookmark</span>
+ </Link>
+ ) : (
+ <span className="text-muted-foreground">No bookmark</span>
+ )}
+ </TableCell>
+ <TableCell>
+ {isAllowedToDetachAsset(asset.assetType) &&
+ asset.bookmarkId && (
+ <ActionConfirmingDialog
+ title={t("settings.manage_assets.delete_asset")}
+ description={t(
+ "settings.manage_assets.delete_asset_confirmation",
+ )}
+ actionButton={(setDialogOpen) => (
+ <ActionButton
+ loading={isDetaching}
+ variant="destructive"
+ onClick={() =>
+ detachAsset(
+ {
+ bookmarkId: asset.bookmarkId!,
+ assetId: asset.id,
+ },
+ { onSettled: () => setDialogOpen(false) },
+ )
+ }
+ >
+ <Trash2 className="mr-2 size-4" />
+ {t("actions.delete")}
+ </ActionButton>
+ )}
+ >
+ <Button
+ variant="destructive"
+ size="sm"
+ title="Delete"
+ >
+ <Trash2 className="size-4" />
+ </Button>
+ </ActionConfirmingDialog>
+ )}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ )}
+ {hasNextPage && (
+ <div className="flex justify-center">
+ <ActionButton
+ variant="secondary"
+ onClick={() => fetchNextPage()}
+ loading={isFetchingNextPage}
+ ignoreDemoMode={true}
+ >
+ Load More
+ </ActionButton>
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx
index 909dfd9c..62ac041c 100644
--- a/apps/web/app/settings/layout.tsx
+++ b/apps/web/app/settings/layout.tsx
@@ -5,6 +5,7 @@ import { TFunction } from "i18next";
import {
ArrowLeft,
Download,
+ Image,
KeyRound,
Link,
Rss,
@@ -60,6 +61,11 @@ const settingsSidebarItems = (
icon: <Webhook size={18} />,
path: "/settings/webhooks",
},
+ {
+ name: t("settings.manage_assets.manage_assets"),
+ icon: <Image size={18} />,
+ path: "/settings/assets",
+ },
];
export default async function SettingsLayout({
diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx
index 32939cb0..19622f8d 100644
--- a/apps/web/components/dashboard/preview/AttachmentBox.tsx
+++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx
@@ -9,32 +9,18 @@ import {
} from "@/components/ui/collapsible";
import FilePickerButton from "@/components/ui/file-picker-button";
import { toast } from "@/components/ui/use-toast";
+import { ASSET_TYPE_TO_ICON } from "@/lib/attachments";
import useUpload from "@/lib/hooks/upload-file";
import { useTranslation } from "@/lib/i18n/client";
-import {
- Archive,
- Camera,
- ChevronsDownUp,
- Download,
- Image,
- Paperclip,
- Pencil,
- Plus,
- Trash2,
- Video,
-} from "lucide-react";
+import { ChevronsDownUp, Download, Pencil, Plus, Trash2 } from "lucide-react";
import {
useAttachBookmarkAsset,
useDetachBookmarkAsset,
useReplaceBookmarkAsset,
-} from "@hoarder/shared-react/hooks/bookmarks";
+} from "@hoarder/shared-react/hooks/assets";
import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils";
-import {
- BookmarkTypes,
- ZAssetType,
- ZBookmark,
-} from "@hoarder/shared/types/bookmarks";
+import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
import {
humanFriendlyNameForAssertType,
isAllowedToAttachAsset,
@@ -43,17 +29,6 @@ import {
export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
const { t } = useTranslation();
- const typeToIcon: Record<ZAssetType, React.ReactNode> = {
- screenshot: <Camera className="size-4" />,
- assetScreenshot: <Camera className="size-4" />,
- fullPageArchive: <Archive className="size-4" />,
- precrawledArchive: <Archive className="size-4" />,
- bannerImage: <Image className="size-4" />,
- video: <Video className="size-4" />,
- bookmarkAsset: <Paperclip className="size-4" />,
- unknown: <Paperclip className="size-4" />,
- };
-
const { mutate: attachAsset, isPending: isAttaching } =
useAttachBookmarkAsset({
onSuccess: () => {
@@ -124,7 +99,7 @@ export default function AttachmentBox({ bookmark }: { bookmark: ZBookmark }) {
href={getAssetUrl(asset.id)}
className="flex items-center gap-1"
>
- {typeToIcon[asset.assetType]}
+ {ASSET_TYPE_TO_ICON[asset.assetType]}
<p>{humanFriendlyNameForAssertType(asset.assetType)}</p>
</Link>
<div className="flex gap-2">
diff --git a/apps/web/lib/attachments.tsx b/apps/web/lib/attachments.tsx
new file mode 100644
index 00000000..8110d6ce
--- /dev/null
+++ b/apps/web/lib/attachments.tsx
@@ -0,0 +1,14 @@
+import { Archive, Camera, Image, Paperclip, Video } from "lucide-react";
+
+import { ZAssetType } from "@hoarder/shared/types/bookmarks";
+
+export const ASSET_TYPE_TO_ICON: Record<ZAssetType, React.ReactNode> = {
+ screenshot: <Camera className="size-4" />,
+ assetScreenshot: <Camera className="size-4" />,
+ fullPageArchive: <Archive className="size-4" />,
+ precrawledArchive: <Archive className="size-4" />,
+ bannerImage: <Image className="size-4" />,
+ video: <Video className="size-4" />,
+ bookmarkAsset: <Paperclip className="size-4" />,
+ unknown: <Paperclip className="size-4" />,
+};
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 81ef942f..a12b703f 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -9,6 +9,8 @@
"created_at": "Created At",
"key": "Key",
"role": "Role",
+ "type": "Type",
+ "size": "Size",
"roles": {
"user": "User",
"admin": "Admin"
@@ -150,6 +152,15 @@
"last_crawled_at": "Last Crawled At",
"crawling_status": "Crawling Status",
"crawling_failed": "Crawling Failed"
+ },
+ "manage_assets": {
+ "manage_assets": "Manage Assets",
+ "no_assets": "You don't have any assets yet.",
+ "asset_type": "Asset Type",
+ "bookmark_link": "Bookmark Link",
+ "asset_link": "Asset Link",
+ "delete_asset": "Delete Asset",
+ "delete_asset_confirmation": "Are you sure you want to delete this asset?"
}
},
"admin": {
diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts
index 230c9eef..292a6d62 100644
--- a/apps/web/lib/utils.ts
+++ b/apps/web/lib/utils.ts
@@ -8,6 +8,18 @@ export function cn(...inputs: ClassValue[]) {
export type OS = "macos" | "ios" | "windows" | "android" | "linux" | null;
+export function formatBytes(bytes: number, decimals = 2) {
+ if (bytes === 0) return "0 Bytes";
+
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
+
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
+}
+
export function getOS() {
if (typeof window === "undefined") return;
const userAgent = window.navigator.userAgent.toLowerCase();