Cleanup for deploy
This commit is contained in:
parent
bfb30405df
commit
2f7e2046e6
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -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 };
|
||||
},
|
||||
});
|
||||
@ -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 };
|
||||
},
|
||||
});
|
||||
@ -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 } };
|
||||
},
|
||||
});
|
||||
@ -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 };
|
||||
},
|
||||
});
|
||||
@ -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 };
|
||||
},
|
||||
});
|
||||
@ -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) };
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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 };
|
||||
},
|
||||
});
|
||||
@ -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),
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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 };
|
||||
},
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export default function CatchAll() {
|
||||
return null;
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
@ -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't appear in the homepage</p>
|
||||
</InfoTooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function ArchivedBookmarkPage() {
|
||||
return (
|
||||
<Bookmarks
|
||||
header={header()}
|
||||
query={{ archived: true }}
|
||||
showDivider={true}
|
||||
showEditorCard={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import AISettings from "@/components/settings/AISettings";
|
||||
|
||||
export default function AISettingsPage() {
|
||||
return <AISettings />;
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import FeedSettings from "@/components/settings/FeedSettings";
|
||||
|
||||
export default function FeedSettingsPage() {
|
||||
return <FeedSettings />;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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} />,
|
||||
});
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 }],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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} />
|
||||
),
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
),
|
||||
};
|
||||
}
|
||||
@ -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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
@ -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} />
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,6 @@ import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import GlobalActions from "@/components/dashboard/GlobalActions";
|
||||
import ProfileOptions from "@/components/dashboard/header/ProfileOptions";
|
||||
import { SearchInput } from "@/components/dashboard/search/SearchInput";
|
||||
import HoarderLogo from "@/components/HoarderIcon";
|
||||
import { getServerAuthSession } from "@/server/auth";
|
||||
|
||||
@ -20,7 +19,6 @@ export default async function Header() {
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<SearchInput className="min-w-40 bg-muted" />
|
||||
<GlobalActions />
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
|
||||
@ -100,7 +100,7 @@ export default function EditableHour({
|
||||
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}');
|
||||
}
|
||||
hour.datetime = localDateTime(hour);
|
||||
@ -111,14 +111,6 @@ export default function EditableHour({
|
||||
// console.log(hour.categoryDesc);
|
||||
}, [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) {
|
||||
setHour(newHour);
|
||||
}
|
||||
@ -130,7 +122,8 @@ export default function EditableHour({
|
||||
)}
|
||||
style={{
|
||||
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">
|
||||
@ -154,7 +147,7 @@ export default function EditableHour({
|
||||
i={i}
|
||||
/>
|
||||
</div>
|
||||
<span className="hidden sm:block">
|
||||
<span className="block">
|
||||
{hour.categoryCode != undefined ?
|
||||
<EditableHourComment
|
||||
originalText={hour.comment ?? hour.categoryName}
|
||||
@ -164,7 +157,7 @@ export default function EditableHour({
|
||||
/>
|
||||
: ""}
|
||||
</span>
|
||||
<span className="block sm:hidden">
|
||||
<span className="hidden">
|
||||
<div className="w-full text-left edit-hour-comment"
|
||||
style={{
|
||||
background: hour.background ?? "inherit", color: hour.foreground ?? "inherit", fontFamily: "inherit",
|
||||
@ -172,7 +165,7 @@ export default function EditableHour({
|
||||
{hour.categoryName}
|
||||
</div>
|
||||
</span>
|
||||
<div className="hidden sm:flex items-center justify-end">
|
||||
<div className="flex items-center justify-end">
|
||||
|
||||
{hour.categoryCode != undefined ?
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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 };
|
||||
@ -55,7 +55,7 @@
|
||||
"dayjs": "^1.11.10",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"fastest-levenshtein": "^1.0.16",
|
||||
"lucide-react": "^0.330.0",
|
||||
"lucide-react": "latest",
|
||||
"next": "latest",
|
||||
"next-auth": "^4.24.5",
|
||||
"next-pwa": "^5.6.0",
|
||||
|
||||
1
apps/web/public/sw.js
Normal file
1
apps/web/public/sw.js
Normal file
File diff suppressed because one or more lines are too long
1
apps/web/public/workbox-01fd22c6.js
Normal file
1
apps/web/public/workbox-01fd22c6.js
Normal file
File diff suppressed because one or more lines are too long
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@ -272,8 +272,8 @@ importers:
|
||||
specifier: ^1.0.16
|
||||
version: 1.0.16
|
||||
lucide-react:
|
||||
specifier: ^0.330.0
|
||||
version: 0.330.0(react@18.3.1)
|
||||
specifier: latest
|
||||
version: 0.468.0(react@18.3.1)
|
||||
next:
|
||||
specifier: latest
|
||||
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==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
lucide-react@0.330.0:
|
||||
resolution: {integrity: sha512-CQwY+Fpbt2kxCoVhuN0RCZDCYlbYnqB870Bl/vIQf3ER/cnDDQ6moLmEkguRyruAUGd4j3Lc4mtnJosXnqHheA==}
|
||||
lucide-react@0.468.0:
|
||||
resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
|
||||
@ -20050,7 +20050,7 @@ snapshots:
|
||||
eslint: 8.57.0
|
||||
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-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-react: 7.33.2(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
|
||||
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
|
||||
get-tsconfig: 4.7.2
|
||||
is-core-module: 2.13.1
|
||||
@ -20143,7 +20143,7 @@ snapshots:
|
||||
eslint: 8.57.0
|
||||
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:
|
||||
array-includes: 3.1.7
|
||||
array.prototype.findlastindex: 1.2.3
|
||||
@ -22405,7 +22405,7 @@ snapshots:
|
||||
|
||||
lru-cache@7.18.3: {}
|
||||
|
||||
lucide-react@0.330.0(react@18.3.1):
|
||||
lucide-react@0.468.0(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user