Cleanup for deploy

This commit is contained in:
Ryan Pandya 2024-12-11 13:16:07 -08:00
parent bfb30405df
commit 2f7e2046e6
67 changed files with 18 additions and 4465 deletions

View File

@ -1,56 +0,0 @@
import { createContextFromRequest } from "@/server/api/client";
import { and, eq } from "drizzle-orm";
import { assets } from "@lifetracker/db/schema";
import { readAsset } from "@lifetracker/shared/assetdb";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: { assetId: string } },
) {
const ctx = await createContextFromRequest(request);
if (!ctx.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const assetDb = await ctx.db.query.assets.findFirst({
where: and(eq(assets.id, params.assetId), eq(assets.userId, ctx.user.id)),
});
if (!assetDb) {
return Response.json({ error: "Asset not found" }, { status: 404 });
}
const { asset, metadata } = await readAsset({
userId: ctx.user.id,
assetId: params.assetId,
});
const range = request.headers.get("Range");
if (range) {
const parts = range.replace(/bytes=/, "").split("-");
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : asset.length - 1;
// TODO: Don't read the whole asset into memory in the first place
const chunk = asset.subarray(start, end + 1);
return new Response(chunk, {
status: 206, // Partial Content
headers: {
"Content-Range": `bytes ${start}-${end}/${asset.length}`,
"Accept-Ranges": "bytes",
"Content-Length": chunk.length.toString(),
"Content-type": metadata.contentType,
},
});
} else {
return new Response(asset, {
status: 200,
headers: {
"Content-Length": asset.length.toString(),
"Content-type": metadata.contentType,
},
});
}
}

View File

@ -1,77 +0,0 @@
import { createContextFromRequest } from "@/server/api/client";
import { TRPCError } from "@trpc/server";
import type { ZUploadResponse } from "@lifetracker/shared/types/uploads";
import { assets, AssetTypes } from "@lifetracker/db/schema";
import {
newAssetId,
saveAsset,
SUPPORTED_UPLOAD_ASSET_TYPES,
} from "@lifetracker/shared/assetdb";
import serverConfig from "@lifetracker/shared/config";
const MAX_UPLOAD_SIZE_BYTES = serverConfig.maxAssetSizeMb * 1024 * 1024;
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
const ctx = await createContextFromRequest(request);
if (!ctx.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
if (serverConfig.demoMode) {
throw new TRPCError({
message: "Mutations are not allowed in demo mode",
code: "FORBIDDEN",
});
}
const formData = await request.formData();
const data = formData.get("file") ?? formData.get("image");
let buffer;
let contentType;
if (data instanceof File) {
contentType = data.type;
if (!SUPPORTED_UPLOAD_ASSET_TYPES.has(contentType)) {
return Response.json(
{ error: "Unsupported asset type" },
{ status: 400 },
);
}
if (data.size > MAX_UPLOAD_SIZE_BYTES) {
return Response.json({ error: "Asset is too big" }, { status: 413 });
}
buffer = Buffer.from(await data.arrayBuffer());
} else {
return Response.json({ error: "Bad request" }, { status: 400 });
}
const fileName = data.name;
const [assetDb] = await ctx.db
.insert(assets)
.values({
id: newAssetId(),
// Initially, uploads are uploaded for unknown purpose
// And without an attached bookmark.
assetType: AssetTypes.UNKNOWN,
bookmarkId: null,
userId: ctx.user.id,
contentType,
size: data.size,
fileName,
})
.returning();
const assetId = assetDb.id;
await saveAsset({
userId: ctx.user.id,
assetId,
metadata: { contentType, fileName },
asset: buffer,
});
return Response.json({
assetId,
contentType,
size: buffer.byteLength,
fileName,
} satisfies ZUploadResponse);
}

View File

@ -1,40 +0,0 @@
import { toExportFormat, zExportSchema } from "@/lib/exportBookmarks";
import { api, createContextFromRequest } from "@/server/api/client";
import { z } from "zod";
import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@hoarder/shared/types/bookmarks";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const ctx = await createContextFromRequest(request);
if (!ctx.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const req = {
limit: MAX_NUM_BOOKMARKS_PER_PAGE,
useCursorV2: true,
};
let resp = await api.bookmarks.getBookmarks(req);
let results = resp.bookmarks.map(toExportFormat);
while (resp.nextCursor) {
resp = await api.bookmarks.getBookmarks({
...request,
cursor: resp.nextCursor,
});
results = [...results, ...resp.bookmarks.map(toExportFormat)];
}
const exportData: z.infer<typeof zExportSchema> = {
bookmarks: results.filter((b) => b.content !== null),
};
return new Response(JSON.stringify(exportData), {
status: 200,
headers: {
"Content-type": "application/json",
"Content-disposition": `attachment; filename="hoarder-export-${new Date().toISOString()}.json"`,
},
});
}

View File

@ -1,18 +0,0 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
export const dynamic = "force-dynamic";
export const GET = (
req: NextRequest,
params: { params: { bookmarkId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
const resp = await api.lists.getListsOfBookmark({
bookmarkId: params.params.bookmarkId,
});
return { status: 200, resp };
},
});

View File

@ -1,50 +0,0 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
import { zUpdateBookmarksRequestSchema } from "@hoarder/shared/types/bookmarks";
export const dynamic = "force-dynamic";
export const GET = (
req: NextRequest,
{ params }: { params: { bookmarkId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
const bookmark = await api.bookmarks.getBookmark({
bookmarkId: params.bookmarkId,
});
return { status: 200, resp: bookmark };
},
});
export const PATCH = (
req: NextRequest,
{ params }: { params: { bookmarkId: string } },
) =>
buildHandler({
req,
bodySchema: zUpdateBookmarksRequestSchema.omit({ bookmarkId: true }),
handler: async ({ api, body }) => {
const bookmark = await api.bookmarks.updateBookmark({
bookmarkId: params.bookmarkId,
...body!,
});
return { status: 200, resp: bookmark };
},
});
export const DELETE = (
req: NextRequest,
{ params }: { params: { bookmarkId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
await api.bookmarks.deleteBookmark({
bookmarkId: params.bookmarkId,
});
return { status: 204 };
},
});

View File

@ -1,45 +0,0 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
import { z } from "zod";
import { zManipulatedTagSchema } from "@hoarder/shared/types/bookmarks";
export const dynamic = "force-dynamic";
export const POST = (
req: NextRequest,
params: { params: { bookmarkId: string } },
) =>
buildHandler({
req,
bodySchema: z.object({
tags: z.array(zManipulatedTagSchema),
}),
handler: async ({ api, body }) => {
const resp = await api.bookmarks.updateTags({
bookmarkId: params.params.bookmarkId,
attach: body!.tags,
detach: [],
});
return { status: 200, resp: { attached: resp.attached } };
},
});
export const DELETE = (
req: NextRequest,
params: { params: { bookmarkId: string } },
) =>
buildHandler({
req,
bodySchema: z.object({
tags: z.array(zManipulatedTagSchema),
}),
handler: async ({ api, body }) => {
const resp = await api.bookmarks.updateTags({
bookmarkId: params.params.bookmarkId,
detach: body!.tags,
attach: [],
});
return { status: 200, resp: { detached: resp.detached } };
},
});

View File

@ -1,37 +0,0 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { zNewBookmarkRequestSchema } from "@hoarder/shared/types/bookmarks";
import { buildHandler } from "../utils/handler";
import { adaptPagination, zPagination } from "../utils/pagination";
import { zStringBool } from "../utils/types";
export const dynamic = "force-dynamic";
export const GET = (req: NextRequest) =>
buildHandler({
req,
searchParamsSchema: z
.object({
favourited: zStringBool.optional(),
archived: zStringBool.optional(),
})
.and(zPagination),
handler: async ({ api, searchParams }) => {
const bookmarks = await api.bookmarks.getBookmarks({
...searchParams,
});
return { status: 200, resp: adaptPagination(bookmarks) };
},
});
export const POST = (req: NextRequest) =>
buildHandler({
req,
bodySchema: zNewBookmarkRequestSchema,
handler: async ({ api, body }) => {
const bookmark = await api.bookmarks.createBookmark(body!);
return { status: 201, resp: bookmark };
},
});

View File

@ -1,35 +0,0 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
export const dynamic = "force-dynamic";
export const PUT = (
req: NextRequest,
{ params }: { params: { listId: string; bookmarkId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
// TODO: PUT is supposed to be idempotent, but we currently fail if the bookmark is already in the list.
await api.lists.addToList({
listId: params.listId,
bookmarkId: params.bookmarkId,
});
return { status: 204 };
},
});
export const DELETE = (
req: NextRequest,
{ params }: { params: { listId: string; bookmarkId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
await api.lists.removeFromList({
listId: params.listId,
bookmarkId: params.bookmarkId,
});
return { status: 204 };
},
});

View File

@ -1,18 +0,0 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
import { adaptPagination, zPagination } from "@/app/api/v1/utils/pagination";
export const dynamic = "force-dynamic";
export const GET = (req: NextRequest, params: { params: { listId: string } }) =>
buildHandler({
req,
searchParamsSchema: zPagination,
handler: async ({ api, searchParams }) => {
const bookmarks = await api.bookmarks.getBookmarks({
listId: params.params.listId,
...searchParams,
});
return { status: 200, resp: adaptPagination(bookmarks) };
},
});

View File

@ -1,55 +0,0 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
import { zNewBookmarkListSchema } from "@hoarder/shared/types/lists";
export const dynamic = "force-dynamic";
export const GET = (
req: NextRequest,
{ params }: { params: { listId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
const list = await api.lists.get({
listId: params.listId,
});
return {
status: 200,
resp: list,
};
},
});
export const PATCH = (
req: NextRequest,
{ params }: { params: { listId: string } },
) =>
buildHandler({
req,
bodySchema: zNewBookmarkListSchema.partial(),
handler: async ({ api, body }) => {
const list = await api.lists.edit({
listId: params.listId,
...body!,
});
return { status: 200, resp: list };
},
});
export const DELETE = (
req: NextRequest,
{ params }: { params: { listId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
await api.lists.delete({
listId: params.listId,
});
return {
status: 204,
};
},
});

View File

@ -1,26 +0,0 @@
import { NextRequest } from "next/server";
import { zNewBookmarkListSchema } from "@hoarder/shared/types/lists";
import { buildHandler } from "../utils/handler";
export const dynamic = "force-dynamic";
export const GET = (req: NextRequest) =>
buildHandler({
req,
handler: async ({ api }) => {
const lists = await api.lists.list();
return { status: 200, resp: lists };
},
});
export const POST = (req: NextRequest) =>
buildHandler({
req,
bodySchema: zNewBookmarkListSchema,
handler: async ({ api, body }) => {
const list = await api.lists.create(body!);
return { status: 201, resp: list };
},
});

View File

@ -1,25 +0,0 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
import { adaptPagination, zPagination } from "@/app/api/v1/utils/pagination";
export const dynamic = "force-dynamic";
export const GET = (
req: NextRequest,
{ params }: { params: { tagId: string } },
) =>
buildHandler({
req,
searchParamsSchema: zPagination,
handler: async ({ api, searchParams }) => {
const bookmarks = await api.bookmarks.getBookmarks({
tagId: params.tagId,
limit: searchParams.limit,
cursor: searchParams.cursor,
});
return {
status: 200,
resp: adaptPagination(bookmarks),
};
},
});

View File

@ -1,55 +0,0 @@
import { NextRequest } from "next/server";
import { buildHandler } from "@/app/api/v1/utils/handler";
import { zUpdateTagRequestSchema } from "@hoarder/shared/types/tags";
export const dynamic = "force-dynamic";
export const GET = (
req: NextRequest,
{ params }: { params: { tagId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
const tag = await api.tags.get({
tagId: params.tagId,
});
return {
status: 200,
resp: tag,
};
},
});
export const PATCH = (
req: NextRequest,
{ params }: { params: { tagId: string } },
) =>
buildHandler({
req,
bodySchema: zUpdateTagRequestSchema.omit({ tagId: true }),
handler: async ({ api, body }) => {
const tag = await api.tags.update({
tagId: params.tagId,
...body!,
});
return { status: 200, resp: tag };
},
});
export const DELETE = (
req: NextRequest,
{ params }: { params: { tagId: string } },
) =>
buildHandler({
req,
handler: async ({ api }) => {
await api.tags.delete({
tagId: params.tagId,
});
return {
status: 204,
};
},
});

View File

@ -1,14 +0,0 @@
import { NextRequest } from "next/server";
import { buildHandler } from "../utils/handler";
export const dynamic = "force-dynamic";
export const GET = (req: NextRequest) =>
buildHandler({
req,
handler: async ({ api }) => {
const tags = await api.tags.list();
return { status: 200, resp: tags };
},
});

View File

@ -1,35 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import BookmarkPreview from "@/components/dashboard/preview/BookmarkPreview";
import { Dialog, DialogContent } from "@/components/ui/dialog";
export default function BookmarkPreviewPage({
params,
}: {
params: { bookmarkId: string };
}) {
const router = useRouter();
const [open, setOpen] = useState(true);
const setOpenWithRouter = (value: boolean) => {
setOpen(value);
if (!value) {
router.back();
}
};
return (
<Dialog open={open} onOpenChange={setOpenWithRouter}>
<DialogContent
className="h-[90%] max-w-[90%] overflow-hidden p-0"
hideCloseBtn={true}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<BookmarkPreview bookmarkId={params.bookmarkId} />
</DialogContent>
</Dialog>
);
}

View File

@ -1,3 +0,0 @@
export default function CatchAll() {
return null;
}

View File

@ -1,3 +0,0 @@
export default function Default() {
return null;
}

View File

@ -1,24 +0,0 @@
import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
import InfoTooltip from "@/components/ui/info-tooltip";
function header() {
return (
<div className="flex gap-2">
<p className="text-2xl">🗄 Archive</p>
<InfoTooltip size={17} className="my-auto" variant="explain">
<p>Archived bookmarks won&apos;t appear in the homepage</p>
</InfoTooltip>
</div>
);
}
export default async function ArchivedBookmarkPage() {
return (
<Bookmarks
header={header()}
query={{ archived: true }}
showDivider={true}
showEditorCard={true}
/>
);
}

View File

@ -1,47 +0,0 @@
import { notFound } from "next/navigation";
import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
import EditableTagName from "@/components/dashboard/tags/EditableTagName";
import { TagOptions } from "@/components/dashboard/tags/TagOptions";
import { Button } from "@/components/ui/button";
import { api } from "@/server/api/client";
import { TRPCError } from "@trpc/server";
import { MoreHorizontal } from "lucide-react";
export default async function TagPage({
params,
}: {
params: { tagId: string };
}) {
let tag;
try {
tag = await api.tags.get({ tagId: params.tagId });
} catch (e) {
if (e instanceof TRPCError) {
if (e.code == "NOT_FOUND") {
notFound();
}
}
throw e;
}
return (
<Bookmarks
header={
<div className="flex justify-between">
<EditableTagName
tag={{ id: tag.id, name: tag.name }}
className="text-2xl"
/>
<TagOptions tag={tag}>
<Button variant="ghost">
<MoreHorizontal />
</Button>
</TagOptions>
</div>
}
query={{ tagId: tag.id }}
showEditorCard={true}
/>
);
}

View File

@ -1,21 +0,0 @@
import { TagDuplicationDetection } from "@/components/dashboard/cleanups/TagDuplicationDetention";
import { Separator } from "@/components/ui/separator";
import { Paintbrush, Tags } from "lucide-react";
export default function Cleanups() {
return (
<div className="flex flex-col gap-y-4 rounded-md border bg-background p-4">
<span className="flex items-center gap-1 text-2xl">
<Paintbrush />
Cleanups
</span>
<Separator />
<span className="flex items-center gap-1 text-xl">
<Tags />
Duplicate Tags
</span>
<Separator />
<TagDuplicationDetection />
</div>
);
}

View File

@ -1,16 +0,0 @@
import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
export default async function FavouritesBookmarkPage() {
return (
<Bookmarks
header={
<div className="flex items-center justify-between">
<p className="text-2xl"> Favourites</p>
</div>
}
query={{ favourited: true }}
showDivider={true}
showEditorCard={true}
/>
);
}

View File

@ -1,31 +0,0 @@
import { notFound } from "next/navigation";
import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
import { api } from "@/server/api/client";
import { TRPCError } from "@trpc/server";
export default async function FeedPage({
params,
}: {
params: { feedId: string };
}) {
let feed;
try {
feed = await api.feeds.get({ feedId: params.feedId });
} catch (e) {
if (e instanceof TRPCError) {
if (e.code == "NOT_FOUND") {
notFound();
}
}
throw e;
}
return (
<Bookmarks
query={{ rssFeedId: feed.id }}
showDivider={true}
showEditorCard={false}
header={<div className="text-2xl">{feed.name}</div>}
/>
);
}

View File

@ -1,32 +0,0 @@
import { notFound } from "next/navigation";
import Bookmarks from "@/components/dashboard/bookmarks/Bookmarks";
import ListHeader from "@/components/dashboard/lists/ListHeader";
import { api } from "@/server/api/client";
import { TRPCError } from "@trpc/server";
export default async function ListPage({
params,
}: {
params: { listId: string };
}) {
let list;
try {
list = await api.lists.get({ listId: params.listId });
} catch (e) {
if (e instanceof TRPCError) {
if (e.code == "NOT_FOUND") {
notFound();
}
}
throw e;
}
return (
<Bookmarks
query={{ listId: list.id }}
showDivider={true}
showEditorCard={true}
header={<ListHeader initialData={list} />}
/>
);
}

View File

@ -1,15 +0,0 @@
import AllListsView from "@/components/dashboard/lists/AllListsView";
import { Separator } from "@/components/ui/separator";
import { api } from "@/server/api/client";
export default async function ListsPage() {
const lists = await api.lists.list();
return (
<div className="flex flex-col gap-3 rounded-md border bg-background p-4">
<p className="text-2xl">📋 All Lists</p>
<Separator />
<AllListsView initialData={lists.lists} />
</div>
);
}

View File

@ -1,30 +0,0 @@
import { notFound } from "next/navigation";
import BookmarkPreview from "@/components/dashboard/preview/BookmarkPreview";
import { api } from "@/server/api/client";
import { TRPCError } from "@trpc/server";
export default async function BookmarkPreviewPage({
params,
}: {
params: { bookmarkId: string };
}) {
let bookmark;
try {
bookmark = await api.bookmarks.getBookmark({
bookmarkId: params.bookmarkId,
});
} catch (e) {
if (e instanceof TRPCError) {
if (e.code === "NOT_FOUND") {
notFound();
}
}
throw e;
}
return (
<div className="max-h-screen">
<BookmarkPreview bookmarkId={bookmark.id} initialData={bookmark} />
</div>
);
}

View File

@ -1,28 +0,0 @@
"use client";
import { Suspense } from "react";
import BookmarksGrid from "@/components/dashboard/bookmarks/BookmarksGrid";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { useBookmarkSearch } from "@/lib/hooks/bookmark-search";
function SearchComp() {
const { data } = useBookmarkSearch();
return (
<div className="flex flex-col gap-3">
{data ? (
<BookmarksGrid bookmarks={data.bookmarks} />
) : (
<FullPageSpinner />
)}
</div>
);
}
export default function SearchPage() {
return (
<Suspense>
<SearchComp />
</Suspense>
);
}

View File

@ -1,5 +0,0 @@
import AISettings from "@/components/settings/AISettings";
export default function AISettingsPage() {
return <AISettings />;
}

View File

@ -1,5 +0,0 @@
import FeedSettings from "@/components/settings/FeedSettings";
export default function FeedSettingsPage() {
return <FeedSettings />;
}

View File

@ -1,9 +0,0 @@
import ImportExport from "@/components/settings/ImportExport";
export default function ImportSettingsPage() {
return (
<div className="rounded-md border bg-background p-4">
<ImportExport />
</div>
);
}

View File

@ -1,75 +0,0 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import type { ZBookmarkTypeAsset } from "@hoarder/shared/types/bookmarks";
import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils";
import { getSourceUrl } from "@hoarder/shared-react/utils/bookmarkUtils";
import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard";
import FooterLinkURL from "./FooterLinkURL";
function AssetImage({
bookmark,
className,
}: {
bookmark: ZBookmarkTypeAsset;
className?: string;
}) {
const bookmarkedAsset = bookmark.content;
switch (bookmarkedAsset.assetType) {
case "image": {
return (
<Link href={`/dashboard/preview/${bookmark.id}`}>
<Image
alt="asset"
src={getAssetUrl(bookmarkedAsset.assetId)}
fill={true}
className={className}
/>
</Link>
);
}
case "pdf": {
return (
<iframe
title={bookmarkedAsset.assetId}
className={className}
src={getAssetUrl(bookmarkedAsset.assetId)}
/>
);
}
default: {
const _exhaustiveCheck: never = bookmarkedAsset.assetType;
return <span />;
}
}
}
export default function AssetCard({
bookmark: bookmarkedAsset,
className,
}: {
bookmark: ZBookmarkTypeAsset;
className?: string;
}) {
return (
<BookmarkLayoutAdaptingCard
title={bookmarkedAsset.title ?? bookmarkedAsset.content.fileName}
footer={
getSourceUrl(bookmarkedAsset) && (
<FooterLinkURL url={getSourceUrl(bookmarkedAsset)} />
)
}
bookmark={bookmarkedAsset}
className={className}
wrapTags={true}
image={(_layout, className) => (
<div className="relative size-full flex-1">
<AssetImage bookmark={bookmarkedAsset} className={className} />
</div>
)}
/>
);
}

View File

@ -1,30 +0,0 @@
import Link from "next/link";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Maximize2 } from "lucide-react";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
import BookmarkOptions from "./BookmarkOptions";
import { FavouritedActionIcon } from "./icons";
export default function BookmarkActionBar({
bookmark,
}: {
bookmark: ZBookmark;
}) {
return (
<div className="flex text-gray-500">
{bookmark.favourited && (
<FavouritedActionIcon className="m-1 size-8 rounded p-1" favourited />
)}
<Link
href={`/dashboard/preview/${bookmark.id}`}
className={cn(buttonVariants({ variant: "ghost" }), "px-2")}
>
<Maximize2 size={16} />
</Link>
<BookmarkOptions bookmark={bookmark} />
</div>
);
}

View File

@ -1,59 +0,0 @@
import { api } from "@/lib/trpc";
import { isBookmarkStillLoading } from "@hoarder/shared-react/utils/bookmarkUtils";
import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
import AssetCard from "./AssetCard";
import LinkCard from "./LinkCard";
import TextCard from "./TextCard";
export default function BookmarkCard({
bookmark: initialData,
className,
}: {
bookmark: ZBookmark;
className?: string;
}) {
const { data: bookmark } = api.bookmarks.getBookmark.useQuery(
{
bookmarkId: initialData.id,
},
{
initialData,
refetchInterval: (query) => {
const data = query.state.data;
if (!data) {
return false;
}
if (isBookmarkStillLoading(data)) {
return 1000;
}
return false;
},
},
);
switch (bookmark.content.type) {
case BookmarkTypes.LINK:
return (
<LinkCard
className={className}
bookmark={{ ...bookmark, content: bookmark.content }}
/>
);
case BookmarkTypes.TEXT:
return (
<TextCard
className={className}
bookmark={{ ...bookmark, content: bookmark.content }}
/>
);
case BookmarkTypes.ASSET:
return (
<AssetCard
className={className}
bookmark={{ ...bookmark, content: bookmark.content }}
/>
);
}
}

View File

@ -1,253 +0,0 @@
import type { BookmarksLayoutTypes } from "@/lib/userLocalSettings/types";
import React, { useEffect, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import useBulkActionsStore from "@/lib/bulkActions";
import {
bookmarkLayoutSwitch,
useBookmarkLayout,
} from "@/lib/userLocalSettings/bookmarksLayout";
import { cn } from "@/lib/utils";
import dayjs from "dayjs";
import { Check, Image as ImageIcon, NotebookPen } from "lucide-react";
import { useTheme } from "next-themes";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
import { isBookmarkStillTagging } from "@hoarder/shared-react/utils/bookmarkUtils";
import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";
import BookmarkActionBar from "./BookmarkActionBar";
import TagList from "./TagList";
interface Props {
bookmark: ZBookmark;
image: (layout: BookmarksLayoutTypes, className: string) => React.ReactNode;
title?: React.ReactNode;
content?: React.ReactNode;
footer?: React.ReactNode;
className?: string;
fitHeight?: boolean;
wrapTags: boolean;
}
function BottomRow({
footer,
bookmark,
}: {
footer?: React.ReactNode;
bookmark: ZBookmark;
}) {
return (
<div className="justify flex w-full shrink-0 justify-between text-gray-500">
<div className="flex items-center gap-2 overflow-hidden text-nowrap">
{footer && <>{footer}</>}
<Link
href={`/dashboard/preview/${bookmark.id}`}
suppressHydrationWarning
>
{dayjs(bookmark.createdAt).format("MMM DD")}
</Link>
</div>
<BookmarkActionBar bookmark={bookmark} />
</div>
);
}
function MultiBookmarkSelector({ bookmark }: { bookmark: ZBookmark }) {
const { selectedBookmarks, isBulkEditEnabled } = useBulkActionsStore();
const toggleBookmark = useBulkActionsStore((state) => state.toggleBookmark);
const [isSelected, setIsSelected] = useState(false);
const { theme } = useTheme();
useEffect(() => {
setIsSelected(selectedBookmarks.some((item) => item.id === bookmark.id));
}, [selectedBookmarks]);
if (!isBulkEditEnabled) return null;
const getIconColor = () => {
if (theme === "dark") {
return isSelected ? "black" : "white";
}
return isSelected ? "white" : "black";
};
const getIconBackgroundColor = () => {
if (theme === "dark") {
return isSelected ? "bg-white" : "bg-white bg-opacity-10";
}
return isSelected ? "bg-black" : "bg-white bg-opacity-40";
};
return (
<button
className={cn(
"absolute left-0 top-0 z-50 h-full w-full bg-opacity-0",
{
"bg-opacity-10": isSelected,
},
theme === "dark" ? "bg-white" : "bg-black",
)}
onClick={() => toggleBookmark(bookmark)}
>
<div className="absolute right-2 top-2 z-50 opacity-100">
<div
className={cn(
"flex h-4 w-4 items-center justify-center rounded-full border border-gray-600",
getIconBackgroundColor(),
)}
>
<Check size={12} color={getIconColor()} />
</div>
</div>
</button>
);
}
function ListView({
bookmark,
image,
title,
content,
footer,
className,
}: Props) {
return (
<div
className={cn(
"relative flex max-h-96 gap-4 overflow-hidden rounded-lg p-2 shadow-md",
className,
)}
>
<MultiBookmarkSelector bookmark={bookmark} />
<div className="flex size-32 items-center justify-center overflow-hidden">
{image("list", "object-cover rounded-lg size-32")}
</div>
<div className="flex h-full flex-1 flex-col justify-between gap-2 overflow-hidden">
<div className="flex flex-col gap-2 overflow-hidden">
{title && (
<div className="line-clamp-2 flex-none shrink-0 overflow-hidden text-ellipsis break-words text-lg">
{title}
</div>
)}
{content && <div className="shrink-1 overflow-hidden">{content}</div>}
<div className="flex shrink-0 flex-wrap gap-1 overflow-hidden">
<TagList
bookmark={bookmark}
loading={isBookmarkStillTagging(bookmark)}
/>
</div>
</div>
<BottomRow footer={footer} bookmark={bookmark} />
</div>
</div>
);
}
function GridView({
bookmark,
image,
title,
content,
footer,
className,
wrapTags,
layout,
fitHeight = false,
}: Props & { layout: BookmarksLayoutTypes }) {
const img = image("grid", "h-56 min-h-56 w-full object-cover rounded-t-lg");
return (
<div
className={cn(
"relative flex flex-col overflow-hidden rounded-lg shadow-md",
className,
fitHeight && layout != "grid" ? "max-h-96" : "h-96",
)}
>
<MultiBookmarkSelector bookmark={bookmark} />
{img && <div className="h-56 w-full shrink-0 overflow-hidden">{img}</div>}
<div className="flex h-full flex-col justify-between gap-2 overflow-hidden p-2">
<div className="grow-1 flex flex-col gap-2 overflow-hidden">
{title && (
<div className="line-clamp-2 flex-none shrink-0 overflow-hidden text-ellipsis break-words text-lg">
{title}
</div>
)}
{content && <div className="shrink-1 overflow-hidden">{content}</div>}
<div className="flex shrink-0 flex-wrap gap-1 overflow-hidden">
<TagList
className={wrapTags ? undefined : "h-full"}
bookmark={bookmark}
loading={isBookmarkStillTagging(bookmark)}
/>
</div>
</div>
<BottomRow footer={footer} bookmark={bookmark} />
</div>
</div>
);
}
function CompactView({ bookmark, title, footer, className }: Props) {
return (
<div
className={cn(
"relative flex flex-col overflow-hidden rounded-lg shadow-md",
className,
"max-h-96",
)}
>
<MultiBookmarkSelector bookmark={bookmark} />
<div className="flex h-full justify-between gap-2 overflow-hidden p-2">
<div className="flex items-center gap-2">
{bookmark.content.type === BookmarkTypes.LINK &&
bookmark.content.favicon && (
<Image
src={bookmark.content.favicon}
alt="favicon"
width={5}
unoptimized
height={5}
className="size-5"
/>
)}
{bookmark.content.type === BookmarkTypes.TEXT && (
<NotebookPen className="size-5" />
)}
{bookmark.content.type === BookmarkTypes.ASSET && (
<ImageIcon className="size-5" />
)}
{
<div className="shrink-1 text-md line-clamp-1 overflow-hidden text-ellipsis break-words">
{title ?? "Untitled"}
</div>
}
{footer && (
<p className="flex shrink-0 gap-2 text-gray-500">{footer}</p>
)}
<p className="text-gray-500"></p>
<Link
href={`/dashboard/preview/${bookmark.id}`}
suppressHydrationWarning
className="shrink-0 gap-2 text-gray-500"
>
{dayjs(bookmark.createdAt).format("MMM DD")}
</Link>
</div>
<BookmarkActionBar bookmark={bookmark} />
</div>
</div>
);
}
export function BookmarkLayoutAdaptingCard(props: Props) {
const layout = useBookmarkLayout();
return bookmarkLayoutSwitch(layout, {
masonry: <GridView layout={layout} {...props} />,
grid: <GridView layout={layout} {...props} />,
list: <ListView {...props} />,
compact: <CompactView {...props} />,
});
}

View File

@ -1,245 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useToast } from "@/components/ui/use-toast";
import { useClientConfig } from "@/lib/clientConfig";
import {
FileDown,
Link,
List,
ListX,
MoreHorizontal,
Pencil,
RotateCw,
Tags,
Trash2,
} from "lucide-react";
import type {
ZBookmark,
ZBookmarkedLink,
} from "@hoarder/shared/types/bookmarks";
import {
useDeleteBookmark,
useRecrawlBookmark,
useUpdateBookmark,
} from "@hoarder/shared-react/hooks//bookmarks";
import { useRemoveBookmarkFromList } from "@hoarder/shared-react/hooks//lists";
import { useBookmarkGridContext } from "@hoarder/shared-react/hooks/bookmark-grid-context";
import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";
import { BookmarkedTextEditor } from "./BookmarkedTextEditor";
import { ArchivedActionIcon, FavouritedActionIcon } from "./icons";
import { useManageListsModal } from "./ManageListsModal";
import { useTagModel } from "./TagModal";
export default function BookmarkOptions({ bookmark }: { bookmark: ZBookmark }) {
const { toast } = useToast();
const linkId = bookmark.id;
const demoMode = !!useClientConfig().demoMode;
const { setOpen: setTagModalIsOpen, content: tagModal } =
useTagModel(bookmark);
const { setOpen: setManageListsModalOpen, content: manageListsModal } =
useManageListsModal(bookmark.id);
const [isTextEditorOpen, setTextEditorOpen] = useState(false);
const { listId } = useBookmarkGridContext() ?? {};
const onError = () => {
toast({
variant: "destructive",
title: "Something went wrong",
description: "There was a problem with your request.",
});
};
const deleteBookmarkMutator = useDeleteBookmark({
onSuccess: () => {
toast({
description: "The bookmark has been deleted!",
});
},
onError,
});
const updateBookmarkMutator = useUpdateBookmark({
onSuccess: () => {
toast({
description: "The bookmark has been updated!",
});
},
onError,
});
const crawlBookmarkMutator = useRecrawlBookmark({
onSuccess: () => {
toast({
description: "Re-fetch has been enqueued!",
});
},
onError,
});
const fullPageArchiveBookmarkMutator = useRecrawlBookmark({
onSuccess: () => {
toast({
description: "Full Page Archive creation has been triggered",
});
},
onError,
});
const removeFromListMutator = useRemoveBookmarkFromList({
onSuccess: () => {
toast({
description: "The bookmark has been deleted from the list",
});
},
onError,
});
return (
<>
{tagModal}
{manageListsModal}
<BookmarkedTextEditor
bookmark={bookmark}
open={isTextEditorOpen}
setOpen={setTextEditorOpen}
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="px-1 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-fit">
{bookmark.content.type === BookmarkTypes.TEXT && (
<DropdownMenuItem onClick={() => setTextEditorOpen(true)}>
<Pencil className="mr-2 size-4" />
<span>Edit</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={demoMode}
onClick={() =>
updateBookmarkMutator.mutate({
bookmarkId: linkId,
favourited: !bookmark.favourited,
})
}
>
<FavouritedActionIcon
className="mr-2 size-4"
favourited={bookmark.favourited}
/>
<span>{bookmark.favourited ? "Un-favourite" : "Favourite"}</span>
</DropdownMenuItem>
<DropdownMenuItem
disabled={demoMode}
onClick={() =>
updateBookmarkMutator.mutate({
bookmarkId: linkId,
archived: !bookmark.archived,
})
}
>
<ArchivedActionIcon
className="mr-2 size-4"
archived={bookmark.archived}
/>
<span>{bookmark.archived ? "Un-archive" : "Archive"}</span>
</DropdownMenuItem>
{bookmark.content.type === BookmarkTypes.LINK && (
<DropdownMenuItem
onClick={() => {
fullPageArchiveBookmarkMutator.mutate({
bookmarkId: bookmark.id,
archiveFullPage: true,
});
}}
>
<FileDown className="mr-2 size-4" />
<span>Download Full Page Archive</span>
</DropdownMenuItem>
)}
{bookmark.content.type === BookmarkTypes.LINK && (
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
(bookmark.content as ZBookmarkedLink).url,
);
toast({
description: "Link was added to your clipboard!",
});
}}
>
<Link className="mr-2 size-4" />
<span>Copy Link</span>
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => setTagModalIsOpen(true)}>
<Tags className="mr-2 size-4" />
<span>Edit Tags</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setManageListsModalOpen(true)}>
<List className="mr-2 size-4" />
<span>Manage Lists</span>
</DropdownMenuItem>
{listId && (
<DropdownMenuItem
disabled={demoMode}
onClick={() =>
removeFromListMutator.mutate({
listId,
bookmarkId: bookmark.id,
})
}
>
<ListX className="mr-2 size-4" />
<span>Remove from List</span>
</DropdownMenuItem>
)}
{bookmark.content.type === BookmarkTypes.LINK && (
<DropdownMenuItem
disabled={demoMode}
onClick={() =>
crawlBookmarkMutator.mutate({ bookmarkId: bookmark.id })
}
>
<RotateCw className="mr-2 size-4" />
<span>Refresh</span>
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={demoMode}
className="text-destructive"
onClick={() =>
deleteBookmarkMutator.mutate({ bookmarkId: bookmark.id })
}
>
<Trash2 className="mr-2 size-4" />
<span>Delete</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

View File

@ -1,48 +0,0 @@
import { toast } from "@/components/ui/use-toast";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
import { useUpdateBookmarkTags } from "@hoarder/shared-react/hooks/bookmarks";
import { TagsEditor } from "./TagsEditor";
export function BookmarkTagsEditor({ bookmark }: { bookmark: ZBookmark }) {
const { mutate } = useUpdateBookmarkTags({
onSuccess: () => {
toast({
description: "Tags has been updated!",
});
},
onError: () => {
toast({
variant: "destructive",
title: "Something went wrong",
description: "There was a problem with your request.",
});
},
});
return (
<TagsEditor
tags={bookmark.tags}
onAttach={({ tagName, tagId }) => {
mutate({
bookmarkId: bookmark.id,
attach: [
{
tagName,
tagId,
},
],
detach: [],
});
}}
onDetach={({ tagId }) => {
mutate({
bookmarkId: bookmark.id,
attach: [],
detach: [{ tagId }],
});
}}
/>
);
}

View File

@ -1,81 +0,0 @@
import { useState } from "react";
import { ActionButton } from "@/components/ui/action-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "@/components/ui/use-toast";
import { useUpdateBookmarkText } from "@hoarder/shared-react/hooks/bookmarks";
import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
export function BookmarkedTextEditor({
bookmark,
open,
setOpen,
}: {
bookmark: ZBookmark;
open: boolean;
setOpen: (open: boolean) => void;
}) {
const isNewBookmark = bookmark === undefined;
const [noteText, setNoteText] = useState(
bookmark && bookmark.content.type == BookmarkTypes.TEXT
? bookmark.content.text
: "",
);
const { mutate: updateBookmarkMutator, isPending } = useUpdateBookmarkText({
onSuccess: () => {
toast({
description: "Note updated!",
});
setOpen(false);
},
onError: () => {
toast({ description: "Something went wrong", variant: "destructive" });
},
});
const onSave = () => {
updateBookmarkMutator({
bookmarkId: bookmark.id,
text: noteText,
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{isNewBookmark ? "New Note" : "Edit Note"}</DialogTitle>
<DialogDescription>
Write your note with markdown support
</DialogDescription>
</DialogHeader>
<Textarea
value={noteText}
onChange={(e) => setNoteText(e.target.value)}
className="h-52 grow"
/>
<DialogFooter className="flex-shrink gap-1 sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<ActionButton type="button" loading={isPending} onClick={onSave}>
Save
</ActionButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,39 +0,0 @@
import { redirect } from "next/navigation";
import { Separator } from "@/components/ui/separator";
import { api } from "@/server/api/client";
import { getServerAuthSession } from "@/server/auth";
import type { ZGetBookmarksRequest } from "@hoarder/shared/types/bookmarks";
import UpdatableBookmarksGrid from "./UpdatableBookmarksGrid";
export default async function Bookmarks({
query,
header,
showDivider,
showEditorCard = false,
}: {
query: ZGetBookmarksRequest;
header?: React.ReactNode;
showDivider?: boolean;
showEditorCard?: boolean;
}) {
const session = await getServerAuthSession();
if (!session) {
redirect("/");
}
const bookmarks = await api.bookmarks.getBookmarks(query);
return (
<div className="flex flex-col gap-3">
{header}
{showDivider && <Separator />}
<UpdatableBookmarksGrid
query={query}
bookmarks={bookmarks}
showEditorCard={showEditorCard}
/>
</div>
);
}

View File

@ -1,117 +0,0 @@
import { useEffect, useMemo } from "react";
import { ActionButton } from "@/components/ui/action-button";
import useBulkActionsStore from "@/lib/bulkActions";
import {
bookmarkLayoutSwitch,
useBookmarkLayout,
} from "@/lib/userLocalSettings/bookmarksLayout";
import tailwindConfig from "@/tailwind.config";
import { Slot } from "@radix-ui/react-slot";
import { useInView } from "react-intersection-observer";
import Masonry from "react-masonry-css";
import resolveConfig from "tailwindcss/resolveConfig";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
import BookmarkCard from "./BookmarkCard";
import EditorCard from "./EditorCard";
function StyledBookmarkCard({ children }: { children: React.ReactNode }) {
return (
<Slot className="mb-4 border border-border bg-card duration-300 ease-in hover:shadow-lg hover:transition-all">
{children}
</Slot>
);
}
function getBreakpointConfig() {
const fullConfig = resolveConfig(tailwindConfig);
const breakpointColumnsObj: { [key: number]: number; default: number } = {
default: 3,
};
breakpointColumnsObj[parseInt(fullConfig.theme.screens.lg)] = 2;
breakpointColumnsObj[parseInt(fullConfig.theme.screens.md)] = 1;
breakpointColumnsObj[parseInt(fullConfig.theme.screens.sm)] = 1;
return breakpointColumnsObj;
}
export default function BookmarksGrid({
bookmarks,
hasNextPage = false,
fetchNextPage = () => ({}),
isFetchingNextPage = false,
showEditorCard = false,
}: {
bookmarks: ZBookmark[];
showEditorCard?: boolean;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
fetchNextPage?: () => void;
}) {
const layout = useBookmarkLayout();
const bulkActionsStore = useBulkActionsStore();
const breakpointConfig = useMemo(() => getBreakpointConfig(), []);
const { ref: loadMoreRef, inView: loadMoreButtonInView } = useInView();
useEffect(() => {
bulkActionsStore.setVisibleBookmarks(bookmarks);
return () => {
bulkActionsStore.setVisibleBookmarks([]);
};
}, [bookmarks]);
useEffect(() => {
if (loadMoreButtonInView && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
}, [loadMoreButtonInView]);
if (bookmarks.length == 0 && !showEditorCard) {
return <p>No bookmarks</p>;
}
const children = [
showEditorCard && (
<StyledBookmarkCard key={"editor"}>
<EditorCard />
</StyledBookmarkCard>
),
...bookmarks.map((b) => (
<StyledBookmarkCard key={b.id}>
<BookmarkCard bookmark={b} />
</StyledBookmarkCard>
)),
];
return (
<>
{bookmarkLayoutSwitch(layout, {
masonry: (
<Masonry className="flex gap-4" breakpointCols={breakpointConfig}>
{children}
</Masonry>
),
grid: (
<Masonry className="flex gap-4" breakpointCols={breakpointConfig}>
{children}
</Masonry>
),
list: <div className="grid grid-cols-1">{children}</div>,
compact: <div className="grid grid-cols-1">{children}</div>,
})}
{hasNextPage && (
<div className="flex justify-center">
<ActionButton
ref={loadMoreRef}
ignoreDemoMode={true}
loading={isFetchingNextPage}
onClick={() => fetchNextPage()}
variant="ghost"
>
Load More
</ActionButton>
</div>
)}
</>
);
}

View File

@ -1,138 +0,0 @@
import { ActionButton } from "@/components/ui/action-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { toast } from "@/components/ui/use-toast";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useAddBookmarkToList } from "@hoarder/shared-react/hooks/lists";
import { BookmarkListSelector } from "../lists/BookmarkListSelector";
export default function BulkManageListsModal({
bookmarkIds,
open,
setOpen,
}: {
bookmarkIds: string[];
open: boolean;
setOpen: (open: boolean) => void;
}) {
const formSchema = z.object({
listId: z.string({
required_error: "Please select a list",
}),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
listId: undefined,
},
});
const { mutateAsync: addToList, isPending: isAddingToListPending } =
useAddBookmarkToList({
onSettled: () => {
form.resetField("listId");
},
onError: (e) => {
if (e.data?.code == "BAD_REQUEST") {
toast({
variant: "destructive",
description: e.message,
});
} else {
toast({
variant: "destructive",
title: "Something went wrong",
});
}
},
});
const onSubmit = async (value: z.infer<typeof formSchema>) => {
const results = await Promise.allSettled(
bookmarkIds.map((bookmarkId) =>
addToList({
bookmarkId,
listId: value.listId,
}),
),
);
const successes = results.filter((r) => r.status == "fulfilled").length;
if (successes > 0) {
toast({
description: `${successes} bookmarks have been added to the list!`,
});
}
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<Form {...form}>
<form
className="flex w-full flex-col gap-4"
onSubmit={form.handleSubmit(onSubmit)}
>
<DialogHeader>
<DialogTitle>
Add {bookmarkIds.length} bookmarks to List
</DialogTitle>
</DialogHeader>
<FormField
control={form.control}
name="listId"
render={({ field }) => {
return (
<FormItem>
<FormControl>
<BookmarkListSelector
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<ActionButton
type="submit"
loading={isAddingToListPending}
disabled={isAddingToListPending}
>
Add
</ActionButton>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -1,126 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { toast } from "@/components/ui/use-toast";
import { useUpdateBookmarkTags } from "@hoarder/shared-react/hooks/bookmarks";
import { api } from "@hoarder/shared-react/trpc";
import { ZBookmark } from "@hoarder/shared/types/bookmarks";
import { TagsEditor } from "./TagsEditor";
export default function BulkTagModal({
bookmarkIds,
open,
setOpen,
}: {
bookmarkIds: string[];
open: boolean;
setOpen: (open: boolean) => void;
}) {
const results = api.useQueries((t) =>
bookmarkIds.map((id) => t.bookmarks.getBookmark({ bookmarkId: id })),
);
const bookmarks = results
.map((r) => r.data)
.filter((b): b is ZBookmark => !!b);
const { mutateAsync } = useUpdateBookmarkTags({
onError: (err) => {
if (err.data?.code == "BAD_REQUEST") {
if (err.data.zodError) {
toast({
variant: "destructive",
description: Object.values(err.data.zodError.fieldErrors)
.flat()
.join("\n"),
});
} else {
toast({
variant: "destructive",
description: err.message,
});
}
} else {
toast({
variant: "destructive",
title: "Something went wrong",
});
}
},
});
const onAttach = async (tag: { tagName: string; tagId?: string }) => {
const results = await Promise.allSettled(
bookmarkIds.map((id) =>
mutateAsync({
bookmarkId: id,
attach: [tag],
detach: [],
}),
),
);
const successes = results.filter((r) => r.status == "fulfilled").length;
toast({
description: `Tag "${tag.tagName}" has been added to ${successes} bookmarks!`,
});
};
const onDetach = async ({
tagId,
tagName,
}: {
tagId: string;
tagName: string;
}) => {
const results = await Promise.allSettled(
bookmarkIds.map((id) =>
mutateAsync({
bookmarkId: id,
attach: [],
detach: [{ tagId }],
}),
),
);
const successes = results.filter((r) => r.status == "fulfilled").length;
toast({
description: `Tag "${tagName}" has been removed from ${successes} bookmarks!`,
});
};
// Get all the tags that are attached to all the bookmarks
let tags = bookmarks
.flatMap((b) => b.tags)
.filter((tag) =>
bookmarks.every((b) => b.tags.some((t) => tag.id == t.id)),
);
// Filter duplicates
tags = tags.filter(
(tag, index, self) => index === self.findIndex((t) => t.id == tag.id),
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Tags of {bookmarks.length} Bookmarks</DialogTitle>
</DialogHeader>
<TagsEditor tags={tags} onAttach={onAttach} onDetach={onDetach} />
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -1,279 +0,0 @@
import type { SubmitErrorHandler, SubmitHandler } from "react-hook-form";
import React, { useEffect, useImperativeHandle, useRef } from "react";
import { ActionButton } from "@/components/ui/action-button";
import { Form, FormControl, FormItem } from "@/components/ui/form";
import InfoTooltip from "@/components/ui/info-tooltip";
import MultipleChoiceDialog from "@/components/ui/multiple-choice-dialog";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "@/components/ui/use-toast";
import BookmarkAlreadyExistsToast from "@/components/utils/BookmarkAlreadyExistsToast";
import { useClientConfig } from "@/lib/clientConfig";
import {
useBookmarkLayout,
useBookmarkLayoutSwitch,
} from "@/lib/userLocalSettings/bookmarksLayout";
import { cn, getOS } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useCreateBookmarkWithPostHook } from "@hoarder/shared-react/hooks/bookmarks";
import { BookmarkTypes } from "@hoarder/shared/types/bookmarks";
import { useUploadAsset } from "../UploadDropzone";
function useFocusOnKeyPress(inputRef: React.RefObject<HTMLTextAreaElement>) {
useEffect(() => {
function handleKeyPress(e: KeyboardEvent) {
if (!inputRef.current) {
return;
}
if ((e.metaKey || e.ctrlKey) && e.code === "KeyE") {
inputRef.current.focus();
e.preventDefault();
}
}
document.addEventListener("keydown", handleKeyPress);
return () => {
document.removeEventListener("keydown", handleKeyPress);
};
}, [inputRef]);
}
interface MultiUrlImportState {
urls: URL[];
text: string;
}
export default function EditorCard({ className }: { className?: string }) {
const inputRef = useRef<HTMLTextAreaElement>(null);
const [multiUrlImportState, setMultiUrlImportState] =
React.useState<MultiUrlImportState | null>(null);
const demoMode = !!useClientConfig().demoMode;
const bookmarkLayout = useBookmarkLayout();
const formSchema = z.object({
text: z.string(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
text: "",
},
});
const { ref, ...textFieldProps } = form.register("text");
useImperativeHandle(ref, () => inputRef.current);
useFocusOnKeyPress(inputRef);
const { mutate, isPending } = useCreateBookmarkWithPostHook({
onSuccess: (resp) => {
if (resp.alreadyExists) {
toast({
description: <BookmarkAlreadyExistsToast bookmarkId={resp.id} />,
variant: "default",
});
}
form.reset();
// if the list layout is used, we reset the size of the editor card to the original size after submitting
if (bookmarkLayout === "list" && inputRef?.current?.style) {
inputRef.current.style.height = "auto";
}
},
onError: (e) => {
toast({ description: e.message, variant: "destructive" });
},
});
const uploadAsset = useUploadAsset();
function tryToImportUrls(text: string): void {
const lines = text.split("\n");
const urls: URL[] = [];
for (const line of lines) {
// parsing can also throw an exception, but will be caught outside
const url = new URL(line);
if (url.protocol != "http:" && url.protocol != "https:") {
throw new Error("Invalid URL");
}
urls.push(url);
}
if (urls.length === 1) {
// Only 1 url in the textfield --> simply import it
mutate({ type: BookmarkTypes.LINK, url: text });
return;
}
// multiple urls found --> ask the user if it should be imported as multiple URLs or as a text bookmark
setMultiUrlImportState({ urls, text });
}
const onInput = (e: React.FormEvent<HTMLTextAreaElement>) => {
// Expand the textarea to a max of half the screen size in the list layout only
if (bookmarkLayout === "list") {
const target = e.target as HTMLTextAreaElement;
const maxHeight = window.innerHeight * 0.5;
target.style.height = "auto";
if (target.scrollHeight <= maxHeight) {
target.style.height = `${target.scrollHeight}px`;
} else {
target.style.height = `${maxHeight}px`;
}
}
};
const onSubmit: SubmitHandler<z.infer<typeof formSchema>> = (data) => {
const text = data.text.trim();
if (!text.length) return;
try {
tryToImportUrls(text);
} catch (e) {
// Not a URL
mutate({ type: BookmarkTypes.TEXT, text });
}
};
const onError: SubmitErrorHandler<z.infer<typeof formSchema>> = (errors) => {
toast({
description: Object.values(errors)
.map((v) => v.message)
.join("\n"),
variant: "destructive",
});
};
const cardHeight = useBookmarkLayoutSwitch({
grid: "h-96",
masonry: "h-96",
list: undefined,
compact: undefined,
});
const handlePaste = async (
event: React.ClipboardEvent<HTMLTextAreaElement>,
) => {
if (event?.clipboardData?.items) {
await Promise.all(
Array.from(event.clipboardData.items)
.filter((item) => item?.type?.startsWith("image"))
.map((item) => {
const blob = item.getAsFile();
if (blob) {
return uploadAsset(blob);
}
}),
);
}
};
const OS = getOS();
return (
<Form {...form}>
<form
className={cn(
className,
"relative flex flex-col gap-2 rounded-xl bg-card p-4",
cardHeight,
)}
onSubmit={form.handleSubmit(onSubmit, onError)}
>
<div className="flex justify-between">
<p className="text-sm">NEW ITEM</p>
<InfoTooltip size={15}>
<p className="text-center">
You can quickly focus on this field by pressing + E
</p>
</InfoTooltip>
</div>
<Separator />
<FormItem className="flex-1">
<FormControl>
<Textarea
ref={inputRef}
disabled={isPending}
className={cn(
"h-full w-full border-none p-0 text-lg focus-visible:ring-0",
{ "resize-none": bookmarkLayout !== "list" },
)}
placeholder={
"Paste a link or an image, write a note or drag and drop an image in here ..."
}
onKeyDown={(e) => {
if (demoMode) {
return;
}
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
form.handleSubmit(onSubmit, onError)();
}
}}
onPaste={(e) => {
if (demoMode) {
return;
}
handlePaste(e);
}}
onInput={onInput}
{...textFieldProps}
/>
</FormControl>
</FormItem>
<ActionButton loading={isPending} type="submit" variant="default">
{form.formState.dirtyFields.text
? demoMode
? "Submissions are disabled"
: `Save (${OS === "macos" ? "⌘" : "Ctrl"} + Enter)`
: "Save"}
</ActionButton>
{multiUrlImportState && (
<MultipleChoiceDialog
open={true}
title={`Import URLs as separate Bookmarks?`}
description={`The input contains multiple URLs on separate lines. Do you want to import them as separate bookmarks?`}
onOpenChange={(open) => {
if (!open) {
setMultiUrlImportState(null);
}
}}
actionButtons={[
() => (
<ActionButton
type="button"
variant="secondary"
loading={isPending}
onClick={() => {
mutate({
type: BookmarkTypes.TEXT,
text: multiUrlImportState.text,
});
setMultiUrlImportState(null);
}}
>
Import as Text Bookmark
</ActionButton>
),
() => (
<ActionButton
type="button"
variant="destructive"
loading={isPending}
onClick={() => {
multiUrlImportState.urls.forEach((url) =>
mutate({ type: BookmarkTypes.LINK, url: url.toString() }),
);
setMultiUrlImportState(null);
}}
>
Import as separate Bookmarks
</ActionButton>
),
]}
></MultipleChoiceDialog>
)}
</form>
</Form>
);
}

View File

@ -1,18 +0,0 @@
import Link from "next/link";
export default function FooterLinkURL({ url }: { url: string | null }) {
if (!url) {
return null;
}
const parsedUrl = new URL(url);
return (
<Link
className="line-clamp-1 hover:text-foreground"
href={url}
target="_blank"
rel="noreferrer"
>
{parsedUrl.host}
</Link>
);
}

View File

@ -1,92 +0,0 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import type { ZBookmarkTypeLink } from "@hoarder/shared/types/bookmarks";
import {
getBookmarkLinkImageUrl,
getSourceUrl,
isBookmarkStillCrawling,
} from "@hoarder/shared-react/utils/bookmarkUtils";
import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard";
import FooterLinkURL from "./FooterLinkURL";
function LinkTitle({ bookmark }: { bookmark: ZBookmarkTypeLink }) {
const link = bookmark.content;
const parsedUrl = new URL(link.url);
return (
<Link href={link.url} target="_blank" rel="noreferrer">
{bookmark.title ?? link?.title ?? parsedUrl.host}
</Link>
);
}
function LinkImage({
bookmark,
className,
}: {
bookmark: ZBookmarkTypeLink;
className?: string;
}) {
const link = bookmark.content;
const imgComponent = (url: string, unoptimized: boolean) => (
<Image
unoptimized={unoptimized}
className={className}
alt="card banner"
fill={true}
src={url}
/>
);
const imageDetails = getBookmarkLinkImageUrl(link);
let img: React.ReactNode;
if (isBookmarkStillCrawling(bookmark)) {
img = imgComponent("/blur.avif", false);
} else if (imageDetails) {
img = imgComponent(imageDetails.url, !imageDetails.localAsset);
} else {
// No image found
// A dummy white pixel for when there's no image.
img = imgComponent(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAA1JREFUGFdj+P///38ACfsD/QVDRcoAAAAASUVORK5CYII=",
true,
);
}
return (
<Link
href={link.url}
target="_blank"
rel="noreferrer"
className={className}
>
<div className="relative size-full flex-1">{img}</div>
</Link>
);
}
export default function LinkCard({
bookmark: bookmarkLink,
className,
}: {
bookmark: ZBookmarkTypeLink;
className?: string;
}) {
return (
<BookmarkLayoutAdaptingCard
title={<LinkTitle bookmark={bookmarkLink} />}
footer={<FooterLinkURL url={getSourceUrl(bookmarkLink)} />}
bookmark={bookmarkLink}
wrapTags={false}
image={(_layout, className) => (
<LinkImage className={className} bookmark={bookmarkLink} />
)}
className={className}
/>
);
}

View File

@ -1,226 +0,0 @@
import { useState } from "react";
import { ActionButton } from "@/components/ui/action-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import LoadingSpinner from "@/components/ui/spinner";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
import { zodResolver } from "@hookform/resolvers/zod";
import { Archive, X } from "lucide-react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
useAddBookmarkToList,
useBookmarkLists,
useRemoveBookmarkFromList,
} from "@hoarder/shared-react/hooks/lists";
import { BookmarkListSelector } from "../lists/BookmarkListSelector";
import ArchiveBookmarkButton from "./action-buttons/ArchiveBookmarkButton";
export default function ManageListsModal({
bookmarkId,
open,
setOpen,
}: {
bookmarkId: string;
open: boolean;
setOpen: (open: boolean) => void;
}) {
const formSchema = z.object({
listId: z.string({
required_error: "Please select a list",
}),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
listId: undefined,
},
});
const { data: allLists, isPending: isAllListsPending } = useBookmarkLists(
undefined,
{ enabled: open },
);
const { data: alreadyInList, isPending: isAlreadyInListPending } =
api.lists.getListsOfBookmark.useQuery(
{
bookmarkId,
},
{ enabled: open },
);
const isLoading = isAllListsPending || isAlreadyInListPending;
const { mutate: addToList, isPending: isAddingToListPending } =
useAddBookmarkToList({
onSuccess: () => {
toast({
description: "List has been updated!",
});
form.resetField("listId");
},
onError: (e) => {
if (e.data?.code == "BAD_REQUEST") {
toast({
variant: "destructive",
description: e.message,
});
} else {
toast({
variant: "destructive",
title: "Something went wrong",
});
}
},
});
const { mutate: deleteFromList, isPending: isDeleteFromListPending } =
useRemoveBookmarkFromList({
onSuccess: () => {
toast({
description: "List has been updated!",
});
form.resetField("listId");
},
onError: (e) => {
if (e.data?.code == "BAD_REQUEST") {
toast({
variant: "destructive",
description: e.message,
});
} else {
toast({
variant: "destructive",
title: "Something went wrong",
});
}
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<Form {...form}>
<form
onSubmit={form.handleSubmit((value) => {
addToList({
bookmarkId: bookmarkId,
listId: value.listId,
});
})}
>
<DialogHeader>
<DialogTitle>Manage Lists</DialogTitle>
</DialogHeader>
{isLoading ? (
<LoadingSpinner className="my-4" />
) : (
allLists && (
<ul className="flex flex-col gap-2 pb-2 pt-4">
{alreadyInList?.lists.map((list) => (
<li
key={list.id}
className="flex items-center justify-between rounded-lg border border-border bg-background px-2 py-1 text-foreground"
>
<p>
{allLists
.getPathById(list.id)!
.map((l) => `${l.icon} ${l.name}`)
.join(" / ")}
</p>
<ActionButton
type="button"
variant="ghost"
size="sm"
loading={isDeleteFromListPending}
onClick={() =>
deleteFromList({ bookmarkId, listId: list.id })
}
>
<X className="size-4" />
</ActionButton>
</li>
))}
</ul>
)
)}
<div className="pb-4">
<FormField
control={form.control}
name="listId"
render={({ field }) => {
return (
<FormItem>
<FormControl>
<BookmarkListSelector
value={field.value}
hideBookmarkIds={alreadyInList?.lists.map(
(l) => l.id,
)}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
</div>
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<ArchiveBookmarkButton
type="button"
bookmarkId={bookmarkId}
onDone={() => setOpen(false)}
>
<Archive className="mr-2 size-4" /> Archive
</ArchiveBookmarkButton>
<ActionButton
type="submit"
loading={isAddingToListPending}
disabled={isAddingToListPending}
>
Add
</ActionButton>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
export function useManageListsModal(bookmarkId: string) {
const [open, setOpen] = useState(false);
return {
open,
setOpen,
content: open && (
<ManageListsModal bookmarkId={bookmarkId} open={open} setOpen={setOpen} />
),
};
}

View File

@ -1,142 +0,0 @@
import React from "react";
import { ActionButton } from "@/components/ui/action-button";
import LoadingSpinner from "@/components/ui/spinner";
import { toast } from "@/components/ui/use-toast";
import { cn } from "@/lib/utils";
import { ChevronUp, RefreshCw, Sparkles, Trash2 } from "lucide-react";
import {
useSummarizeBookmark,
useUpdateBookmark,
} from "@hoarder/shared-react/hooks/bookmarks";
import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
function AISummary({
bookmarkId,
summary,
}: {
bookmarkId: string;
summary: string;
}) {
const [isExpanded, setIsExpanded] = React.useState(false);
const { mutate: resummarize, isPending: isResummarizing } =
useSummarizeBookmark({
onError: () => {
toast({
description: "Something went wrong",
variant: "destructive",
});
},
});
const { mutate: updateBookmark, isPending: isUpdatingBookmark } =
useUpdateBookmark({
onError: () => {
toast({
description: "Something went wrong",
variant: "destructive",
});
},
});
return (
<div className="w-full p-1">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
className={`
relative overflow-hidden rounded-lg p-4
transition-all duration-300 ease-in-out
${isExpanded ? "h-auto" : "cursor-pointer"}
bg-gradient-to-r from-purple-400 via-pink-500 to-red-500 p-[2px]
`}
onClick={() => !isExpanded && setIsExpanded(true)}
>
<div className="h-full rounded-lg bg-accent p-2">
<p
className={`text-sm text-gray-700 dark:text-gray-300 ${!isExpanded && "line-clamp-3"}`}
>
{summary}
</p>
{isExpanded && (
<span className="flex justify-end gap-2 pt-2">
<ActionButton
variant="none"
size="none"
spinner={<LoadingSpinner className="size-4" />}
className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
aria-label={isExpanded ? "Collapse" : "Expand"}
loading={isResummarizing}
onClick={() => resummarize({ bookmarkId })}
>
<RefreshCw size={16} />
</ActionButton>
<ActionButton
size="none"
variant="none"
spinner={<LoadingSpinner className="size-4" />}
className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
aria-label={isExpanded ? "Collapse" : "Expand"}
loading={isUpdatingBookmark}
onClick={() => updateBookmark({ bookmarkId, summary: null })}
>
<Trash2 size={16} />
</ActionButton>
<button
className="rounded-full bg-gray-200 p-1 text-gray-600 dark:bg-gray-700 dark:text-gray-400"
aria-label="Collapse"
onClick={() => setIsExpanded(false)}
>
<ChevronUp size={16} />
</button>
</span>
)}
</div>
</div>
</div>
);
}
export default function SummarizeBookmarkArea({
bookmark,
}: {
bookmark: ZBookmark;
}) {
const { mutate, isPending } = useSummarizeBookmark({
onError: () => {
toast({
description: "Something went wrong",
variant: "destructive",
});
},
});
if (bookmark.content.type !== BookmarkTypes.LINK) {
return null;
}
if (bookmark.summary) {
return <AISummary bookmarkId={bookmark.id} summary={bookmark.summary} />;
} else {
return (
<div className="flex w-full items-center gap-4">
<ActionButton
onClick={() => mutate({ bookmarkId: bookmark.id })}
className={cn(
`relative w-full overflow-hidden bg-opacity-30 bg-gradient-to-r from-blue-400 via-purple-500 to-pink-500 transition-all duration-300`,
isPending ? "text-transparent" : "text-gray-50",
)}
loading={isPending}
>
{isPending && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="h-full w-full bg-gradient-to-r from-blue-400 via-purple-500 to-pink-500"></div>
<LoadingSpinner className="absolute h-5 w-5 text-white" />
</div>
)}
<span className="relative z-10 flex items-center gap-1.5">
Summarize with AI
<Sparkles className="size-4" />
</span>
</ActionButton>
</div>
);
}
}

View File

@ -1,43 +0,0 @@
import Link from "next/link";
import { badgeVariants } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
export default function TagList({
bookmark,
loading,
className,
}: {
bookmark: ZBookmark;
loading?: boolean;
className?: string;
}) {
if (loading) {
return (
<div className="flex w-full flex-col justify-end space-y-2 p-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
</div>
);
}
return (
<>
{bookmark.tags.map((t) => (
<div key={t.id} className={className}>
<Link
key={t.id}
className={cn(
badgeVariants({ variant: "outline" }),
"text-nowrap font-normal hover:bg-foreground hover:text-secondary",
)}
href={`/dashboard/tags/${t.id}`}
>
{t.name}
</Link>
</div>
))}
</>
);
}

View File

@ -1,59 +0,0 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
import { BookmarkTagsEditor } from "./BookmarkTagsEditor";
export default function TagModal({
bookmark,
open,
setOpen,
}: {
bookmark: ZBookmark;
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Tags</DialogTitle>
</DialogHeader>
<BookmarkTagsEditor bookmark={bookmark} />
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function useTagModel(bookmark: ZBookmark) {
const [open, setOpen] = useState(false);
return {
open,
setOpen,
content: (
<TagModal
key={bookmark.id}
bookmark={bookmark}
open={open}
setOpen={setOpen}
/>
),
};
}

View File

@ -1,191 +0,0 @@
import type { ActionMeta } from "react-select";
import { useState } from "react";
import { useClientConfig } from "@/lib/clientConfig";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { Sparkles } from "lucide-react";
import CreateableSelect from "react-select/creatable";
import type {
ZAttachedByEnum,
ZBookmarkTags,
} from "@hoarder/shared/types/tags";
interface EditableTag {
attachedBy: ZAttachedByEnum;
value?: string;
label: string;
}
export function TagsEditor({
tags: _tags,
onAttach,
onDetach,
}: {
tags: ZBookmarkTags[];
onAttach: (tag: { tagName: string; tagId?: string }) => void;
onDetach: (tag: { tagName: string; tagId: string }) => void;
}) {
const demoMode = !!useClientConfig().demoMode;
const [optimisticTags, setOptimisticTags] = useState<ZBookmarkTags[]>(_tags);
const { data: existingTags, isLoading: isExistingTagsLoading } =
api.tags.list.useQuery();
existingTags?.tags.sort((a, b) => a.name.localeCompare(b.name));
const onChange = (
_option: readonly EditableTag[],
actionMeta: ActionMeta<EditableTag>,
) => {
switch (actionMeta.action) {
case "pop-value":
case "remove-value": {
if (actionMeta.removedValue.value) {
setOptimisticTags((prev) =>
prev.filter((t) => t.id != actionMeta.removedValue.value),
);
onDetach({
tagId: actionMeta.removedValue.value,
tagName: actionMeta.removedValue.label,
});
}
break;
}
case "create-option": {
setOptimisticTags((prev) => [
...prev,
{
id: "",
name: actionMeta.option.label,
attachedBy: "human" as const,
},
]);
onAttach({ tagName: actionMeta.option.label });
break;
}
case "select-option": {
if (actionMeta.option) {
setOptimisticTags((prev) => [
...prev,
{
id: actionMeta.option?.value ?? "",
name: actionMeta.option!.label,
attachedBy: "human" as const,
},
]);
onAttach({
tagName: actionMeta.option.label,
tagId: actionMeta.option?.value,
});
}
break;
}
}
};
return (
<CreateableSelect
isDisabled={demoMode}
onChange={onChange}
options={
existingTags?.tags.map((t) => ({
label: t.name,
value: t.id,
attachedBy: "human" as const,
})) ?? []
}
value={optimisticTags.map((t) => ({
label: t.name,
value: t.id,
attachedBy: t.attachedBy,
}))}
isMulti
closeMenuOnSelect={false}
isClearable={false}
isLoading={isExistingTagsLoading}
theme={(theme) => ({
...theme,
// This color scheme doesn't support disabled options.
colors: {
...theme.colors,
primary: "hsl(var(--accent))",
primary50: "hsl(var(--accent))",
primary75: "hsl(var(--accent))",
primary25: "hsl(var(--accent))",
},
})}
styles={{
multiValueRemove: () => ({
backgroundColor: "transparent",
}),
valueContainer: (styles) => ({
...styles,
padding: "0.5rem",
maxHeight: "14rem",
overflowY: "auto",
scrollbarWidth: "thin",
}),
container: (styles) => ({
...styles,
width: "100%",
}),
control: (styles) => ({
...styles,
overflow: "hidden",
backgroundColor: "hsl(var(--background))",
borderColor: "hsl(var(--border))",
":hover": {
borderColor: "hsl(var(--border))",
},
}),
input: (styles) => ({
...styles,
color: "rgb(156 163 175)",
}),
menu: (styles) => ({
...styles,
overflow: "hidden",
color: "rgb(156 163 175)",
}),
placeholder: (styles) => ({
...styles,
color: "hsl(var(--muted-foreground))",
}),
}}
components={{
MultiValueContainer: ({ children, data }) => (
<div
className={cn(
"flex min-h-8 space-x-1 rounded px-2",
(data as { attachedBy: string }).attachedBy == "ai"
? "bg-gradient-to-tr from-purple-500 to-purple-400 text-white"
: "bg-accent",
)}
>
{children}
</div>
),
MultiValueLabel: ({ children, data }) => (
<div className="m-auto flex gap-2">
{(data as { attachedBy: string }).attachedBy == "ai" && (
<Sparkles className="m-auto size-4" />
)}
{children}
</div>
),
DropdownIndicator: () => <span />,
IndicatorSeparator: () => <span />,
}}
classNames={{
multiValueRemove: () => "my-auto",
valueContainer: () => "gap-2 bg-background text-sm",
menu: () => "dark:text-gray-300",
menuList: () => "bg-background text-sm",
option: () => "text-red-500",
input: () => "dark:text-gray-300",
}}
/>
);
}

View File

@ -1,72 +0,0 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { MarkdownComponent } from "@/components/ui/markdown-component";
import { bookmarkLayoutSwitch } from "@/lib/userLocalSettings/bookmarksLayout";
import { cn } from "@/lib/utils";
import type { ZBookmarkTypeText } from "@hoarder/shared/types/bookmarks";
import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils";
import { getSourceUrl } from "@hoarder/shared-react/utils/bookmarkUtils";
import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard";
import FooterLinkURL from "./FooterLinkURL";
export default function TextCard({
bookmark,
className,
}: {
bookmark: ZBookmarkTypeText;
className?: string;
}) {
const bookmarkedText = bookmark.content;
const banner = bookmark.assets.find((a) => a.assetType == "bannerImage");
return (
<>
<BookmarkLayoutAdaptingCard
title={bookmark.title}
content={<MarkdownComponent>{bookmarkedText.text}</MarkdownComponent>}
footer={
getSourceUrl(bookmark) && (
<FooterLinkURL url={getSourceUrl(bookmark)} />
)
}
wrapTags={true}
bookmark={bookmark}
className={className}
fitHeight={true}
image={(layout, className) =>
bookmarkLayoutSwitch(layout, {
grid: null,
masonry: null,
compact: null,
list: banner ? (
<div className="relative size-full flex-1">
<Link href={`/dashboard/preview/${bookmark.id}`}>
<Image
alt="card banner"
fill={true}
className={cn("flex-1", className)}
src={getAssetUrl(banner.id)}
/>
</Link>
</div>
) : (
<div
className={cn(
"flex size-full items-center justify-center bg-accent text-center",
className,
)}
>
Note
</div>
),
})
}
/>
</>
);
}

View File

@ -1,64 +0,0 @@
import React from "react";
import { ActionButton, ActionButtonProps } from "@/components/ui/action-button";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
import { useUpdateBookmark } from "@hoarder/shared-react/hooks/bookmarks";
interface ArchiveBookmarkButtonProps
extends Omit<ActionButtonProps, "loading" | "disabled"> {
bookmarkId: string;
onDone?: () => void;
}
const ArchiveBookmarkButton = React.forwardRef<
HTMLButtonElement,
ArchiveBookmarkButtonProps
>(({ bookmarkId, onDone, ...props }, ref) => {
const { data } = api.bookmarks.getBookmark.useQuery({ bookmarkId });
const { mutate: updateBookmark, isPending: isArchivingBookmark } =
useUpdateBookmark({
onSuccess: () => {
toast({
description: "Bookmark has been archived!",
});
onDone?.();
},
onError: (e) => {
if (e.data?.code == "BAD_REQUEST") {
toast({
variant: "destructive",
description: e.message,
});
} else {
toast({
variant: "destructive",
title: "Something went wrong",
});
}
},
});
if (!data) {
return <span />;
}
return (
<ActionButton
ref={ref}
loading={isArchivingBookmark}
disabled={data.archived}
onClick={() =>
updateBookmark({
bookmarkId,
archived: !data.archived,
})
}
{...props}
/>
);
});
ArchiveBookmarkButton.displayName = "ArchiveBookmarkButton";
export default ArchiveBookmarkButton;

View File

@ -1,33 +0,0 @@
import { Archive, ArchiveRestore, Star } from "lucide-react";
export function FavouritedActionIcon({
favourited,
className,
size,
}: {
favourited: boolean;
className?: string;
size?: number;
}) {
return favourited ? (
<Star size={size} className={className} color="#ebb434" fill="#ebb434" />
) : (
<Star size={size} className={className} />
);
}
export function ArchivedActionIcon({
archived,
className,
size,
}: {
archived: boolean;
className?: string;
size?: number;
}) {
return archived ? (
<ArchiveRestore size={size} className={className} />
) : (
<Archive size={size} className={className} />
);
}

View File

@ -1,280 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { ActionButton } from "@/components/ui/action-button";
import ActionConfirmingDialog from "@/components/ui/action-confirming-dialog";
import { badgeVariants } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import LoadingSpinner from "@/components/ui/spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
import { cn } from "@/lib/utils";
import { distance } from "fastest-levenshtein";
import { Check, Combine, X } from "lucide-react";
import { useMergeTag } from "@lifetracker/shared-react/hooks/tags";
interface Suggestion {
mergeIntoId: string;
tags: { id: string; name: string }[];
}
function normalizeTag(tag: string) {
return tag.toLocaleLowerCase().replace(/[ -_]/g, "");
}
const useSuggestions = () => {
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
function updateMergeInto(suggestion: Suggestion, newMergeIntoId: string) {
setSuggestions((prev) =>
prev.map((s) =>
s === suggestion ? { ...s, mergeIntoId: newMergeIntoId } : s,
),
);
}
function deleteSuggestion(suggestion: Suggestion) {
setSuggestions((prev) => prev.filter((s) => s !== suggestion));
}
return { suggestions, updateMergeInto, deleteSuggestion, setSuggestions };
};
function ApplyAllButton({ suggestions }: { suggestions: Suggestion[] }) {
const [applying, setApplying] = useState(false);
const { mutateAsync } = useMergeTag({
onError: (e) => {
toast({
description: e.message,
variant: "destructive",
});
},
});
const applyAll = async (setDialogOpen: (open: boolean) => void) => {
const promises = suggestions.map((suggestion) =>
mutateAsync({
intoTagId: suggestion.mergeIntoId,
fromTagIds: suggestion.tags
.filter((t) => t.id != suggestion.mergeIntoId)
.map((t) => t.id),
}),
);
setApplying(true);
await Promise.all(promises)
.then(() => {
toast({
description: "All suggestions has been applied!",
});
})
.catch(() => ({}))
.finally(() => {
setApplying(false);
setDialogOpen(false);
});
};
return (
<ActionConfirmingDialog
title="Merge all suggestions?"
description={`Are you sure you want to apply all ${suggestions.length} suggestions?`}
actionButton={(setDialogOpen) => (
<ActionButton
loading={applying}
variant="destructive"
onClick={() => applyAll(setDialogOpen)}
>
<Check className="mr-2 size-4" />
Apply All
</ActionButton>
)}
>
<Button variant="destructive">
<Check className="mr-2 size-4" />
Apply All
</Button>
</ActionConfirmingDialog>
);
}
function SuggestionRow({
suggestion,
updateMergeInto,
deleteSuggestion,
}: {
suggestion: Suggestion;
updateMergeInto: (suggestion: Suggestion, newMergeIntoId: string) => void;
deleteSuggestion: (suggestion: Suggestion) => void;
}) {
const { mutate, isPending } = useMergeTag({
onSuccess: () => {
toast({
description: "Tags have been merged!",
});
},
onError: (e) => {
toast({
description: e.message,
variant: "destructive",
});
},
});
return (
<TableRow key={suggestion.mergeIntoId}>
<TableCell className="flex flex-wrap gap-1">
{suggestion.tags.map((tag, idx) => {
const selected = suggestion.mergeIntoId == tag.id;
return (
<div key={idx} className="group relative">
<Link
href={`/dashboard/tags/${tag.id}`}
className={cn(
badgeVariants({ variant: "outline" }),
"text-sm",
selected
? "border border-blue-500 dark:border-blue-900"
: null,
)}
>
{tag.name}
</Link>
<Button
size="none"
className={cn(
"-translate-1/2 absolute -right-1.5 -top-1.5 rounded-full p-0.5",
selected ? null : "hidden group-hover:block",
)}
onClick={() => updateMergeInto(suggestion, tag.id)}
>
<Check className="size-3" />
</Button>
</div>
);
})}
</TableCell>
<TableCell className="space-x-1 space-y-1 text-center">
<ActionButton
loading={isPending}
onClick={() =>
mutate({
intoTagId: suggestion.mergeIntoId,
fromTagIds: suggestion.tags
.filter((t) => t.id != suggestion.mergeIntoId)
.map((t) => t.id),
})
}
>
<Combine className="mr-2 size-4" />
Merge
</ActionButton>
<Button
variant={"secondary"}
onClick={() => deleteSuggestion(suggestion)}
>
<X className="mr-2 size-4" />
Ignore
</Button>
</TableCell>
</TableRow>
);
}
export function TagDuplicationDetection() {
const [expanded, setExpanded] = useState(false);
let { data: allTags } = api.tags.list.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const { suggestions, updateMergeInto, setSuggestions, deleteSuggestion } =
useSuggestions();
useEffect(() => {
allTags = allTags ?? { tags: [] };
const sortedTags = allTags.tags.sort((a, b) =>
normalizeTag(a.name).localeCompare(normalizeTag(b.name)),
);
const initialSuggestions: Suggestion[] = [];
for (let i = 0; i < sortedTags.length; i++) {
const currentName = normalizeTag(sortedTags[i].name);
const suggestion = [sortedTags[i]];
for (let j = i + 1; j < sortedTags.length; j++) {
const nextName = normalizeTag(sortedTags[j].name);
if (distance(currentName, nextName) <= 1) {
suggestion.push(sortedTags[j]);
} else {
break;
}
}
if (suggestion.length > 1) {
initialSuggestions.push({
mergeIntoId: suggestion[0].id,
tags: suggestion,
});
i += suggestion.length - 1;
}
}
setSuggestions(initialSuggestions);
}, [allTags]);
if (!allTags) {
return <LoadingSpinner />;
}
return (
<Collapsible open={expanded} onOpenChange={setExpanded}>
You have {suggestions.length} suggestions for tag merging.
{suggestions.length > 0 && (
<CollapsibleTrigger asChild>
<Button variant="link" size="sm">
{expanded ? "Hide All" : "Show All"}
</Button>
</CollapsibleTrigger>
)}
<CollapsibleContent>
<p className="text-sm italic text-muted-foreground">
For every suggestion, select the tag that you want to keep and other
tags will be merged into it.
</p>
{suggestions.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>Tags</TableHead>
<TableHead className="text-center">
<ApplyAllButton suggestions={suggestions} />
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suggestions.map((suggestion) => (
<SuggestionRow
key={suggestion.mergeIntoId}
suggestion={suggestion}
updateMergeInto={updateMergeInto}
deleteSuggestion={deleteSuggestion}
/>
))}
</TableBody>
</Table>
)}
</CollapsibleContent>
</Collapsible>
);
}

View File

@ -2,7 +2,6 @@ import Link from "next/link";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import GlobalActions from "@/components/dashboard/GlobalActions"; import GlobalActions from "@/components/dashboard/GlobalActions";
import ProfileOptions from "@/components/dashboard/header/ProfileOptions"; import ProfileOptions from "@/components/dashboard/header/ProfileOptions";
import { SearchInput } from "@/components/dashboard/search/SearchInput";
import HoarderLogo from "@/components/HoarderIcon"; import HoarderLogo from "@/components/HoarderIcon";
import { getServerAuthSession } from "@/server/auth"; import { getServerAuthSession } from "@/server/auth";
@ -20,7 +19,6 @@ export default async function Header() {
</Link> </Link>
</div> </div>
<div className="flex flex-1 gap-2"> <div className="flex flex-1 gap-2">
<SearchInput className="min-w-40 bg-muted" />
<GlobalActions /> <GlobalActions />
</div> </div>
<div className="flex items-center"> <div className="flex items-center">

View File

@ -100,7 +100,7 @@ export default function EditableHour({
const tzOffset = spacetime().offset() / 60; const tzOffset = spacetime().offset() / 60;
const localDateTime = (h) => { const localDateTime = (h: ZHour): string => {
return spacetime(h.date).add(h.time + tzOffset, "hour").format('{hour} {ampm}'); return spacetime(h.date).add(h.time + tzOffset, "hour").format('{hour} {ampm}');
} }
hour.datetime = localDateTime(hour); hour.datetime = localDateTime(hour);
@ -111,14 +111,6 @@ export default function EditableHour({
// console.log(hour.categoryDesc); // console.log(hour.categoryDesc);
}, [hour]); }, [hour]);
function isActiveHour(hour: ZHour) {
const now = new TZDate();
const isCurrentHour = ((now.getHours() - localDateTime.hour()) == 0);
const isToday = (localDateTime.format("iso-short") == format(now, "yyyy-MM-dd"));
return isToday && isCurrentHour;
}
function reload(newHour: ZHour) { function reload(newHour: ZHour) {
setHour(newHour); setHour(newHour);
} }
@ -130,7 +122,8 @@ export default function EditableHour({
)} )}
style={{ style={{
background: hour.background!, color: hour.foreground!, fontFamily: "inherit", background: hour.background!, color: hour.foreground!, fontFamily: "inherit",
gridTemplateColumns: window.innerWidth > 640 ? "50px 100px 3fr 2fr" : "50px 100px 1fr", // Known issue: This won't work if the screen is resized, only on reload // gridTemplateColumns: window.innerWidth > 640 ? "50px 100px 3fr 2fr" : "50px 100px 1fr", // Known issue: This won't work if the screen is resized, only on reload
gridTemplateColumns: "50px 100px 3fr 2fr"
}} }}
> >
<span className="text-right hover:cursor-default"> <span className="text-right hover:cursor-default">
@ -154,7 +147,7 @@ export default function EditableHour({
i={i} i={i}
/> />
</div> </div>
<span className="hidden sm:block"> <span className="block">
{hour.categoryCode != undefined ? {hour.categoryCode != undefined ?
<EditableHourComment <EditableHourComment
originalText={hour.comment ?? hour.categoryName} originalText={hour.comment ?? hour.categoryName}
@ -164,7 +157,7 @@ export default function EditableHour({
/> />
: ""} : ""}
</span> </span>
<span className="block sm:hidden"> <span className="hidden">
<div className="w-full text-left edit-hour-comment" <div className="w-full text-left edit-hour-comment"
style={{ style={{
background: hour.background ?? "inherit", color: hour.foreground ?? "inherit", fontFamily: "inherit", background: hour.background ?? "inherit", color: hour.foreground ?? "inherit", fontFamily: "inherit",
@ -172,7 +165,7 @@ export default function EditableHour({
{hour.categoryName} {hour.categoryName}
</div> </div>
</span> </span>
<div className="hidden sm:flex items-center justify-end"> <div className="flex items-center justify-end">
{hour.categoryCode != undefined ? {hour.categoryCode != undefined ?

View File

@ -1,115 +0,0 @@
import { useRouter } from "next/navigation";
import { ActionButton } from "@/components/ui/action-button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { toast } from "@/components/ui/use-toast";
import { Trash2 } from "lucide-react";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
import {
useDeleteBookmark,
useUpdateBookmark,
} from "@hoarder/shared-react/hooks/bookmarks";
import { ArchivedActionIcon, FavouritedActionIcon } from "../bookmarks/icons";
export default function ActionBar({ bookmark }: { bookmark: ZBookmark }) {
const router = useRouter();
const onError = () => {
toast({
variant: "destructive",
title: "Something went wrong",
description: "There was a problem with your request.",
});
};
const { mutate: favBookmark, isPending: pendingFav } = useUpdateBookmark({
onSuccess: () => {
toast({
description: "The bookmark has been updated!",
});
},
onError,
});
const { mutate: archiveBookmark, isPending: pendingArchive } =
useUpdateBookmark({
onSuccess: (resp) => {
toast({
description: `The bookmark has been ${resp.archived ? "Archived" : "Un-archived"}!`,
});
},
onError,
});
const { mutate: deleteBookmark, isPending: pendingDeletion } =
useDeleteBookmark({
onSuccess: () => {
toast({
description: "The bookmark has been deleted!",
});
router.back();
},
onError,
});
return (
<div className="flex items-center justify-center gap-3">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<ActionButton
variant="none"
className="size-14 rounded-full bg-background"
loading={pendingFav}
onClick={() => {
favBookmark({
bookmarkId: bookmark.id,
favourited: !bookmark.favourited,
});
}}
>
<FavouritedActionIcon favourited={bookmark.favourited} />
</ActionButton>
</TooltipTrigger>
<TooltipContent side="bottom">
{bookmark.favourited ? "Un-favourite" : "Favourite"}
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<ActionButton
variant="none"
loading={pendingArchive}
className="size-14 rounded-full bg-background"
onClick={() => {
archiveBookmark({
bookmarkId: bookmark.id,
archived: !bookmark.archived,
});
}}
>
<ArchivedActionIcon archived={bookmark.archived} />
</ActionButton>
</TooltipTrigger>
<TooltipContent side="bottom">
{bookmark.archived ? "Un-archive" : "Archive"}
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<ActionButton
loading={pendingDeletion}
className="size-14 rounded-full bg-background"
variant="none"
onClick={() => {
deleteBookmark({ bookmarkId: bookmark.id });
}}
>
<Trash2 />
</ActionButton>
</TooltipTrigger>
<TooltipContent side="bottom">Delete</TooltipContent>
</Tooltip>
</div>
);
}

View File

@ -1,42 +0,0 @@
import Image from "next/image";
import Link from "next/link";
import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
export function AssetContentSection({ bookmark }: { bookmark: ZBookmark }) {
if (bookmark.content.type != BookmarkTypes.ASSET) {
throw new Error("Invalid content type");
}
switch (bookmark.content.assetType) {
case "image": {
return (
<div className="relative h-full min-w-full">
<Link
href={`/api/assets/${bookmark.content.assetId}`}
target="_blank"
>
<Image
alt="asset"
fill={true}
className="object-contain"
src={`/api/assets/${bookmark.content.assetId}`}
/>
</Link>
</div>
);
}
case "pdf": {
return (
<iframe
title={bookmark.content.assetId}
className="h-full w-full"
src={`/api/assets/${bookmark.content.assetId}`}
/>
);
}
default: {
return <div>Unsupported asset type</div>;
}
}
}

View File

@ -1,219 +0,0 @@
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,
Paperclip,
Pencil,
Plus,
Trash2,
Video,
} from "lucide-react";
import {
useAttachBookmarkAsset,
useDetachBookmarkAsset,
useReplaceBookmarkAsset,
} from "@hoarder/shared-react/hooks/bookmarks";
import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils";
import {
BookmarkTypes,
ZAssetType,
ZBookmark,
} from "@hoarder/shared/types/bookmarks";
import {
humanFriendlyNameForAssertType,
isAllowedToAttachAsset,
isAllowedToDetachAsset,
} 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" />,
video: <Video className="size-4" />,
bookmarkAsset: <Paperclip className="size-4" />,
unknown: <Paperclip 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",
});
},
});
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>
)}
{isAllowedToDetachAsset(asset.assetType) && (
<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") &&
bookmark.content.type != BookmarkTypes.ASSET && (
<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>
);
}

View File

@ -1,155 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { BookmarkTagsEditor } from "@/components/dashboard/bookmarks/BookmarkTagsEditor";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/lib/trpc";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { CalendarDays, ExternalLink } from "lucide-react";
import {
getSourceUrl,
isBookmarkStillCrawling,
isBookmarkStillLoading,
} from "@hoarder/shared-react/utils/bookmarkUtils";
import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
import SummarizeBookmarkArea from "../bookmarks/SummarizeBookmarkArea";
import ActionBar from "./ActionBar";
import { AssetContentSection } from "./AssetContentSection";
import AttachmentBox from "./AttachmentBox";
import { EditableTitle } from "./EditableTitle";
import LinkContentSection from "./LinkContentSection";
import { NoteEditor } from "./NoteEditor";
import { TextContentSection } from "./TextContentSection";
dayjs.extend(relativeTime);
function ContentLoading() {
return (
<div className="flex w-full flex-col gap-2">
<Skeleton className="h-4" />
<Skeleton className="h-4" />
<Skeleton className="h-4" />
</div>
);
}
function CreationTime({ createdAt }: { createdAt: Date }) {
const [fromNow, setFromNow] = useState("");
const [localCreatedAt, setLocalCreatedAt] = useState("");
// This is to avoid hydration errors when server and clients are in different timezones
useEffect(() => {
setFromNow(dayjs(createdAt).fromNow());
setLocalCreatedAt(createdAt.toLocaleString());
}, [createdAt]);
return (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<span className="flex w-fit gap-2">
<CalendarDays /> {fromNow}
</span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{localCreatedAt}</TooltipContent>
</TooltipPortal>
</Tooltip>
);
}
export default function BookmarkPreview({
bookmarkId,
initialData,
}: {
bookmarkId: string;
initialData?: ZBookmark;
}) {
const { data: bookmark } = api.bookmarks.getBookmark.useQuery(
{
bookmarkId,
},
{
initialData,
refetchInterval: (query) => {
const data = query.state.data;
if (!data) {
return false;
}
// If the link is not crawled or not tagged
if (isBookmarkStillLoading(data)) {
return 1000;
}
return false;
},
},
);
if (!bookmark) {
return <FullPageSpinner />;
}
let content;
switch (bookmark.content.type) {
case BookmarkTypes.LINK: {
content = <LinkContentSection bookmark={bookmark} />;
break;
}
case BookmarkTypes.TEXT: {
content = <TextContentSection bookmark={bookmark} />;
break;
}
case BookmarkTypes.ASSET: {
content = <AssetContentSection bookmark={bookmark} />;
break;
}
}
const sourceUrl = getSourceUrl(bookmark);
return (
<div className="grid h-full grid-rows-3 gap-2 overflow-hidden bg-background lg:grid-cols-3 lg:grid-rows-none">
<div className="row-span-2 h-full w-full overflow-auto p-2 md:col-span-2 lg:row-auto">
{isBookmarkStillCrawling(bookmark) ? <ContentLoading /> : content}
</div>
<div className="lg:col-span1 row-span-1 flex flex-col gap-4 overflow-auto bg-accent p-4 lg:row-auto">
<div className="flex w-full flex-col items-center justify-center gap-y-2">
<EditableTitle bookmark={bookmark} />
{sourceUrl && (
<Link
href={sourceUrl}
className="flex items-center gap-2 text-gray-400"
>
<span>View Original</span>
<ExternalLink />
</Link>
)}
<Separator />
</div>
<CreationTime createdAt={bookmark.createdAt} />
<SummarizeBookmarkArea bookmark={bookmark} />
<div className="flex items-center gap-4">
<p className="text-sm text-gray-400">Tags</p>
<BookmarkTagsEditor bookmark={bookmark} />
</div>
<div className="flex gap-4">
<p className="pt-2 text-sm text-gray-400">Note</p>
<NoteEditor bookmark={bookmark} />
</div>
<AttachmentBox bookmark={bookmark} />
<ActionBar bookmark={bookmark} />
</div>
</div>
);
}

View File

@ -1,60 +0,0 @@
import { toast } from "@/components/ui/use-toast";
import { useUpdateBookmark } from "@hoarder/shared-react/hooks/bookmarks";
import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
import { EditableText } from "../EditableText";
export function EditableTitle({ bookmark }: { bookmark: ZBookmark }) {
const { mutate: updateBookmark, isPending } = useUpdateBookmark({
onSuccess: () => {
toast({
description: "Title updated!",
});
},
});
let title: string | null = null;
switch (bookmark.content.type) {
case BookmarkTypes.LINK:
title = bookmark.content.title ?? bookmark.content.url;
break;
case BookmarkTypes.TEXT:
title = null;
break;
case BookmarkTypes.ASSET:
title = bookmark.content.fileName ?? null;
break;
}
title = bookmark.title ?? title;
if (title == "") {
title = null;
}
return (
<EditableText
originalText={title}
editClassName="p-2 text-lg break-all"
viewClassName="break-words line-clamp-2 text-lg text-ellipsis"
untitledClassName="text-lg italic text-gray-600"
onSave={(newTitle) => {
updateBookmark(
{
bookmarkId: bookmark.id,
title: newTitle,
},
{
onError: () => {
toast({
description: "Something went wrong",
variant: "destructive",
});
},
},
);
}}
isSaving={isPending}
/>
);
}

View File

@ -1,129 +0,0 @@
import { useState } from "react";
import Image from "next/image";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ScrollArea } from "@radix-ui/react-scroll-area";
import {
BookmarkTypes,
ZBookmark,
ZBookmarkedLink,
} from "@hoarder/shared/types/bookmarks";
function FullPageArchiveSection({ link }: { link: ZBookmarkedLink }) {
return (
<iframe
title={link.url}
src={`/api/assets/${link.fullPageArchiveAssetId}`}
className="relative h-full min-w-full"
/>
);
}
function ScreenshotSection({ link }: { link: ZBookmarkedLink }) {
return (
<div className="relative h-full min-w-full">
<Image
alt="screenshot"
src={`/api/assets/${link.screenshotAssetId}`}
width={0}
height={0}
sizes="100vw"
style={{ width: "100%", height: "auto" }}
/>
</div>
);
}
function CachedContentSection({ link }: { link: ZBookmarkedLink }) {
let content;
if (!link.htmlContent) {
content = (
<div className="text-destructive">Failed to fetch link content ...</div>
);
} else {
content = (
<div
dangerouslySetInnerHTML={{
__html: link.htmlContent || "",
}}
className="prose mx-auto dark:prose-invert"
/>
);
}
return <ScrollArea className="h-full">{content}</ScrollArea>;
}
function VideoSection({ link }: { link: ZBookmarkedLink }) {
return (
<div className="relative h-full w-full overflow-hidden">
<div className="absolute inset-0 h-full w-full">
{/* eslint-disable-next-line jsx-a11y/media-has-caption -- captions not (yet) available */}
<video className="m-auto max-h-full max-w-full" controls>
<source src={`/api/assets/${link.videoAssetId}`} />
Not supported by your browser
</video>
</div>
</div>
);
}
export default function LinkContentSection({
bookmark,
}: {
bookmark: ZBookmark;
}) {
const [section, setSection] = useState<string>("cached");
if (bookmark.content.type != BookmarkTypes.LINK) {
throw new Error("Invalid content type");
}
let content;
if (section === "cached") {
content = <CachedContentSection link={bookmark.content} />;
} else if (section === "archive") {
content = <FullPageArchiveSection link={bookmark.content} />;
} else if (section === "video") {
content = <VideoSection link={bookmark.content} />;
} else {
content = <ScreenshotSection link={bookmark.content} />;
}
return (
<div className="flex h-full flex-col items-center gap-2">
<Select onValueChange={setSection} value={section}>
<SelectTrigger className="w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="cached">Cached Content</SelectItem>
<SelectItem
value="screenshot"
disabled={!bookmark.content.screenshotAssetId}
>
Screenshot
</SelectItem>
<SelectItem
value="archive"
disabled={!bookmark.content.fullPageArchiveAssetId}
>
Archive
</SelectItem>
<SelectItem value="video" disabled={!bookmark.content.videoAssetId}>
Video
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
{content}
</div>
);
}

View File

@ -1,42 +0,0 @@
import { Textarea } from "@/components/ui/textarea";
import { toast } from "@/components/ui/use-toast";
import { useClientConfig } from "@/lib/clientConfig";
import type { ZBookmark } from "@hoarder/shared/types/bookmarks";
import { useUpdateBookmark } from "@hoarder/shared-react/hooks/bookmarks";
export function NoteEditor({ bookmark }: { bookmark: ZBookmark }) {
const demoMode = !!useClientConfig().demoMode;
const updateBookmarkMutator = useUpdateBookmark({
onSuccess: () => {
toast({
description: "The bookmark has been updated!",
});
},
onError: () => {
toast({
description: "Something went wrong while saving the note",
variant: "destructive",
});
},
});
return (
<Textarea
className="h-44 w-full overflow-auto rounded bg-background p-2 text-sm text-gray-400 dark:text-gray-300"
defaultValue={bookmark.note ?? ""}
disabled={demoMode}
placeholder="Write some notes ..."
onBlur={(e) => {
if (e.currentTarget.value == bookmark.note) {
return;
}
updateBookmarkMutator.mutate({
bookmarkId: bookmark.id,
note: e.currentTarget.value,
});
}}
/>
);
}

View File

@ -1,33 +0,0 @@
import Image from "next/image";
import { MarkdownComponent } from "@/components/ui/markdown-component";
import { ScrollArea } from "@radix-ui/react-scroll-area";
import { getAssetUrl } from "@hoarder/shared-react/utils/assetUtils";
import { BookmarkTypes, ZBookmark } from "@hoarder/shared/types/bookmarks";
export function TextContentSection({ bookmark }: { bookmark: ZBookmark }) {
if (bookmark.content.type != BookmarkTypes.TEXT) {
throw new Error("Invalid content type");
}
const banner = bookmark.assets.find(
(asset) => asset.assetType == "bannerImage",
);
return (
<ScrollArea className="h-full">
{banner && (
<div className="relative h-52 min-w-full">
<Image
alt="banner"
src={getAssetUrl(banner.id)}
width={0}
height={0}
layout="fill"
objectFit="cover"
/>
</div>
)}
<MarkdownComponent>{bookmark.content.text}</MarkdownComponent>
</ScrollArea>
);
}

View File

@ -1,80 +0,0 @@
"use client";
import React, { useEffect, useImperativeHandle, useRef } from "react";
import { Input } from "@/components/ui/input";
import { useDoBookmarkSearch } from "@/lib/hooks/bookmark-search";
function useFocusSearchOnKeyPress(
inputRef: React.RefObject<HTMLInputElement>,
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void,
) {
useEffect(() => {
function handleKeyPress(e: KeyboardEvent) {
if (!inputRef.current) {
return;
}
if ((e.metaKey || e.ctrlKey) && e.code === "KeyK") {
e.preventDefault();
inputRef.current.focus();
// Move the cursor to the end of the input field, so you can continue typing
const length = inputRef.current.value.length;
inputRef.current.setSelectionRange(length, length);
}
if (
e.code === "Escape" &&
e.target == inputRef.current &&
inputRef.current.value !== ""
) {
e.preventDefault();
inputRef.current.blur();
inputRef.current.value = "";
onChange({
target: inputRef.current,
} as React.ChangeEvent<HTMLInputElement>);
}
}
document.addEventListener("keydown", handleKeyPress);
return () => {
document.removeEventListener("keydown", handleKeyPress);
};
}, [inputRef, onChange]);
}
const SearchInput = React.forwardRef<
HTMLInputElement,
React.HTMLAttributes<HTMLInputElement> & { loading?: boolean }
>(({ className, ...props }, ref) => {
const { debounceSearch, searchQuery, isInSearchPage } = useDoBookmarkSearch();
const [value, setValue] = React.useState(searchQuery);
const inputRef = useRef<HTMLInputElement>(null);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
debounceSearch(e.target.value);
};
useFocusSearchOnKeyPress(inputRef, onChange);
useImperativeHandle(ref, () => inputRef.current!);
useEffect(() => {
if (!isInSearchPage) {
setValue("");
}
}, [isInSearchPage]);
return (
<Input
ref={inputRef}
value={value}
onChange={onChange}
placeholder="Search"
className={className}
{...props}
/>
);
});
SearchInput.displayName = "SearchInput";
export { SearchInput };

View File

@ -55,7 +55,7 @@
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"drizzle-orm": "^0.33.0", "drizzle-orm": "^0.33.0",
"fastest-levenshtein": "^1.0.16", "fastest-levenshtein": "^1.0.16",
"lucide-react": "^0.330.0", "lucide-react": "latest",
"next": "latest", "next": "latest",
"next-auth": "^4.24.5", "next-auth": "^4.24.5",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",

1
apps/web/public/sw.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

18
pnpm-lock.yaml generated
View File

@ -272,8 +272,8 @@ importers:
specifier: ^1.0.16 specifier: ^1.0.16
version: 1.0.16 version: 1.0.16
lucide-react: lucide-react:
specifier: ^0.330.0 specifier: latest
version: 0.330.0(react@18.3.1) version: 0.468.0(react@18.3.1)
next: next:
specifier: latest specifier: latest
version: 15.0.3(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) version: 15.0.3(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -8330,10 +8330,10 @@ packages:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'} engines: {node: '>=12'}
lucide-react@0.330.0: lucide-react@0.468.0:
resolution: {integrity: sha512-CQwY+Fpbt2kxCoVhuN0RCZDCYlbYnqB870Bl/vIQf3ER/cnDDQ6moLmEkguRyruAUGd4j3Lc4mtnJosXnqHheA==} resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==}
peerDependencies: peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
magic-string@0.25.9: magic-string@0.25.9:
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
@ -20050,7 +20050,7 @@ snapshots:
eslint: 8.57.0 eslint: 8.57.0
eslint-import-resolver-node: 0.3.9 eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0) eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-import: 2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
eslint-plugin-react: 7.33.2(eslint@8.57.0) eslint-plugin-react: 7.33.2(eslint@8.57.0)
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0) eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
@ -20104,7 +20104,7 @@ snapshots:
enhanced-resolve: 5.15.0 enhanced-resolve: 5.15.0
eslint: 8.57.0 eslint: 8.57.0
eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0) eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-import: 2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
fast-glob: 3.3.1 fast-glob: 3.3.1
get-tsconfig: 4.7.2 get-tsconfig: 4.7.2
is-core-module: 2.13.1 is-core-module: 2.13.1
@ -20143,7 +20143,7 @@ snapshots:
eslint: 8.57.0 eslint: 8.57.0
ignore: 5.3.1 ignore: 5.3.1
eslint-plugin-import@2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): eslint-plugin-import@2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
dependencies: dependencies:
array-includes: 3.1.7 array-includes: 3.1.7
array.prototype.findlastindex: 1.2.3 array.prototype.findlastindex: 1.2.3
@ -22405,7 +22405,7 @@ snapshots:
lru-cache@7.18.3: {} lru-cache@7.18.3: {}
lucide-react@0.330.0(react@18.3.1): lucide-react@0.468.0(react@18.3.1):
dependencies: dependencies:
react: 18.3.1 react: 18.3.1