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 { redirect } from "next/navigation";
|
||||||
import GlobalActions from "@/components/dashboard/GlobalActions";
|
import GlobalActions from "@/components/dashboard/GlobalActions";
|
||||||
import ProfileOptions from "@/components/dashboard/header/ProfileOptions";
|
import ProfileOptions from "@/components/dashboard/header/ProfileOptions";
|
||||||
import { SearchInput } from "@/components/dashboard/search/SearchInput";
|
|
||||||
import HoarderLogo from "@/components/HoarderIcon";
|
import HoarderLogo from "@/components/HoarderIcon";
|
||||||
import { getServerAuthSession } from "@/server/auth";
|
import { getServerAuthSession } from "@/server/auth";
|
||||||
|
|
||||||
@ -20,7 +19,6 @@ export default async function Header() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 gap-2">
|
<div className="flex flex-1 gap-2">
|
||||||
<SearchInput className="min-w-40 bg-muted" />
|
|
||||||
<GlobalActions />
|
<GlobalActions />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|||||||
@ -100,7 +100,7 @@ export default function EditableHour({
|
|||||||
const tzOffset = spacetime().offset() / 60;
|
const tzOffset = spacetime().offset() / 60;
|
||||||
|
|
||||||
|
|
||||||
const localDateTime = (h) => {
|
const localDateTime = (h: ZHour): string => {
|
||||||
return spacetime(h.date).add(h.time + tzOffset, "hour").format('{hour} {ampm}');
|
return spacetime(h.date).add(h.time + tzOffset, "hour").format('{hour} {ampm}');
|
||||||
}
|
}
|
||||||
hour.datetime = localDateTime(hour);
|
hour.datetime = localDateTime(hour);
|
||||||
@ -111,14 +111,6 @@ export default function EditableHour({
|
|||||||
// console.log(hour.categoryDesc);
|
// console.log(hour.categoryDesc);
|
||||||
}, [hour]);
|
}, [hour]);
|
||||||
|
|
||||||
|
|
||||||
function isActiveHour(hour: ZHour) {
|
|
||||||
const now = new TZDate();
|
|
||||||
const isCurrentHour = ((now.getHours() - localDateTime.hour()) == 0);
|
|
||||||
const isToday = (localDateTime.format("iso-short") == format(now, "yyyy-MM-dd"));
|
|
||||||
return isToday && isCurrentHour;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reload(newHour: ZHour) {
|
function reload(newHour: ZHour) {
|
||||||
setHour(newHour);
|
setHour(newHour);
|
||||||
}
|
}
|
||||||
@ -130,7 +122,8 @@ export default function EditableHour({
|
|||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
background: hour.background!, color: hour.foreground!, fontFamily: "inherit",
|
background: hour.background!, color: hour.foreground!, fontFamily: "inherit",
|
||||||
gridTemplateColumns: window.innerWidth > 640 ? "50px 100px 3fr 2fr" : "50px 100px 1fr", // Known issue: This won't work if the screen is resized, only on reload
|
// gridTemplateColumns: window.innerWidth > 640 ? "50px 100px 3fr 2fr" : "50px 100px 1fr", // Known issue: This won't work if the screen is resized, only on reload
|
||||||
|
gridTemplateColumns: "50px 100px 3fr 2fr"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-right hover:cursor-default">
|
<span className="text-right hover:cursor-default">
|
||||||
@ -154,7 +147,7 @@ export default function EditableHour({
|
|||||||
i={i}
|
i={i}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="hidden sm:block">
|
<span className="block">
|
||||||
{hour.categoryCode != undefined ?
|
{hour.categoryCode != undefined ?
|
||||||
<EditableHourComment
|
<EditableHourComment
|
||||||
originalText={hour.comment ?? hour.categoryName}
|
originalText={hour.comment ?? hour.categoryName}
|
||||||
@ -164,7 +157,7 @@ export default function EditableHour({
|
|||||||
/>
|
/>
|
||||||
: ""}
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
<span className="block sm:hidden">
|
<span className="hidden">
|
||||||
<div className="w-full text-left edit-hour-comment"
|
<div className="w-full text-left edit-hour-comment"
|
||||||
style={{
|
style={{
|
||||||
background: hour.background ?? "inherit", color: hour.foreground ?? "inherit", fontFamily: "inherit",
|
background: hour.background ?? "inherit", color: hour.foreground ?? "inherit", fontFamily: "inherit",
|
||||||
@ -172,7 +165,7 @@ export default function EditableHour({
|
|||||||
{hour.categoryName}
|
{hour.categoryName}
|
||||||
</div>
|
</div>
|
||||||
</span>
|
</span>
|
||||||
<div className="hidden sm:flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
|
|
||||||
{hour.categoryCode != undefined ?
|
{hour.categoryCode != undefined ?
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
"dayjs": "^1.11.10",
|
||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.33.0",
|
||||||
"fastest-levenshtein": "^1.0.16",
|
"fastest-levenshtein": "^1.0.16",
|
||||||
"lucide-react": "^0.330.0",
|
"lucide-react": "latest",
|
||||||
"next": "latest",
|
"next": "latest",
|
||||||
"next-auth": "^4.24.5",
|
"next-auth": "^4.24.5",
|
||||||
"next-pwa": "^5.6.0",
|
"next-pwa": "^5.6.0",
|
||||||
|
|||||||
1
apps/web/public/sw.js
Normal file
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
|
specifier: ^1.0.16
|
||||||
version: 1.0.16
|
version: 1.0.16
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.330.0
|
specifier: latest
|
||||||
version: 0.330.0(react@18.3.1)
|
version: 0.468.0(react@18.3.1)
|
||||||
next:
|
next:
|
||||||
specifier: latest
|
specifier: latest
|
||||||
version: 15.0.3(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 15.0.3(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
@ -8330,10 +8330,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
|
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
lucide-react@0.330.0:
|
lucide-react@0.468.0:
|
||||||
resolution: {integrity: sha512-CQwY+Fpbt2kxCoVhuN0RCZDCYlbYnqB870Bl/vIQf3ER/cnDDQ6moLmEkguRyruAUGd4j3Lc4mtnJosXnqHheA==}
|
resolution: {integrity: sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
|
||||||
|
|
||||||
magic-string@0.25.9:
|
magic-string@0.25.9:
|
||||||
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
|
resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==}
|
||||||
@ -20050,7 +20050,7 @@ snapshots:
|
|||||||
eslint: 8.57.0
|
eslint: 8.57.0
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0)
|
eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0)
|
||||||
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
|
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
|
||||||
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
|
eslint-plugin-jsx-a11y: 6.8.0(eslint@8.57.0)
|
||||||
eslint-plugin-react: 7.33.2(eslint@8.57.0)
|
eslint-plugin-react: 7.33.2(eslint@8.57.0)
|
||||||
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
|
eslint-plugin-react-hooks: 4.6.0(eslint@8.57.0)
|
||||||
@ -20104,7 +20104,7 @@ snapshots:
|
|||||||
enhanced-resolve: 5.15.0
|
enhanced-resolve: 5.15.0
|
||||||
eslint: 8.57.0
|
eslint: 8.57.0
|
||||||
eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
|
eslint-module-utils: 2.8.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
|
||||||
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0)
|
eslint-plugin-import: 2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0)
|
||||||
fast-glob: 3.3.1
|
fast-glob: 3.3.1
|
||||||
get-tsconfig: 4.7.2
|
get-tsconfig: 4.7.2
|
||||||
is-core-module: 2.13.1
|
is-core-module: 2.13.1
|
||||||
@ -20143,7 +20143,7 @@ snapshots:
|
|||||||
eslint: 8.57.0
|
eslint: 8.57.0
|
||||||
ignore: 5.3.1
|
ignore: 5.3.1
|
||||||
|
|
||||||
eslint-plugin-import@2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0):
|
eslint-plugin-import@2.29.0(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.1.0(eslint@8.57.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.0(eslint@8.57.0))(eslint@8.57.0))(eslint@8.57.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
array-includes: 3.1.7
|
array-includes: 3.1.7
|
||||||
array.prototype.findlastindex: 1.2.3
|
array.prototype.findlastindex: 1.2.3
|
||||||
@ -22405,7 +22405,7 @@ snapshots:
|
|||||||
|
|
||||||
lru-cache@7.18.3: {}
|
lru-cache@7.18.3: {}
|
||||||
|
|
||||||
lucide-react@0.330.0(react@18.3.1):
|
lucide-react@0.468.0(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user