aboutsummaryrefslogtreecommitdiffstats
path: root/apps/web
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web')
-rw-r--r--apps/web/app/api/[[...route]]/route.ts28
-rw-r--r--apps/web/app/api/assets/[assetId]/route.ts85
-rw-r--r--apps/web/app/api/assets/route.ts124
-rw-r--r--apps/web/app/api/bookmarks/export/route.tsx56
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts37
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts36
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/highlights/route.ts18
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts18
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts54
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/summarize/route.ts19
-rw-r--r--apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts45
-rw-r--r--apps/web/app/api/v1/bookmarks/route.ts39
-rw-r--r--apps/web/app/api/v1/bookmarks/search/route.ts43
-rw-r--r--apps/web/app/api/v1/bookmarks/singlefile/route.ts54
-rw-r--r--apps/web/app/api/v1/highlights/[highlightId]/route.ts50
-rw-r--r--apps/web/app/api/v1/highlights/route.ts30
-rw-r--r--apps/web/app/api/v1/lists/[listId]/bookmarks/[bookmarkId]/route.ts35
-rw-r--r--apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts19
-rw-r--r--apps/web/app/api/v1/lists/[listId]/route.ts55
-rw-r--r--apps/web/app/api/v1/lists/route.ts26
-rw-r--r--apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts26
-rw-r--r--apps/web/app/api/v1/tags/[tagId]/route.ts55
-rw-r--r--apps/web/app/api/v1/tags/route.ts14
-rw-r--r--apps/web/app/api/v1/users/me/route.ts14
-rw-r--r--apps/web/app/api/v1/users/me/stats/route.ts14
-rw-r--r--apps/web/app/api/v1/utils/handler.ts170
-rw-r--r--apps/web/app/api/v1/utils/pagination.ts30
-rw-r--r--apps/web/app/api/v1/utils/types.ts11
-rw-r--r--apps/web/app/dashboard/layout.tsx42
-rw-r--r--apps/web/app/dashboard/lists/[listId]/page.tsx15
-rw-r--r--apps/web/app/dashboard/search/page.tsx10
-rw-r--r--apps/web/app/dashboard/tags/[tagId]/page.tsx15
-rw-r--r--apps/web/app/layout.tsx21
-rw-r--r--apps/web/app/public/layout.tsx16
-rw-r--r--apps/web/app/public/lists/[listId]/not-found.tsx18
-rw-r--r--apps/web/app/public/lists/[listId]/page.tsx84
-rw-r--r--apps/web/app/settings/assets/page.tsx2
-rw-r--r--apps/web/app/settings/layout.tsx17
-rw-r--r--apps/web/components/admin/BackgroundJobs.tsx24
-rw-r--r--apps/web/components/dashboard/SortOrderToggle.tsx36
-rw-r--r--apps/web/components/dashboard/bookmarks/AssetCard.tsx4
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkCard.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx8
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx15
-rw-r--r--apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx8
-rw-r--r--apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx2
-rw-r--r--apps/web/components/dashboard/bookmarks/LinkCard.tsx28
-rw-r--r--apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx4
-rw-r--r--apps/web/components/dashboard/bookmarks/TextCard.tsx4
-rw-r--r--apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx6
-rw-r--r--apps/web/components/dashboard/lists/EditListModal.tsx14
-rw-r--r--apps/web/components/dashboard/lists/ListOptions.tsx34
-rw-r--r--apps/web/components/dashboard/lists/PublicListLink.tsx67
-rw-r--r--apps/web/components/dashboard/lists/RssLink.tsx95
-rw-r--r--apps/web/components/dashboard/lists/ShareListModal.tsx70
-rw-r--r--apps/web/components/dashboard/preview/AssetContentSection.tsx2
-rw-r--r--apps/web/components/dashboard/preview/AttachmentBox.tsx2
-rw-r--r--apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx23
-rw-r--r--apps/web/components/dashboard/preview/BookmarkPreview.tsx120
-rw-r--r--apps/web/components/dashboard/preview/TextContentSection.tsx2
-rw-r--r--apps/web/components/dashboard/search/SearchInput.tsx2
-rw-r--r--apps/web/components/dashboard/sidebar/AllLists.tsx48
-rw-r--r--apps/web/components/dashboard/tags/TagOptions.tsx14
-rw-r--r--apps/web/components/dashboard/tags/TagPill.tsx1
-rw-r--r--apps/web/components/public/lists/PublicBookmarkGrid.tsx247
-rw-r--r--apps/web/components/public/lists/PublicListHeader.tsx17
-rw-r--r--apps/web/components/settings/FeedSettings.tsx72
-rw-r--r--apps/web/components/settings/ImportExport.tsx155
-rw-r--r--apps/web/components/settings/UserOptions.tsx148
-rw-r--r--apps/web/components/ui/copy-button.tsx41
-rw-r--r--apps/web/components/ui/input.tsx16
-rw-r--r--apps/web/components/utils/useShowArchived.tsx24
-rw-r--r--apps/web/lib/clientConfig.tsx3
-rw-r--r--apps/web/lib/exportBookmarks.ts51
-rw-r--r--apps/web/lib/hooks/bookmark-search.ts9
-rw-r--r--apps/web/lib/i18n/locales/en/translation.json42
-rw-r--r--apps/web/lib/i18n/locales/en_US/translation.json41
-rw-r--r--apps/web/lib/importBookmarkParser.ts32
-rw-r--r--apps/web/lib/userSettings.tsx34
-rw-r--r--apps/web/next.config.mjs2
-rw-r--r--apps/web/package.json2
81 files changed, 1622 insertions, 1394 deletions
diff --git a/apps/web/app/api/[[...route]]/route.ts b/apps/web/app/api/[[...route]]/route.ts
new file mode 100644
index 00000000..8930671d
--- /dev/null
+++ b/apps/web/app/api/[[...route]]/route.ts
@@ -0,0 +1,28 @@
+import { createContextFromRequest } from "@/server/api/client";
+import { Hono } from "hono";
+import { createMiddleware } from "hono/factory";
+import { handle } from "hono/vercel";
+
+import allApp from "@karakeep/api";
+import { Context } from "@karakeep/trpc";
+
+export const runtime = "nodejs";
+
+export const nextAuth = createMiddleware<{
+ Variables: {
+ ctx: Context;
+ };
+}>(async (c, next) => {
+ const ctx = await createContextFromRequest(c.req.raw);
+ c.set("ctx", ctx);
+ await next();
+});
+
+const app = new Hono().basePath("/api").use(nextAuth).route("/", allApp);
+
+export const GET = handle(app);
+export const POST = handle(app);
+export const PATCH = handle(app);
+export const DELETE = handle(app);
+export const OPTIONS = handle(app);
+export const PUT = handle(app);
diff --git a/apps/web/app/api/assets/[assetId]/route.ts b/apps/web/app/api/assets/[assetId]/route.ts
deleted file mode 100644
index 8abb9080..00000000
--- a/apps/web/app/api/assets/[assetId]/route.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { createContextFromRequest } from "@/server/api/client";
-import { and, eq } from "drizzle-orm";
-
-import { assets } from "@karakeep/db/schema";
-import {
- createAssetReadStream,
- getAssetSize,
- readAssetMetadata,
-} from "@karakeep/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 [metadata, size] = await Promise.all([
- readAssetMetadata({
- userId: ctx.user.id,
- assetId: params.assetId,
- }),
-
- getAssetSize({
- 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) : size - 1;
-
- const stream = createAssetReadStream({
- userId: ctx.user.id,
- assetId: params.assetId,
- start,
- end,
- });
-
- return new Response(
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
- stream as any,
- {
- status: 206, // Partial Content
- headers: {
- "Content-Range": `bytes ${start}-${end}/${size}`,
- "Accept-Ranges": "bytes",
- "Content-Length": (end - start + 1).toString(),
- "Content-type": metadata.contentType,
- },
- },
- );
- } else {
- const stream = createAssetReadStream({
- userId: ctx.user.id,
- assetId: params.assetId,
- });
-
- return new Response(
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
- stream as any,
- {
- status: 200,
- headers: {
- "Content-Length": size.toString(),
- "Content-type": metadata.contentType,
- },
- },
- );
- }
-}
diff --git a/apps/web/app/api/assets/route.ts b/apps/web/app/api/assets/route.ts
deleted file mode 100644
index e2e1e63e..00000000
--- a/apps/web/app/api/assets/route.ts
+++ /dev/null
@@ -1,124 +0,0 @@
-import * as fs from "fs";
-import * as os from "os";
-import * as path from "path";
-import { Readable } from "stream";
-import { pipeline } from "stream/promises";
-import { createContextFromRequest } from "@/server/api/client";
-import { TRPCError } from "@trpc/server";
-
-import type { ZUploadResponse } from "@karakeep/shared/types/uploads";
-import { assets, AssetTypes } from "@karakeep/db/schema";
-import {
- newAssetId,
- saveAssetFromFile,
- SUPPORTED_UPLOAD_ASSET_TYPES,
-} from "@karakeep/shared/assetdb";
-import serverConfig from "@karakeep/shared/config";
-import { AuthedContext } from "@karakeep/trpc";
-
-const MAX_UPLOAD_SIZE_BYTES = serverConfig.maxAssetSizeMb * 1024 * 1024;
-
-export const dynamic = "force-dynamic";
-
-// Helper to convert Web Stream to Node Stream (requires Node >= 16.5 / 14.18)
-function webStreamToNode(webStream: ReadableStream<Uint8Array>): Readable {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
- return Readable.fromWeb(webStream as any); // Type assertion might be needed
-}
-
-export async function uploadFromPostData(
- user: AuthedContext["user"],
- db: AuthedContext["db"],
- formData: FormData,
-): Promise<
- | { error: string; status: number }
- | {
- assetId: string;
- contentType: string;
- fileName: string;
- size: number;
- }
-> {
- const data = formData.get("file") ?? formData.get("image");
-
- if (!(data instanceof File)) {
- return { error: "Bad request", status: 400 };
- }
-
- const contentType = data.type;
- const fileName = data.name;
- if (!SUPPORTED_UPLOAD_ASSET_TYPES.has(contentType)) {
- return { error: "Unsupported asset type", status: 400 };
- }
- if (data.size > MAX_UPLOAD_SIZE_BYTES) {
- return { error: "Asset is too big", status: 413 };
- }
-
- let tempFilePath: string | undefined;
-
- try {
- tempFilePath = path.join(os.tmpdir(), `karakeep-upload-${Date.now()}`);
- await pipeline(
- webStreamToNode(data.stream()),
- fs.createWriteStream(tempFilePath),
- );
- const [assetDb] = await db
- .insert(assets)
- .values({
- id: newAssetId(),
- // Initially, uploads are uploaded for unknown purpose
- // And without an attached bookmark.
- assetType: AssetTypes.UNKNOWN,
- bookmarkId: null,
- userId: user.id,
- contentType,
- size: data.size,
- fileName,
- })
- .returning();
-
- await saveAssetFromFile({
- userId: user.id,
- assetId: assetDb.id,
- assetPath: tempFilePath,
- metadata: { contentType, fileName },
- });
-
- return {
- assetId: assetDb.id,
- contentType,
- size: data.size,
- fileName,
- };
- } finally {
- if (tempFilePath) {
- await fs.promises.unlink(tempFilePath).catch(() => ({}));
- }
- }
-}
-
-export async function POST(request: Request) {
- const ctx = await createContextFromRequest(request);
- if (ctx.user === null) {
- 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 resp = await uploadFromPostData(ctx.user, ctx.db, formData);
- if ("error" in resp) {
- return Response.json({ error: resp.error }, { status: resp.status });
- }
-
- return Response.json({
- assetId: resp.assetId,
- contentType: resp.contentType,
- size: resp.size,
- fileName: resp.fileName,
- } satisfies ZUploadResponse);
-}
diff --git a/apps/web/app/api/bookmarks/export/route.tsx b/apps/web/app/api/bookmarks/export/route.tsx
index e550fcb5..f568b9f7 100644
--- a/apps/web/app/api/bookmarks/export/route.tsx
+++ b/apps/web/app/api/bookmarks/export/route.tsx
@@ -1,15 +1,23 @@
-import { toExportFormat, zExportSchema } from "@/lib/exportBookmarks";
+import { NextRequest } from "next/server";
+import {
+ toExportFormat,
+ toNetscapeFormat,
+ zExportSchema,
+} from "@/lib/exportBookmarks";
import { api, createContextFromRequest } from "@/server/api/client";
import { z } from "zod";
import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@karakeep/shared/types/bookmarks";
export const dynamic = "force-dynamic";
-export async function GET(request: Request) {
+export async function GET(request: NextRequest) {
const ctx = await createContextFromRequest(request);
if (!ctx.user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
+
+ const format = request.nextUrl.searchParams.get("format") ?? "json";
+
const req = {
limit: MAX_NUM_BOOKMARKS_PER_PAGE,
useCursorV2: true,
@@ -17,25 +25,43 @@ export async function GET(request: Request) {
};
let resp = await api.bookmarks.getBookmarks(req);
- let results = resp.bookmarks.map(toExportFormat);
+ let bookmarks = resp.bookmarks;
while (resp.nextCursor) {
resp = await api.bookmarks.getBookmarks({
- ...request,
+ ...req,
cursor: resp.nextCursor,
});
- results = [...results, ...resp.bookmarks.map(toExportFormat)];
+ bookmarks = [...bookmarks, ...resp.bookmarks];
}
- const exportData: z.infer<typeof zExportSchema> = {
- bookmarks: results.filter((b) => b.content !== null),
- };
+ if (format === "json") {
+ // Default JSON format
+ const exportData: z.infer<typeof zExportSchema> = {
+ bookmarks: bookmarks
+ .map(toExportFormat)
+ .filter((b) => b.content !== null),
+ };
- return new Response(JSON.stringify(exportData), {
- status: 200,
- headers: {
- "Content-type": "application/json",
- "Content-disposition": `attachment; filename="karakeep-export-${new Date().toISOString()}.json"`,
- },
- });
+ return new Response(JSON.stringify(exportData), {
+ status: 200,
+ headers: {
+ "Content-type": "application/json",
+ "Content-disposition": `attachment; filename="hoarder-export-${new Date().toISOString()}.json"`,
+ },
+ });
+ } else if (format === "netscape") {
+ // Netscape format
+ const netscapeContent = toNetscapeFormat(bookmarks);
+
+ return new Response(netscapeContent, {
+ status: 200,
+ headers: {
+ "Content-type": "text/html",
+ "Content-disposition": `attachment; filename="bookmarks-${new Date().toISOString()}.html"`,
+ },
+ });
+ } else {
+ return Response.json({ error: "Invalid format" }, { status: 400 });
+ }
}
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts
deleted file mode 100644
index 88e203de..00000000
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/[assetId]/route.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-import { z } from "zod";
-
-export const dynamic = "force-dynamic";
-
-export const PUT = (
- req: NextRequest,
- params: { params: { bookmarkId: string; assetId: string } },
-) =>
- buildHandler({
- req,
- bodySchema: z.object({ assetId: z.string() }),
- handler: async ({ api, body }) => {
- await api.assets.replaceAsset({
- bookmarkId: params.params.bookmarkId,
- oldAssetId: params.params.assetId,
- newAssetId: body!.assetId,
- });
- return { status: 204 };
- },
- });
-
-export const DELETE = (
- req: NextRequest,
- params: { params: { bookmarkId: string; assetId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- await api.assets.detachAsset({
- bookmarkId: params.params.bookmarkId,
- assetId: params.params.assetId,
- });
- return { status: 204 };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts
deleted file mode 100644
index 6c7c70d7..00000000
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/assets/route.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-import { zAssetSchema } from "@karakeep/shared/types/bookmarks";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (
- req: NextRequest,
- params: { params: { bookmarkId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const resp = await api.bookmarks.getBookmark({
- bookmarkId: params.params.bookmarkId,
- });
- return { status: 200, resp: { assets: resp.assets } };
- },
- });
-
-export const POST = (
- req: NextRequest,
- params: { params: { bookmarkId: string } },
-) =>
- buildHandler({
- req,
- bodySchema: zAssetSchema,
- handler: async ({ api, body }) => {
- const asset = await api.assets.attachAsset({
- bookmarkId: params.params.bookmarkId,
- asset: body!,
- });
- return { status: 201, resp: asset };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/highlights/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/highlights/route.ts
deleted file mode 100644
index 4e1f87a0..00000000
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/highlights/route.ts
+++ /dev/null
@@ -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.highlights.getForBookmark({
- bookmarkId: params.params.bookmarkId,
- });
- return { status: 200, resp };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts
deleted file mode 100644
index ad3052c9..00000000
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/lists/route.ts
+++ /dev/null
@@ -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 };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts
deleted file mode 100644
index fa551894..00000000
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/route.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-import { zUpdateBookmarksRequestSchema } from "@karakeep/shared/types/bookmarks";
-
-import { zGetBookmarkSearchParamsSchema } from "../../utils/types";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (
- req: NextRequest,
- { params }: { params: { bookmarkId: string } },
-) =>
- buildHandler({
- req,
- searchParamsSchema: zGetBookmarkSearchParamsSchema,
- handler: async ({ api, searchParams }) => {
- const bookmark = await api.bookmarks.getBookmark({
- bookmarkId: params.bookmarkId,
- includeContent: searchParams.includeContent,
- });
- 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 };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/summarize/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/summarize/route.ts
deleted file mode 100644
index ea41cad4..00000000
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/summarize/route.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-export const dynamic = "force-dynamic";
-
-export const POST = (
- req: NextRequest,
- params: { params: { bookmarkId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const bookmark = await api.bookmarks.summarizeBookmark({
- bookmarkId: params.params.bookmarkId,
- });
-
- return { status: 200, resp: bookmark };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts b/apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts
deleted file mode 100644
index 00c28afa..00000000
--- a/apps/web/app/api/v1/bookmarks/[bookmarkId]/tags/route.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-import { z } from "zod";
-
-import { zManipulatedTagSchema } from "@karakeep/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 } };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/route.ts b/apps/web/app/api/v1/bookmarks/route.ts
deleted file mode 100644
index 1605d2b5..00000000
--- a/apps/web/app/api/v1/bookmarks/route.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { NextRequest } from "next/server";
-import { z } from "zod";
-
-import { zNewBookmarkRequestSchema } from "@karakeep/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(),
- // TODO: Change the default to false in a couple of releases.
- includeContent: zStringBool.optional().default("true"),
- })
- .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 };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/search/route.ts b/apps/web/app/api/v1/bookmarks/search/route.ts
deleted file mode 100644
index 52081c7f..00000000
--- a/apps/web/app/api/v1/bookmarks/search/route.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { NextRequest } from "next/server";
-import { z } from "zod";
-
-import { buildHandler } from "../../utils/handler";
-import { zGetBookmarkSearchParamsSchema } from "../../utils/types";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (req: NextRequest) =>
- buildHandler({
- req,
- searchParamsSchema: z
- .object({
- q: z.string(),
- limit: z.coerce.number().optional(),
- cursor: z
- .string()
- // Search cursor V1 is just a number
- .pipe(z.coerce.number())
- .transform((val) => {
- return { ver: 1 as const, offset: val };
- })
- .optional(),
- })
- .and(zGetBookmarkSearchParamsSchema),
- handler: async ({ api, searchParams }) => {
- const bookmarks = await api.bookmarks.searchBookmarks({
- text: searchParams.q,
- cursor: searchParams.cursor,
- limit: searchParams.limit,
- includeContent: searchParams.includeContent,
- });
- return {
- status: 200,
- resp: {
- bookmarks: bookmarks.bookmarks,
- nextCursor: bookmarks.nextCursor
- ? `${bookmarks.nextCursor.offset}`
- : null,
- },
- };
- },
- });
diff --git a/apps/web/app/api/v1/bookmarks/singlefile/route.ts b/apps/web/app/api/v1/bookmarks/singlefile/route.ts
deleted file mode 100644
index 7c1d7201..00000000
--- a/apps/web/app/api/v1/bookmarks/singlefile/route.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { createContextFromRequest } from "@/server/api/client";
-import { TRPCError } from "@trpc/server";
-
-import serverConfig from "@karakeep/shared/config";
-import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
-import { createCallerFactory } from "@karakeep/trpc";
-import { appRouter } from "@karakeep/trpc/routers/_app";
-
-import { uploadFromPostData } from "../../../assets/route";
-
-export const dynamic = "force-dynamic";
-
-export async function POST(req: Request) {
- const ctx = await createContextFromRequest(req);
- 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 req.formData();
- const up = await uploadFromPostData(ctx.user, ctx.db, formData);
-
- if ("error" in up) {
- return Response.json({ error: up.error }, { status: up.status });
- }
-
- const url = formData.get("url");
- if (!url) {
- throw new TRPCError({
- message: "URL is required",
- code: "BAD_REQUEST",
- });
- }
- if (typeof url !== "string") {
- throw new TRPCError({
- message: "URL must be a string",
- code: "BAD_REQUEST",
- });
- }
-
- const createCaller = createCallerFactory(appRouter);
- const api = createCaller(ctx);
-
- const bookmark = await api.bookmarks.createBookmark({
- type: BookmarkTypes.LINK,
- url,
- precrawledArchiveId: up.assetId,
- });
- return Response.json(bookmark, { status: 201 });
-}
diff --git a/apps/web/app/api/v1/highlights/[highlightId]/route.ts b/apps/web/app/api/v1/highlights/[highlightId]/route.ts
deleted file mode 100644
index 50420427..00000000
--- a/apps/web/app/api/v1/highlights/[highlightId]/route.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-import { zUpdateHighlightSchema } from "@karakeep/shared/types/highlights";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (
- req: NextRequest,
- { params }: { params: { highlightId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const highlight = await api.highlights.get({
- highlightId: params.highlightId,
- });
- return { status: 200, resp: highlight };
- },
- });
-
-export const PATCH = (
- req: NextRequest,
- { params }: { params: { highlightId: string } },
-) =>
- buildHandler({
- req,
- bodySchema: zUpdateHighlightSchema.omit({ highlightId: true }),
- handler: async ({ api, body }) => {
- const highlight = await api.highlights.update({
- highlightId: params.highlightId,
- ...body!,
- });
- return { status: 200, resp: highlight };
- },
- });
-
-export const DELETE = (
- req: NextRequest,
- { params }: { params: { highlightId: string } },
-) =>
- buildHandler({
- req,
- handler: async ({ api }) => {
- const highlight = await api.highlights.delete({
- highlightId: params.highlightId,
- });
- return { status: 200, resp: highlight };
- },
- });
diff --git a/apps/web/app/api/v1/highlights/route.ts b/apps/web/app/api/v1/highlights/route.ts
deleted file mode 100644
index e95d84f6..00000000
--- a/apps/web/app/api/v1/highlights/route.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-import { zNewHighlightSchema } from "@karakeep/shared/types/highlights";
-
-import { adaptPagination, zPagination } from "../utils/pagination";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (req: NextRequest) =>
- buildHandler({
- req,
- searchParamsSchema: zPagination,
- handler: async ({ api, searchParams }) => {
- const resp = await api.highlights.getAll({
- ...searchParams,
- });
- return { status: 200, resp: adaptPagination(resp) };
- },
- });
-
-export const POST = (req: NextRequest) =>
- buildHandler({
- req,
- bodySchema: zNewHighlightSchema,
- handler: async ({ body, api }) => {
- const resp = await api.highlights.create(body!);
- return { status: 201, resp };
- },
- });
diff --git a/apps/web/app/api/v1/lists/[listId]/bookmarks/[bookmarkId]/route.ts b/apps/web/app/api/v1/lists/[listId]/bookmarks/[bookmarkId]/route.ts
deleted file mode 100644
index 6efe2055..00000000
--- a/apps/web/app/api/v1/lists/[listId]/bookmarks/[bookmarkId]/route.ts
+++ /dev/null
@@ -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 };
- },
- });
diff --git a/apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts b/apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts
deleted file mode 100644
index 3977413a..00000000
--- a/apps/web/app/api/v1/lists/[listId]/bookmarks/route.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-import { adaptPagination, zPagination } from "@/app/api/v1/utils/pagination";
-import { zGetBookmarkSearchParamsSchema } from "@/app/api/v1/utils/types";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (req: NextRequest, params: { params: { listId: string } }) =>
- buildHandler({
- req,
- searchParamsSchema: zPagination.and(zGetBookmarkSearchParamsSchema),
- handler: async ({ api, searchParams }) => {
- const bookmarks = await api.bookmarks.getBookmarks({
- listId: params.params.listId,
- ...searchParams,
- });
- return { status: 200, resp: adaptPagination(bookmarks) };
- },
- });
diff --git a/apps/web/app/api/v1/lists/[listId]/route.ts b/apps/web/app/api/v1/lists/[listId]/route.ts
deleted file mode 100644
index 2cddbfdb..00000000
--- a/apps/web/app/api/v1/lists/[listId]/route.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-import { zEditBookmarkListSchema } from "@karakeep/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: zEditBookmarkListSchema.omit({ listId: true }),
- handler: async ({ api, body }) => {
- const list = await api.lists.edit({
- ...body!,
- listId: params.listId,
- });
- 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,
- };
- },
- });
diff --git a/apps/web/app/api/v1/lists/route.ts b/apps/web/app/api/v1/lists/route.ts
deleted file mode 100644
index 5def2506..00000000
--- a/apps/web/app/api/v1/lists/route.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { NextRequest } from "next/server";
-
-import { zNewBookmarkListSchema } from "@karakeep/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 };
- },
- });
diff --git a/apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts b/apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts
deleted file mode 100644
index cfc0af51..00000000
--- a/apps/web/app/api/v1/tags/[tagId]/bookmarks/route.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-import { adaptPagination, zPagination } from "@/app/api/v1/utils/pagination";
-import { zGetBookmarkSearchParamsSchema } from "@/app/api/v1/utils/types";
-
-export const dynamic = "force-dynamic";
-
-export const GET = (
- req: NextRequest,
- { params }: { params: { tagId: string } },
-) =>
- buildHandler({
- req,
- searchParamsSchema: zPagination.and(zGetBookmarkSearchParamsSchema),
- 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),
- };
- },
- });
diff --git a/apps/web/app/api/v1/tags/[tagId]/route.ts b/apps/web/app/api/v1/tags/[tagId]/route.ts
deleted file mode 100644
index 234d952d..00000000
--- a/apps/web/app/api/v1/tags/[tagId]/route.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { NextRequest } from "next/server";
-import { buildHandler } from "@/app/api/v1/utils/handler";
-
-import { zUpdateTagRequestSchema } from "@karakeep/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,
- };
- },
- });
diff --git a/apps/web/app/api/v1/tags/route.ts b/apps/web/app/api/v1/tags/route.ts
deleted file mode 100644
index 9625820c..00000000
--- a/apps/web/app/api/v1/tags/route.ts
+++ /dev/null
@@ -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 };
- },
- });
diff --git a/apps/web/app/api/v1/users/me/route.ts b/apps/web/app/api/v1/users/me/route.ts
deleted file mode 100644
index bf0a3ba2..00000000
--- a/apps/web/app/api/v1/users/me/route.ts
+++ /dev/null
@@ -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 user = await api.users.whoami();
- return { status: 200, resp: user };
- },
- });
diff --git a/apps/web/app/api/v1/users/me/stats/route.ts b/apps/web/app/api/v1/users/me/stats/route.ts
deleted file mode 100644
index 359c3156..00000000
--- a/apps/web/app/api/v1/users/me/stats/route.ts
+++ /dev/null
@@ -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 stats = await api.users.stats();
- return { status: 200, resp: stats };
- },
- });
diff --git a/apps/web/app/api/v1/utils/handler.ts b/apps/web/app/api/v1/utils/handler.ts
deleted file mode 100644
index 9154506d..00000000
--- a/apps/web/app/api/v1/utils/handler.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-import { NextRequest } from "next/server";
-import {
- createContextFromRequest,
- createTrcpClientFromCtx,
-} from "@/server/api/client";
-import { TRPCError } from "@trpc/server";
-import { z, ZodError } from "zod";
-
-import { Context } from "@karakeep/trpc";
-
-function trpcCodeToHttpCode(code: TRPCError["code"]) {
- switch (code) {
- case "BAD_REQUEST":
- case "PARSE_ERROR":
- return 400;
- case "UNAUTHORIZED":
- return 401;
- case "FORBIDDEN":
- return 403;
- case "NOT_FOUND":
- return 404;
- case "METHOD_NOT_SUPPORTED":
- return 405;
- case "TIMEOUT":
- return 408;
- case "PAYLOAD_TOO_LARGE":
- return 413;
- case "INTERNAL_SERVER_ERROR":
- return 500;
- default:
- return 500;
- }
-}
-
-interface ErrorMessage {
- path: (string | number)[];
- message: string;
-}
-
-function formatZodError(error: ZodError): string {
- if (!error.issues) {
- return error.message || "An unknown error occurred";
- }
-
- const errors: ErrorMessage[] = error.issues.map((issue) => ({
- path: issue.path,
- message: issue.message,
- }));
-
- const formattedErrors = errors.map((err) => {
- const path = err.path.join(".");
- return path ? `${path}: ${err.message}` : err.message;
- });
-
- return `${formattedErrors.join(", ")}`;
-}
-
-export interface TrpcAPIRequest<SearchParamsT, BodyType> {
- ctx: Context;
- api: ReturnType<typeof createTrcpClientFromCtx>;
- searchParams: SearchParamsT extends z.ZodTypeAny
- ? z.infer<SearchParamsT>
- : undefined;
- body: BodyType extends z.ZodTypeAny
- ? z.infer<BodyType> | undefined
- : undefined;
-}
-
-type SchemaType<T> = T extends z.ZodTypeAny
- ? z.infer<T> | undefined
- : undefined;
-
-export async function buildHandler<
- SearchParamsT extends z.ZodTypeAny | undefined,
- BodyT extends z.ZodTypeAny | undefined,
- InputT extends TrpcAPIRequest<SearchParamsT, BodyT>,
->({
- req,
- handler,
- searchParamsSchema,
- bodySchema,
-}: {
- req: NextRequest;
- handler: (req: InputT) => Promise<{ status: number; resp?: object }>;
- searchParamsSchema?: SearchParamsT | undefined;
- bodySchema?: BodyT | undefined;
-}) {
- try {
- const ctx = await createContextFromRequest(req);
- const api = createTrcpClientFromCtx(ctx);
-
- let searchParams: SchemaType<SearchParamsT> | undefined = undefined;
- if (searchParamsSchema !== undefined) {
- searchParams = searchParamsSchema.parse(
- Object.fromEntries(req.nextUrl.searchParams.entries()),
- ) as SchemaType<SearchParamsT>;
- }
-
- let body: SchemaType<BodyT> | undefined = undefined;
- if (bodySchema) {
- if (req.headers.get("Content-Type") !== "application/json") {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: "Content-Type must be application/json",
- });
- }
-
- let bodyJson = undefined;
- try {
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
- bodyJson = await req.json();
- } catch (e) {
- throw new TRPCError({
- code: "BAD_REQUEST",
- message: `Invalid JSON: ${(e as Error).message}`,
- });
- }
- body = bodySchema.parse(bodyJson) as SchemaType<BodyT>;
- }
-
- const { status, resp } = await handler({
- ctx,
- api,
- searchParams,
- body,
- } as InputT);
-
- return new Response(resp ? JSON.stringify(resp) : null, {
- status,
- headers: {
- "Content-Type": "application/json",
- },
- });
- } catch (e) {
- if (e instanceof ZodError) {
- return new Response(
- JSON.stringify({ code: "ParseError", message: formatZodError(e) }),
- {
- status: 400,
- headers: {
- "Content-Type": "application/json",
- },
- },
- );
- }
- if (e instanceof TRPCError) {
- let message = e.message;
- if (e.cause instanceof ZodError) {
- message = formatZodError(e.cause);
- }
- return new Response(JSON.stringify({ code: e.code, error: message }), {
- status: trpcCodeToHttpCode(e.code),
- headers: {
- "Content-Type": "application/json",
- },
- });
- } else {
- const error = e as Error;
- console.error(
- `Unexpected error in: ${req.method} ${req.nextUrl.pathname}:\n${error.stack}`,
- );
- return new Response(JSON.stringify({ code: "UnknownError" }), {
- status: 500,
- headers: {
- "Content-Type": "application/json",
- },
- });
- }
- }
-}
diff --git a/apps/web/app/api/v1/utils/pagination.ts b/apps/web/app/api/v1/utils/pagination.ts
deleted file mode 100644
index 12a0b950..00000000
--- a/apps/web/app/api/v1/utils/pagination.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { z } from "zod";
-
-import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@karakeep/shared/types/bookmarks";
-import { zCursorV2 } from "@karakeep/shared/types/pagination";
-
-export const zPagination = z.object({
- limit: z.coerce.number().max(MAX_NUM_BOOKMARKS_PER_PAGE).optional(),
- cursor: z
- .string()
- .refine((val) => val.includes("_"), "Must be a valid cursor")
- .transform((val) => {
- const [id, createdAt] = val.split("_");
- return { id, createdAt };
- })
- .pipe(z.object({ id: z.string(), createdAt: z.coerce.date() }))
- .optional(),
-});
-
-export function adaptPagination<
- T extends { nextCursor: z.infer<typeof zCursorV2> | null },
->(input: T) {
- const { nextCursor, ...rest } = input;
- if (!nextCursor) {
- return input;
- }
- return {
- ...rest,
- nextCursor: `${nextCursor.id}_${nextCursor.createdAt.toISOString()}`,
- };
-}
diff --git a/apps/web/app/api/v1/utils/types.ts b/apps/web/app/api/v1/utils/types.ts
deleted file mode 100644
index f0fe6231..00000000
--- a/apps/web/app/api/v1/utils/types.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { z } from "zod";
-
-export const zStringBool = z
- .string()
- .refine((val) => val === "true" || val === "false", "Must be true or false")
- .transform((val) => val === "true");
-
-export const zGetBookmarkSearchParamsSchema = z.object({
- // TODO: Change the default to false in a couple of releases.
- includeContent: zStringBool.optional().default("true"),
-});
diff --git a/apps/web/app/dashboard/layout.tsx b/apps/web/app/dashboard/layout.tsx
index 45b97653..c4a53e4b 100644
--- a/apps/web/app/dashboard/layout.tsx
+++ b/apps/web/app/dashboard/layout.tsx
@@ -4,6 +4,7 @@ import MobileSidebar from "@/components/shared/sidebar/MobileSidebar";
import Sidebar from "@/components/shared/sidebar/Sidebar";
import SidebarLayout from "@/components/shared/sidebar/SidebarLayout";
import { Separator } from "@/components/ui/separator";
+import { UserSettingsContextProvider } from "@/lib/userSettings";
import { api } from "@/server/api/client";
import { getServerAuthSession } from "@/server/auth";
import { TFunction } from "i18next";
@@ -30,7 +31,10 @@ export default async function Dashboard({
redirect("/");
}
- const lists = await api.lists.list();
+ const [lists, userSettings] = await Promise.all([
+ api.lists.list(),
+ api.users.settings(),
+ ]);
const items = (t: TFunction) =>
[
@@ -75,22 +79,24 @@ export default async function Dashboard({
];
return (
- <SidebarLayout
- sidebar={
- <Sidebar
- items={items}
- extraSections={
- <>
- <Separator />
- <AllLists initialData={lists} />
- </>
- }
- />
- }
- mobileSidebar={<MobileSidebar items={mobileSidebar} />}
- modal={modal}
- >
- {children}
- </SidebarLayout>
+ <UserSettingsContextProvider userSettings={userSettings}>
+ <SidebarLayout
+ sidebar={
+ <Sidebar
+ items={items}
+ extraSections={
+ <>
+ <Separator />
+ <AllLists initialData={lists} />
+ </>
+ }
+ />
+ }
+ mobileSidebar={<MobileSidebar items={mobileSidebar} />}
+ modal={modal}
+ >
+ {children}
+ </SidebarLayout>
+ </UserSettingsContextProvider>
);
}
diff --git a/apps/web/app/dashboard/lists/[listId]/page.tsx b/apps/web/app/dashboard/lists/[listId]/page.tsx
index 6c0bc36c..de0f5054 100644
--- a/apps/web/app/dashboard/lists/[listId]/page.tsx
+++ b/apps/web/app/dashboard/lists/[listId]/page.tsx
@@ -8,9 +8,14 @@ import { BookmarkListContextProvider } from "@karakeep/shared-react/hooks/bookma
export default async function ListPage({
params,
+ searchParams,
}: {
params: { listId: string };
+ searchParams?: {
+ includeArchived?: string;
+ };
}) {
+ const userSettings = await api.users.settings();
let list;
try {
list = await api.lists.get({ listId: params.listId });
@@ -23,10 +28,18 @@ export default async function ListPage({
throw e;
}
+ const includeArchived =
+ searchParams?.includeArchived !== undefined
+ ? searchParams.includeArchived === "true"
+ : userSettings.archiveDisplayBehaviour === "show";
+
return (
<BookmarkListContextProvider list={list}>
<Bookmarks
- query={{ listId: list.id }}
+ query={{
+ listId: list.id,
+ archived: !includeArchived ? false : undefined,
+ }}
showDivider={true}
showEditorCard={list.type === "manual"}
header={<ListHeader initialData={list} />}
diff --git a/apps/web/app/dashboard/search/page.tsx b/apps/web/app/dashboard/search/page.tsx
index beae73b8..c3542a88 100644
--- a/apps/web/app/dashboard/search/page.tsx
+++ b/apps/web/app/dashboard/search/page.tsx
@@ -1,14 +1,22 @@
"use client";
-import { Suspense } from "react";
+import { Suspense, useEffect } from "react";
import BookmarksGrid from "@/components/dashboard/bookmarks/BookmarksGrid";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { useBookmarkSearch } from "@/lib/hooks/bookmark-search";
+import { useSortOrderStore } from "@/lib/store/useSortOrderStore";
function SearchComp() {
const { data, hasNextPage, fetchNextPage, isFetchingNextPage } =
useBookmarkSearch();
+ const { setSortOrder } = useSortOrderStore();
+
+ useEffect(() => {
+ // also see related cleanup code in SortOrderToggle.tsx
+ setSortOrder("relevance");
+ }, []);
+
return (
<div className="flex flex-col gap-3">
{data ? (
diff --git a/apps/web/app/dashboard/tags/[tagId]/page.tsx b/apps/web/app/dashboard/tags/[tagId]/page.tsx
index f6e02a65..b33a351a 100644
--- a/apps/web/app/dashboard/tags/[tagId]/page.tsx
+++ b/apps/web/app/dashboard/tags/[tagId]/page.tsx
@@ -9,8 +9,12 @@ import { MoreHorizontal } from "lucide-react";
export default async function TagPage({
params,
+ searchParams,
}: {
params: { tagId: string };
+ searchParams?: {
+ includeArchived?: string;
+ };
}) {
let tag;
try {
@@ -23,6 +27,12 @@ export default async function TagPage({
}
throw e;
}
+ const userSettings = await api.users.settings();
+
+ const includeArchived =
+ searchParams?.includeArchived !== undefined
+ ? searchParams.includeArchived === "true"
+ : userSettings.archiveDisplayBehaviour === "show";
return (
<Bookmarks
@@ -40,7 +50,10 @@ export default async function TagPage({
</TagOptions>
</div>
}
- query={{ tagId: tag.id }}
+ query={{
+ tagId: tag.id,
+ archived: !includeArchived ? false : undefined,
+ }}
showEditorCard={true}
/>
);
diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx
index beeecc2b..d5af9e35 100644
--- a/apps/web/app/layout.tsx
+++ b/apps/web/app/layout.tsx
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
+import { NuqsAdapter } from "nuqs/adapters/next/app";
import "@karakeep/tailwind-config/globals.css";
@@ -55,15 +56,17 @@ export default async function RootLayout({
dir={isRTL ? "rtl" : "ltr"}
>
<body className={inter.className}>
- <Providers
- session={session}
- clientConfig={clientConfig}
- userLocalSettings={await getUserLocalSettings()}
- >
- {children}
- <ReactQueryDevtools initialIsOpen={false} />
- </Providers>
- <Toaster />
+ <NuqsAdapter>
+ <Providers
+ session={session}
+ clientConfig={clientConfig}
+ userLocalSettings={await getUserLocalSettings()}
+ >
+ {children}
+ <ReactQueryDevtools initialIsOpen={false} />
+ </Providers>
+ <Toaster />
+ </NuqsAdapter>
</body>
</html>
);
diff --git a/apps/web/app/public/layout.tsx b/apps/web/app/public/layout.tsx
new file mode 100644
index 00000000..4203c44c
--- /dev/null
+++ b/apps/web/app/public/layout.tsx
@@ -0,0 +1,16 @@
+import KarakeepLogo from "@/components/KarakeepIcon";
+
+export default function PublicLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+ <div className="h-screen flex-col overflow-y-auto bg-muted">
+ <header className="sticky left-0 right-0 top-0 z-50 flex h-16 items-center justify-between overflow-x-auto overflow-y-hidden bg-background p-4 shadow">
+ <KarakeepLogo height={38} />
+ </header>
+ <main className="container mx-3 mt-3 flex-1">{children}</main>
+ </div>
+ );
+}
diff --git a/apps/web/app/public/lists/[listId]/not-found.tsx b/apps/web/app/public/lists/[listId]/not-found.tsx
new file mode 100644
index 00000000..a6fd71dc
--- /dev/null
+++ b/apps/web/app/public/lists/[listId]/not-found.tsx
@@ -0,0 +1,18 @@
+import { X } from "lucide-react";
+
+export default function PublicListPageNotFound() {
+ return (
+ <div className="mx-auto flex max-w-md flex-1 flex-col items-center justify-center px-4 py-16 text-center">
+ <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-700">
+ <X className="h-12 w-12 text-gray-300" strokeWidth={1.5} />
+ </div>
+ <h1 className="mb-3 text-2xl font-semibold text-gray-800">
+ List not found
+ </h1>
+ <p className="text-center text-gray-500">
+ The list you&apos;re looking for doesn&apos;t exist or may have been
+ removed.
+ </p>
+ </div>
+ );
+}
diff --git a/apps/web/app/public/lists/[listId]/page.tsx b/apps/web/app/public/lists/[listId]/page.tsx
new file mode 100644
index 00000000..c0495b9f
--- /dev/null
+++ b/apps/web/app/public/lists/[listId]/page.tsx
@@ -0,0 +1,84 @@
+import type { Metadata } from "next";
+import { notFound } from "next/navigation";
+import NoBookmarksBanner from "@/components/dashboard/bookmarks/NoBookmarksBanner";
+import PublicBookmarkGrid from "@/components/public/lists/PublicBookmarkGrid";
+import PublicListHeader from "@/components/public/lists/PublicListHeader";
+import { Separator } from "@/components/ui/separator";
+import { api } from "@/server/api/client";
+import { TRPCError } from "@trpc/server";
+
+export async function generateMetadata({
+ params,
+}: {
+ params: { listId: string };
+}): Promise<Metadata> {
+ // TODO: Don't load the entire list, just create an endpoint to get the list name
+ try {
+ const resp = await api.publicBookmarks.getPublicBookmarksInList({
+ listId: params.listId,
+ });
+ return {
+ title: `${resp.list.name} - Karakeep`,
+ };
+ } catch (e) {
+ if (e instanceof TRPCError && e.code === "NOT_FOUND") {
+ notFound();
+ }
+ }
+ return {
+ title: "Karakeep",
+ };
+}
+
+export default async function PublicListPage({
+ params,
+}: {
+ params: { listId: string };
+}) {
+ try {
+ const { list, bookmarks, nextCursor } =
+ await api.publicBookmarks.getPublicBookmarksInList({
+ listId: params.listId,
+ });
+ return (
+ <div className="flex flex-col gap-3">
+ <div className="flex items-center gap-2">
+ <span className="text-2xl">
+ {list.icon} {list.name}
+ {list.description && (
+ <span className="mx-2 text-lg text-gray-400">
+ {`(${list.description})`}
+ </span>
+ )}
+ </span>
+ </div>
+ <Separator />
+ <PublicListHeader
+ list={{
+ id: params.listId,
+ numItems: list.numItems,
+ }}
+ />
+ {list.numItems > 0 ? (
+ <PublicBookmarkGrid
+ list={{
+ id: params.listId,
+ name: list.name,
+ description: list.description,
+ icon: list.icon,
+ numItems: list.numItems,
+ }}
+ bookmarks={bookmarks}
+ nextCursor={nextCursor}
+ />
+ ) : (
+ <NoBookmarksBanner />
+ )}
+ </div>
+ );
+ } catch (e) {
+ if (e instanceof TRPCError && e.code === "NOT_FOUND") {
+ notFound();
+ }
+ }
+}
diff --git a/apps/web/app/settings/assets/page.tsx b/apps/web/app/settings/assets/page.tsx
index 0b3c2b5b..0e7a3daa 100644
--- a/apps/web/app/settings/assets/page.tsx
+++ b/apps/web/app/settings/assets/page.tsx
@@ -21,7 +21,7 @@ import { formatBytes } from "@/lib/utils";
import { ExternalLink, Trash2 } from "lucide-react";
import { useDetachBookmarkAsset } from "@karakeep/shared-react/hooks/assets";
-import { getAssetUrl } from "@karakeep/shared-react/utils/assetUtils";
+import { getAssetUrl } from "@karakeep/shared/utils/assetUtils";
import {
humanFriendlyNameForAssertType,
isAllowedToDetachAsset,
diff --git a/apps/web/app/settings/layout.tsx b/apps/web/app/settings/layout.tsx
index 9bac783c..1f7c5c12 100644
--- a/apps/web/app/settings/layout.tsx
+++ b/apps/web/app/settings/layout.tsx
@@ -1,6 +1,8 @@
import MobileSidebar from "@/components/shared/sidebar/MobileSidebar";
import Sidebar from "@/components/shared/sidebar/Sidebar";
import SidebarLayout from "@/components/shared/sidebar/SidebarLayout";
+import { UserSettingsContextProvider } from "@/lib/userSettings";
+import { api } from "@/server/api/client";
import { TFunction } from "i18next";
import {
ArrowLeft,
@@ -79,12 +81,15 @@ export default async function SettingsLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
+ const userSettings = await api.users.settings();
return (
- <SidebarLayout
- sidebar={<Sidebar items={settingsSidebarItems} />}
- mobileSidebar={<MobileSidebar items={settingsSidebarItems} />}
- >
- {children}
- </SidebarLayout>
+ <UserSettingsContextProvider userSettings={userSettings}>
+ <SidebarLayout
+ sidebar={<Sidebar items={settingsSidebarItems} />}
+ mobileSidebar={<MobileSidebar items={settingsSidebarItems} />}
+ >
+ {children}
+ </SidebarLayout>
+ </UserSettingsContextProvider>
);
}
diff --git a/apps/web/components/admin/BackgroundJobs.tsx b/apps/web/components/admin/BackgroundJobs.tsx
index 217e2ad9..ac5885ef 100644
--- a/apps/web/components/admin/BackgroundJobs.tsx
+++ b/apps/web/components/admin/BackgroundJobs.tsx
@@ -127,7 +127,7 @@ function AdminActions() {
variant="destructive"
loading={isInferencePending}
onClick={() =>
- reRunInferenceOnAllBookmarks({ taggingStatus: "failure" })
+ reRunInferenceOnAllBookmarks({ type: "tag", status: "failure" })
}
>
{t("admin.actions.regenerate_ai_tags_for_failed_bookmarks_only")}
@@ -135,12 +135,32 @@ function AdminActions() {
<ActionButton
variant="destructive"
loading={isInferencePending}
- onClick={() => reRunInferenceOnAllBookmarks({ taggingStatus: "all" })}
+ onClick={() =>
+ reRunInferenceOnAllBookmarks({ type: "tag", status: "all" })
+ }
>
{t("admin.actions.regenerate_ai_tags_for_all_bookmarks")}
</ActionButton>
<ActionButton
variant="destructive"
+ loading={isInferencePending}
+ onClick={() =>
+ reRunInferenceOnAllBookmarks({ type: "summarize", status: "failure" })
+ }
+ >
+ {t("admin.actions.regenerate_ai_summaries_for_failed_bookmarks_only")}
+ </ActionButton>
+ <ActionButton
+ variant="destructive"
+ loading={isInferencePending}
+ onClick={() =>
+ reRunInferenceOnAllBookmarks({ type: "summarize", status: "all" })
+ }
+ >
+ {t("admin.actions.regenerate_ai_summaries_for_all_bookmarks")}
+ </ActionButton>
+ <ActionButton
+ variant="destructive"
loading={isReindexPending}
onClick={() => reindexBookmarks()}
>
diff --git a/apps/web/components/dashboard/SortOrderToggle.tsx b/apps/web/components/dashboard/SortOrderToggle.tsx
index 8c0f617d..ba3385ac 100644
--- a/apps/web/components/dashboard/SortOrderToggle.tsx
+++ b/apps/web/components/dashboard/SortOrderToggle.tsx
@@ -1,3 +1,6 @@
+"use client";
+
+import { useEffect } from "react";
import { ButtonWithTooltip } from "@/components/ui/button";
import {
DropdownMenu,
@@ -5,15 +8,26 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { useIsSearchPage } from "@/lib/hooks/bookmark-search";
import { useTranslation } from "@/lib/i18n/client";
import { useSortOrderStore } from "@/lib/store/useSortOrderStore";
-import { Check, SortAsc, SortDesc } from "lucide-react";
+import { Check, ListFilter, SortAsc, SortDesc } from "lucide-react";
export default function SortOrderToggle() {
const { t } = useTranslation();
+ const isInSearchPage = useIsSearchPage();
const { sortOrder: currentSort, setSortOrder } = useSortOrderStore();
+ // also see related on page enter sortOrder.relevance init
+ // in apps/web/app/dashboard/search/page.tsx
+ useEffect(() => {
+ if (!isInSearchPage && currentSort === "relevance") {
+ // reset to default sort order
+ setSortOrder("desc");
+ }
+ }, [isInSearchPage, currentSort]);
+
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -22,14 +36,24 @@ export default function SortOrderToggle() {
delayDuration={100}
variant="ghost"
>
- {currentSort === "asc" ? (
- <SortAsc size={18} />
- ) : (
- <SortDesc size={18} />
- )}
+ {currentSort === "relevance" && <ListFilter size={18} />}
+ {currentSort === "asc" && <SortAsc size={18} />}
+ {currentSort === "desc" && <SortDesc size={18} />}
</ButtonWithTooltip>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-fit">
+ {isInSearchPage && (
+ <DropdownMenuItem
+ className="cursor-pointer justify-between"
+ onClick={() => setSortOrder("relevance")}
+ >
+ <div className="flex items-center">
+ <ListFilter size={16} className="mr-2" />
+ <span>{t("actions.sort.relevant_first")}</span>
+ </div>
+ {currentSort === "relevance" && <Check className="ml-2 h-4 w-4" />}
+ </DropdownMenuItem>
+ )}
<DropdownMenuItem
className="cursor-pointer justify-between"
onClick={() => setSortOrder("desc")}
diff --git a/apps/web/components/dashboard/bookmarks/AssetCard.tsx b/apps/web/components/dashboard/bookmarks/AssetCard.tsx
index 6fc8a723..c906f2a7 100644
--- a/apps/web/components/dashboard/bookmarks/AssetCard.tsx
+++ b/apps/web/components/dashboard/bookmarks/AssetCard.tsx
@@ -6,8 +6,8 @@ import { cn } from "@/lib/utils";
import { FileText } from "lucide-react";
import type { ZBookmarkTypeAsset } from "@karakeep/shared/types/bookmarks";
-import { getAssetUrl } from "@karakeep/shared-react/utils/assetUtils";
-import { getSourceUrl } from "@karakeep/shared-react/utils/bookmarkUtils";
+import { getAssetUrl } from "@karakeep/shared/utils/assetUtils";
+import { getSourceUrl } from "@karakeep/shared/utils/bookmarkUtils";
import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard";
import FooterLinkURL from "./FooterLinkURL";
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx
index 3c92e03e..4fc7d94a 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkCard.tsx
@@ -1,7 +1,7 @@
import { api } from "@/lib/trpc";
-import { isBookmarkStillLoading } from "@karakeep/shared-react/utils/bookmarkUtils";
import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
+import { isBookmarkStillLoading } from "@karakeep/shared/utils/bookmarkUtils";
import AssetCard from "./AssetCard";
import LinkCard from "./LinkCard";
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx b/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx
new file mode 100644
index 00000000..a3e5d3b3
--- /dev/null
+++ b/apps/web/components/dashboard/bookmarks/BookmarkFormattedCreatedAt.tsx
@@ -0,0 +1,8 @@
+import dayjs from "dayjs";
+
+export default function BookmarkFormattedCreatedAt(prop: { createdAt: Date }) {
+ const createdAt = dayjs(prop.createdAt);
+ const oneYearAgo = dayjs().subtract(1, "year");
+ const formatString = createdAt.isAfter(oneYearAgo) ? "MMM D" : "MMM D, YYYY";
+ return createdAt.format(formatString);
+}
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
index a0437c71..4b511a3c 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkLayoutAdaptingCard.tsx
@@ -8,15 +8,15 @@ import {
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 "@karakeep/shared/types/bookmarks";
-import { isBookmarkStillTagging } from "@karakeep/shared-react/utils/bookmarkUtils";
import { BookmarkTypes } from "@karakeep/shared/types/bookmarks";
+import { isBookmarkStillTagging } from "@karakeep/shared/utils/bookmarkUtils";
import BookmarkActionBar from "./BookmarkActionBar";
+import BookmarkFormattedCreatedAt from "./BookmarkFormattedCreatedAt";
import TagList from "./TagList";
interface Props {
@@ -30,13 +30,6 @@ interface Props {
wrapTags: boolean;
}
-function BookmarkFormattedCreatedAt({ bookmark }: { bookmark: ZBookmark }) {
- const createdAt = dayjs(bookmark.createdAt);
- const oneYearAgo = dayjs().subtract(1, "year");
- const formatString = createdAt.isAfter(oneYearAgo) ? "MMM D" : "MMM D, YYYY";
- return createdAt.format(formatString);
-}
-
function BottomRow({
footer,
bookmark,
@@ -52,7 +45,7 @@ function BottomRow({
href={`/dashboard/preview/${bookmark.id}`}
suppressHydrationWarning
>
- <BookmarkFormattedCreatedAt bookmark={bookmark} />
+ <BookmarkFormattedCreatedAt createdAt={bookmark.createdAt} />
</Link>
</div>
<BookmarkActionBar bookmark={bookmark} />
@@ -239,7 +232,7 @@ function CompactView({ bookmark, title, footer, className }: Props) {
suppressHydrationWarning
className="shrink-0 gap-2 text-gray-500"
>
- <BookmarkFormattedCreatedAt bookmark={bookmark} />
+ <BookmarkFormattedCreatedAt createdAt={bookmark.createdAt} />
</Link>
</div>
<BookmarkActionBar bookmark={bookmark} />
diff --git a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx
index debd5ad9..82e483a9 100644
--- a/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx
+++ b/apps/web/components/dashboard/bookmarks/BookmarkMarkdownComponent.tsx
@@ -2,14 +2,18 @@ import MarkdownEditor from "@/components/ui/markdown/markdown-editor";
import { MarkdownReadonly } from "@/components/ui/markdown/markdown-readonly";
import { toast } from "@/components/ui/use-toast";
-import type { ZBookmarkTypeText } from "@karakeep/shared/types/bookmarks";
import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks";
export function BookmarkMarkdownComponent({
children: bookmark,
readOnly = true,
}: {
- children: ZBookmarkTypeText;
+ children: {
+ id: string;
+ content: {
+ text: string;
+ };
+ };
readOnly?: boolean;
}) {
const { mutate: updateBookmarkMutator, isPending } = useUpdateBookmark({
diff --git a/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx b/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx
index ab5d0364..f0ede24e 100644
--- a/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx
+++ b/apps/web/components/dashboard/bookmarks/EditBookmarkDialog.tsx
@@ -35,13 +35,13 @@ import { CalendarIcon } from "lucide-react";
import { useForm } from "react-hook-form";
import { useUpdateBookmark } from "@karakeep/shared-react/hooks/bookmarks";
-import { getBookmarkTitle } from "@karakeep/shared-react/utils/bookmarkUtils";
import {
BookmarkTypes,
ZBookmark,
ZUpdateBookmarksRequest,
zUpdateBookmarksRequestSchema,
} from "@karakeep/shared/types/bookmarks";
+import { getBookmarkTitle } from "@karakeep/shared/utils/bookmarkUtils";
import { BookmarkTagsEditor } from "./BookmarkTagsEditor";
diff --git a/apps/web/components/dashboard/bookmarks/LinkCard.tsx b/apps/web/components/dashboard/bookmarks/LinkCard.tsx
index ec224ca6..778166b5 100644
--- a/apps/web/components/dashboard/bookmarks/LinkCard.tsx
+++ b/apps/web/components/dashboard/bookmarks/LinkCard.tsx
@@ -2,6 +2,7 @@
import Image from "next/image";
import Link from "next/link";
+import { useUserSettings } from "@/lib/userSettings";
import type { ZBookmarkTypeLink } from "@karakeep/shared/types/bookmarks";
import {
@@ -9,16 +10,30 @@ import {
getBookmarkTitle,
getSourceUrl,
isBookmarkStillCrawling,
-} from "@karakeep/shared-react/utils/bookmarkUtils";
+} from "@karakeep/shared/utils/bookmarkUtils";
import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard";
import FooterLinkURL from "./FooterLinkURL";
+const useOnClickUrl = (bookmark: ZBookmarkTypeLink) => {
+ const userSettings = useUserSettings();
+ return {
+ urlTarget:
+ userSettings.bookmarkClickAction === "open_original_link"
+ ? ("_blank" as const)
+ : ("_self" as const),
+ onClickUrl:
+ userSettings.bookmarkClickAction === "expand_bookmark_preview"
+ ? `/dashboard/preview/${bookmark.id}`
+ : bookmark.content.url,
+ };
+};
+
function LinkTitle({ bookmark }: { bookmark: ZBookmarkTypeLink }) {
- const link = bookmark.content;
- const parsedUrl = new URL(link.url);
+ const { onClickUrl, urlTarget } = useOnClickUrl(bookmark);
+ const parsedUrl = new URL(bookmark.content.url);
return (
- <Link href={link.url} target="_blank" rel="noreferrer">
+ <Link href={onClickUrl} target={urlTarget} rel="noreferrer">
{getBookmarkTitle(bookmark) ?? parsedUrl.host}
</Link>
);
@@ -31,6 +46,7 @@ function LinkImage({
bookmark: ZBookmarkTypeLink;
className?: string;
}) {
+ const { onClickUrl, urlTarget } = useOnClickUrl(bookmark);
const link = bookmark.content;
const imgComponent = (url: string, unoptimized: boolean) => (
@@ -61,8 +77,8 @@ function LinkImage({
return (
<Link
- href={link.url}
- target="_blank"
+ href={onClickUrl}
+ target={urlTarget}
rel="noreferrer"
className={className}
>
diff --git a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx
index 717c98a1..b5e89a01 100644
--- a/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx
+++ b/apps/web/components/dashboard/bookmarks/SummarizeBookmarkArea.tsx
@@ -2,6 +2,7 @@ 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 { useClientConfig } from "@/lib/clientConfig";
import { useTranslation } from "@/lib/i18n/client";
import { cn } from "@/lib/utils";
import { ChevronUp, RefreshCw, Sparkles, Trash2 } from "lucide-react";
@@ -110,12 +111,15 @@ export default function SummarizeBookmarkArea({
},
});
+ const clientConfig = useClientConfig();
if (bookmark.content.type !== BookmarkTypes.LINK) {
return null;
}
if (bookmark.summary) {
return <AISummary bookmarkId={bookmark.id} summary={bookmark.summary} />;
+ } else if (!clientConfig.inference.isConfigured) {
+ return null;
} else {
return (
<div className="flex w-full items-center gap-4">
diff --git a/apps/web/components/dashboard/bookmarks/TextCard.tsx b/apps/web/components/dashboard/bookmarks/TextCard.tsx
index 0233357c..3be3a093 100644
--- a/apps/web/components/dashboard/bookmarks/TextCard.tsx
+++ b/apps/web/components/dashboard/bookmarks/TextCard.tsx
@@ -7,8 +7,8 @@ import { bookmarkLayoutSwitch } from "@/lib/userLocalSettings/bookmarksLayout";
import { cn } from "@/lib/utils";
import type { ZBookmarkTypeText } from "@karakeep/shared/types/bookmarks";
-import { getAssetUrl } from "@karakeep/shared-react/utils/assetUtils";
-import { getSourceUrl } from "@karakeep/shared-react/utils/bookmarkUtils";
+import { getAssetUrl } from "@karakeep/shared/utils/assetUtils";
+import { getSourceUrl } from "@karakeep/shared/utils/bookmarkUtils";
import { BookmarkLayoutAdaptingCard } from "./BookmarkLayoutAdaptingCard";
import FooterLinkURL from "./FooterLinkURL";
diff --git a/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx b/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx
index da65b9d9..968d0326 100644
--- a/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx
+++ b/apps/web/components/dashboard/bookmarks/UpdatableBookmarksGrid.tsx
@@ -23,7 +23,11 @@ export default function UpdatableBookmarksGrid({
showEditorCard?: boolean;
itemsPerPage?: number;
}) {
- const sortOrder = useSortOrderStore((state) => state.sortOrder);
+ let sortOrder = useSortOrderStore((state) => state.sortOrder);
+ if (sortOrder === "relevance") {
+ // Relevance is not supported in the `getBookmarks` endpoint.
+ sortOrder = "desc";
+ }
const finalQuery = { ...query, sortOrder, includeContent: false };
diff --git a/apps/web/components/dashboard/lists/EditListModal.tsx b/apps/web/components/dashboard/lists/EditListModal.tsx
index 68d32b0a..7a750c33 100644
--- a/apps/web/components/dashboard/lists/EditListModal.tsx
+++ b/apps/web/components/dashboard/lists/EditListModal.tsx
@@ -358,14 +358,16 @@ export function EditListModal({
value={field.value}
onChange={field.onChange}
placeholder={t("lists.search_query")}
+ endIcon={
+ parsedSearchQuery ? (
+ <QueryExplainerTooltip
+ className="stroke-foreground p-1"
+ parsedSearchQuery={parsedSearchQuery}
+ />
+ ) : undefined
+ }
/>
</FormControl>
- {parsedSearchQuery && (
- <QueryExplainerTooltip
- className="translate-1/2 absolute right-1.5 top-2 stroke-foreground p-0.5"
- parsedSearchQuery={parsedSearchQuery}
- />
- )}
</div>
<FormDescription>
<Link
diff --git a/apps/web/components/dashboard/lists/ListOptions.tsx b/apps/web/components/dashboard/lists/ListOptions.tsx
index 9a979686..7e020374 100644
--- a/apps/web/components/dashboard/lists/ListOptions.tsx
+++ b/apps/web/components/dashboard/lists/ListOptions.tsx
@@ -5,14 +5,24 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { useShowArchived } from "@/components/utils/useShowArchived";
import { useTranslation } from "@/lib/i18n/client";
-import { FolderInput, Pencil, Plus, Trash2 } from "lucide-react";
+import {
+ FolderInput,
+ Pencil,
+ Plus,
+ Share,
+ Square,
+ SquareCheck,
+ Trash2,
+} from "lucide-react";
import { ZBookmarkList } from "@karakeep/shared/types/lists";
import { EditListModal } from "../lists/EditListModal";
import DeleteListConfirmationDialog from "./DeleteListConfirmationDialog";
import { MergeListModal } from "./MergeListModal";
+import { ShareListModal } from "./ShareListModal";
export function ListOptions({
list,
@@ -26,14 +36,21 @@ export function ListOptions({
children?: React.ReactNode;
}) {
const { t } = useTranslation();
+ const { showArchived, onClickShowArchived } = useShowArchived();
const [deleteListDialogOpen, setDeleteListDialogOpen] = useState(false);
const [newNestedListModalOpen, setNewNestedListModalOpen] = useState(false);
const [mergeListModalOpen, setMergeListModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
+ const [shareModalOpen, setShareModalOpen] = useState(false);
return (
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
+ <ShareListModal
+ open={shareModalOpen}
+ setOpen={setShareModalOpen}
+ list={list}
+ />
<EditListModal
open={newNestedListModalOpen}
setOpen={setNewNestedListModalOpen}
@@ -67,6 +84,13 @@ export function ListOptions({
</DropdownMenuItem>
<DropdownMenuItem
className="flex gap-2"
+ onClick={() => setShareModalOpen(true)}
+ >
+ <Share className="size-4" />
+ <span>{t("lists.share_list")}</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ className="flex gap-2"
onClick={() => setNewNestedListModalOpen(true)}
>
<Plus className="size-4" />
@@ -79,6 +103,14 @@ export function ListOptions({
<FolderInput className="size-4" />
<span>{t("lists.merge_list")}</span>
</DropdownMenuItem>
+ <DropdownMenuItem className="flex gap-2" onClick={onClickShowArchived}>
+ {showArchived ? (
+ <SquareCheck className="size-4" />
+ ) : (
+ <Square className="size-4" />
+ )}
+ <span>{t("actions.toggle_show_archived")}</span>
+ </DropdownMenuItem>
<DropdownMenuItem
className="flex gap-2"
onClick={() => setDeleteListDialogOpen(true)}
diff --git a/apps/web/components/dashboard/lists/PublicListLink.tsx b/apps/web/components/dashboard/lists/PublicListLink.tsx
new file mode 100644
index 00000000..9cd1f795
--- /dev/null
+++ b/apps/web/components/dashboard/lists/PublicListLink.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { CopyBtnV2 } from "@/components/ui/copy-button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { useClientConfig } from "@/lib/clientConfig";
+import { useTranslation } from "react-i18next";
+
+import { useEditBookmarkList } from "@karakeep/shared-react/hooks/lists";
+import { ZBookmarkList } from "@karakeep/shared/types/lists";
+
+export default function PublicListLink({ list }: { list: ZBookmarkList }) {
+ const { t } = useTranslation();
+ const clientConfig = useClientConfig();
+
+ const { mutate: editList, isPending: isLoading } = useEditBookmarkList();
+
+ const publicListUrl = `${clientConfig.publicUrl}/public/lists/${list.id}`;
+ const isPublic = list.public;
+
+ return (
+ <>
+ {/* Public List Toggle */}
+ <div className="flex items-center justify-between">
+ <div className="space-y-1">
+ <Label htmlFor="public-toggle" className="text-sm font-medium">
+ {t("lists.public_list.title")}
+ </Label>
+ <p className="text-xs text-muted-foreground">
+ {t("lists.public_list.description")}
+ </p>
+ </div>
+ <Switch
+ id="public-toggle"
+ checked={isPublic}
+ disabled={isLoading || !!clientConfig.demoMode}
+ onCheckedChange={(checked) => {
+ editList({
+ listId: list.id,
+ public: checked,
+ });
+ }}
+ />
+ </div>
+
+ {/* Share URL - only show when public */}
+ {isPublic && (
+ <>
+ <div className="space-y-3">
+ <Label className="text-sm font-medium">
+ {t("lists.public_list.share_link")}
+ </Label>
+ <div className="flex items-center space-x-2">
+ <Input
+ value={publicListUrl}
+ readOnly
+ className="flex-1 text-sm"
+ />
+ <CopyBtnV2 getStringToCopy={() => publicListUrl} />
+ </div>
+ </div>
+ </>
+ )}
+ </>
+ );
+}
diff --git a/apps/web/components/dashboard/lists/RssLink.tsx b/apps/web/components/dashboard/lists/RssLink.tsx
new file mode 100644
index 00000000..1be48681
--- /dev/null
+++ b/apps/web/components/dashboard/lists/RssLink.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { useMemo } from "react";
+import { Button } from "@/components/ui/button";
+import { CopyBtnV2 } from "@/components/ui/copy-button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { useClientConfig } from "@/lib/clientConfig";
+import { api } from "@/lib/trpc";
+import { Loader2, RotateCcw } from "lucide-react";
+import { useTranslation } from "react-i18next";
+
+export default function RssLink({ listId }: { listId: string }) {
+ const { t } = useTranslation();
+ const clientConfig = useClientConfig();
+ const apiUtils = api.useUtils();
+
+ const { mutate: regenRssToken, isPending: isRegenPending } =
+ api.lists.regenRssToken.useMutation({
+ onSuccess: () => {
+ apiUtils.lists.getRssToken.invalidate({ listId });
+ },
+ });
+ const { mutate: clearRssToken, isPending: isClearPending } =
+ api.lists.clearRssToken.useMutation({
+ onSuccess: () => {
+ apiUtils.lists.getRssToken.invalidate({ listId });
+ },
+ });
+ const { data: rssToken, isLoading: isTokenLoading } =
+ api.lists.getRssToken.useQuery({ listId });
+
+ const rssUrl = useMemo(() => {
+ if (!rssToken || !rssToken.token) {
+ return null;
+ }
+ return `${clientConfig.publicApiUrl}/v1/rss/lists/${listId}?token=${rssToken.token}`;
+ }, [rssToken]);
+
+ const rssEnabled = rssUrl !== null;
+
+ return (
+ <>
+ {/* RSS Feed Toggle */}
+ <div className="flex items-center justify-between">
+ <div className="space-y-1">
+ <Label htmlFor="rss-toggle" className="text-sm font-medium">
+ {t("lists.rss.title")}
+ </Label>
+ <p className="text-xs text-muted-foreground">
+ {t("lists.rss.description")}
+ </p>
+ </div>
+ <Switch
+ id="rss-toggle"
+ checked={rssEnabled}
+ onCheckedChange={(checked) =>
+ checked ? regenRssToken({ listId }) : clearRssToken({ listId })
+ }
+ disabled={
+ isTokenLoading ||
+ isClearPending ||
+ isRegenPending ||
+ !!clientConfig.demoMode
+ }
+ />
+ </div>
+ {/* RSS URL - only show when RSS is enabled */}
+ {rssEnabled && (
+ <div className="space-y-3">
+ <Label className="text-sm font-medium">
+ {t("lists.rss.feed_url")}
+ </Label>
+ <div className="flex items-center space-x-2">
+ <Input value={rssUrl} readOnly className="flex-1 text-sm" />
+ <CopyBtnV2 getStringToCopy={() => rssUrl} />
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => regenRssToken({ listId })}
+ disabled={isRegenPending}
+ >
+ {isRegenPending ? (
+ <Loader2 className="h-4 w-4 animate-spin" />
+ ) : (
+ <RotateCcw className="h-4 w-4" />
+ )}
+ </Button>
+ </div>
+ </div>
+ )}
+ </>
+ );
+}
diff --git a/apps/web/components/dashboard/lists/ShareListModal.tsx b/apps/web/components/dashboard/lists/ShareListModal.tsx
new file mode 100644
index 00000000..16668e67
--- /dev/null
+++ b/apps/web/components/dashboard/lists/ShareListModal.tsx
@@ -0,0 +1,70 @@
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog";
+import { useTranslation } from "@/lib/i18n/client";
+import { DialogDescription } from "@radix-ui/react-dialog";
+
+import { ZBookmarkList } from "@karakeep/shared/types/lists";
+
+import PublicListLink from "./PublicListLink";
+import RssLink from "./RssLink";
+
+export function ShareListModal({
+ open: userOpen,
+ setOpen: userSetOpen,
+ list,
+ children,
+}: {
+ open?: boolean;
+ setOpen?: (v: boolean) => void;
+ list: ZBookmarkList;
+ children?: React.ReactNode;
+}) {
+ const { t } = useTranslation();
+ if (
+ (userOpen !== undefined && !userSetOpen) ||
+ (userOpen === undefined && userSetOpen)
+ ) {
+ throw new Error("You must provide both open and setOpen or neither");
+ }
+ const [customOpen, customSetOpen] = useState(false);
+ const [open, setOpen] = [
+ userOpen ?? customOpen,
+ userSetOpen ?? customSetOpen,
+ ];
+
+ return (
+ <Dialog
+ open={open}
+ onOpenChange={(s) => {
+ setOpen(s);
+ }}
+ >
+ {children && <DialogTrigger asChild>{children}</DialogTrigger>}
+ <DialogContent className="max-w-xl">
+ <DialogHeader>
+ <DialogTitle>{t("lists.share_list")}</DialogTitle>
+ </DialogHeader>
+ <DialogDescription className="mt-4 space-y-6">
+ <PublicListLink list={list} />
+ <RssLink listId={list.id} />
+ </DialogDescription>
+ <DialogFooter className="sm:justify-end">
+ <DialogClose asChild>
+ <Button type="button" variant="secondary">
+ {t("actions.close")}
+ </Button>
+ </DialogClose>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
diff --git a/apps/web/components/dashboard/preview/AssetContentSection.tsx b/apps/web/components/dashboard/preview/AssetContentSection.tsx
index fd299320..5cab86bd 100644
--- a/apps/web/components/dashboard/preview/AssetContentSection.tsx
+++ b/apps/web/components/dashboard/preview/AssetContentSection.tsx
@@ -11,8 +11,8 @@ import {
} from "@/components/ui/select";
import { useTranslation } from "@/lib/i18n/client";
-import { getAssetUrl } from "@karakeep/shared-react/utils/assetUtils";
import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
+import { getAssetUrl } from "@karakeep/shared/utils/assetUtils";
// 20 MB
const BIG_FILE_SIZE = 20 * 1024 * 1024;
diff --git a/apps/web/components/dashboard/preview/AttachmentBox.tsx b/apps/web/components/dashboard/preview/AttachmentBox.tsx
index 15acd799..674f151c 100644
--- a/apps/web/components/dashboard/preview/AttachmentBox.tsx
+++ b/apps/web/components/dashboard/preview/AttachmentBox.tsx
@@ -19,8 +19,8 @@ import {
useDetachBookmarkAsset,
useReplaceBookmarkAsset,
} from "@karakeep/shared-react/hooks/assets";
-import { getAssetUrl } from "@karakeep/shared-react/utils/assetUtils";
import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
+import { getAssetUrl } from "@karakeep/shared/utils/assetUtils";
import {
humanFriendlyNameForAssertType,
isAllowedToAttachAsset,
diff --git a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx
index a3b34f9a..dc446112 100644
--- a/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx
+++ b/apps/web/components/dashboard/preview/BookmarkHtmlHighlighter.tsx
@@ -19,6 +19,7 @@ interface ColorPickerMenuProps {
onDelete?: () => void;
selectedHighlight: Highlight | null;
onClose: () => void;
+ isMobile: boolean;
}
const ColorPickerMenu: React.FC<ColorPickerMenuProps> = ({
@@ -27,6 +28,7 @@ const ColorPickerMenu: React.FC<ColorPickerMenuProps> = ({
onDelete,
selectedHighlight,
onClose,
+ isMobile,
}) => {
return (
<Popover
@@ -44,7 +46,10 @@ const ColorPickerMenu: React.FC<ColorPickerMenuProps> = ({
top: position?.y,
}}
/>
- <PopoverContent side="top" className="flex w-fit items-center gap-1 p-2">
+ <PopoverContent
+ side={isMobile ? "bottom" : "top"}
+ className="flex w-fit items-center gap-1 p-2"
+ >
{SUPPORTED_HIGHLIGHT_COLORS.map((color) => (
<Button
size="none"
@@ -113,6 +118,11 @@ function BookmarkHTMLHighlighter({
const [selectedHighlight, setSelectedHighlight] = useState<Highlight | null>(
null,
);
+ const isMobile = useState(
+ () =>
+ typeof window !== "undefined" &&
+ window.matchMedia("(pointer: coarse)").matches,
+ )[0];
// Apply existing highlights when component mounts or highlights change
useEffect(() => {
@@ -160,7 +170,7 @@ function BookmarkHTMLHighlighter({
window.getSelection()?.addRange(newRange);
}, [pendingHighlight, contentRef]);
- const handleMouseUp = (e: React.MouseEvent) => {
+ const handlePointerUp = (e: React.PointerEvent) => {
const selection = window.getSelection();
// Check if we clicked on an existing highlight
@@ -192,11 +202,11 @@ function BookmarkHTMLHighlighter({
return;
}
- // Position the menu above the selection
+ // Position the menu based on device type
const rect = range.getBoundingClientRect();
setMenuPosition({
- x: rect.left + rect.width / 2, // Center the menu
- y: rect.top,
+ x: rect.left + rect.width / 2, // Center the menu horizontally
+ y: isMobile ? rect.bottom : rect.top, // Position below on mobile, above otherwise
});
// Store the highlight for later use
@@ -333,7 +343,7 @@ function BookmarkHTMLHighlighter({
role="presentation"
ref={contentRef}
dangerouslySetInnerHTML={{ __html: htmlContent }}
- onMouseUp={handleMouseUp}
+ onPointerUp={handlePointerUp}
className={className}
/>
<ColorPickerMenu
@@ -342,6 +352,7 @@ function BookmarkHTMLHighlighter({
onDelete={handleDelete}
selectedHighlight={selectedHighlight}
onClose={closeColorPicker}
+ isMobile={isMobile}
/>
</div>
);
diff --git a/apps/web/components/dashboard/preview/BookmarkPreview.tsx b/apps/web/components/dashboard/preview/BookmarkPreview.tsx
index df09f687..e213b9cb 100644
--- a/apps/web/components/dashboard/preview/BookmarkPreview.tsx
+++ b/apps/web/components/dashboard/preview/BookmarkPreview.tsx
@@ -1,11 +1,12 @@
"use client";
-import React from "react";
+import { 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
@@ -17,13 +18,13 @@ import { useTranslation } from "@/lib/i18n/client";
import { api } from "@/lib/trpc";
import { CalendarDays, ExternalLink } from "lucide-react";
+import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
import {
getBookmarkTitle,
getSourceUrl,
isBookmarkStillCrawling,
isBookmarkStillLoading,
-} from "@karakeep/shared-react/utils/bookmarkUtils";
-import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
+} from "@karakeep/shared/utils/bookmarkUtils";
import SummarizeBookmarkArea from "../bookmarks/SummarizeBookmarkArea";
import ActionBar from "./ActionBar";
@@ -68,6 +69,8 @@ export default function BookmarkPreview({
initialData?: ZBookmark;
}) {
const { t } = useTranslation();
+ const [activeTab, setActiveTab] = useState<string>("content");
+
const { data: bookmark } = api.bookmarks.getBookmark.useQuery(
{
bookmarkId,
@@ -111,45 +114,86 @@ export default function BookmarkPreview({
const sourceUrl = getSourceUrl(bookmark);
const title = getBookmarkTitle(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="row-span-1 flex flex-col gap-4 overflow-auto bg-accent p-4 md:col-span-2 lg:col-span-1 lg:row-auto">
- <div className="flex w-full flex-col items-center justify-center gap-y-2">
- <div className="flex w-full items-center justify-center gap-2">
- <p className="line-clamp-2 text-ellipsis break-words text-lg">
- {title === undefined || title === "" ? "Untitled" : title}
- </p>
- </div>
- {sourceUrl && (
- <Link
- href={sourceUrl}
- target="_blank"
- className="flex items-center gap-2 text-gray-400"
- >
- <span>{t("preview.view_original")}</span>
- <ExternalLink />
- </Link>
- )}
- <Separator />
+ // Common content for both layouts
+ const contentSection = isBookmarkStillCrawling(bookmark) ? (
+ <ContentLoading />
+ ) : (
+ content
+ );
+
+ const detailsSection = (
+ <div className="flex flex-col gap-4">
+ <div className="flex w-full flex-col items-center justify-center gap-y-2">
+ <div className="flex w-full items-center justify-center gap-2">
+ <p className="line-clamp-2 text-ellipsis break-words text-lg">
+ {title === undefined || title === "" ? "Untitled" : title}
+ </p>
</div>
+ {sourceUrl && (
+ <Link
+ href={sourceUrl}
+ target="_blank"
+ className="flex items-center gap-2 text-gray-400"
+ >
+ <span>{t("preview.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">{t("common.tags")}</p>
+ <BookmarkTagsEditor bookmark={bookmark} />
+ </div>
+ <div className="flex gap-4">
+ <p className="pt-2 text-sm text-gray-400">{t("common.note")}</p>
+ <NoteEditor bookmark={bookmark} />
+ </div>
+ <AttachmentBox bookmark={bookmark} />
+ <HighlightsBox bookmarkId={bookmark.id} />
+ <ActionBar bookmark={bookmark} />
+ </div>
+ );
- <CreationTime createdAt={bookmark.createdAt} />
- <SummarizeBookmarkArea bookmark={bookmark} />
- <div className="flex items-center gap-4">
- <p className="text-sm text-gray-400">{t("common.tags")}</p>
- <BookmarkTagsEditor bookmark={bookmark} />
+ return (
+ <>
+ {/* Render original layout for wide screens */}
+ <div className="hidden h-full grid-cols-3 overflow-hidden bg-background lg:grid">
+ <div className="col-span-2 h-full w-full overflow-auto p-2">
+ {contentSection}
</div>
- <div className="flex gap-4">
- <p className="pt-2 text-sm text-gray-400">{t("common.note")}</p>
- <NoteEditor bookmark={bookmark} />
+ <div className="flex flex-col gap-4 overflow-auto bg-accent p-4">
+ {detailsSection}
</div>
- <AttachmentBox bookmark={bookmark} />
- <HighlightsBox bookmarkId={bookmark.id} />
- <ActionBar bookmark={bookmark} />
</div>
- </div>
+
+ {/* Render tabbed layout for narrow/vertical screens */}
+ <Tabs
+ value={activeTab}
+ onValueChange={setActiveTab}
+ className="flex h-full w-full flex-col overflow-hidden lg:hidden"
+ >
+ <TabsList
+ className={`sticky top-0 z-10 grid h-auto w-full grid-cols-2`}
+ >
+ <TabsTrigger value="content">{t("preview.tabs.content")}</TabsTrigger>
+ <TabsTrigger value="details">{t("preview.tabs.details")}</TabsTrigger>
+ </TabsList>
+ <TabsContent
+ value="content"
+ className="h-full flex-1 overflow-hidden overflow-y-auto bg-background p-2 data-[state=inactive]:hidden"
+ >
+ {contentSection}
+ </TabsContent>
+ <TabsContent
+ value="details"
+ className="h-full overflow-y-auto bg-accent p-4 data-[state=inactive]:hidden"
+ >
+ {detailsSection}
+ </TabsContent>
+ </Tabs>
+ </>
);
}
diff --git a/apps/web/components/dashboard/preview/TextContentSection.tsx b/apps/web/components/dashboard/preview/TextContentSection.tsx
index 0c1aae67..4e33bb92 100644
--- a/apps/web/components/dashboard/preview/TextContentSection.tsx
+++ b/apps/web/components/dashboard/preview/TextContentSection.tsx
@@ -3,8 +3,8 @@ import { BookmarkMarkdownComponent } from "@/components/dashboard/bookmarks/Book
import { ScrollArea } from "@radix-ui/react-scroll-area";
import type { ZBookmarkTypeText } from "@karakeep/shared/types/bookmarks";
-import { getAssetUrl } from "@karakeep/shared-react/utils/assetUtils";
import { BookmarkTypes, ZBookmark } from "@karakeep/shared/types/bookmarks";
+import { getAssetUrl } from "@karakeep/shared/utils/assetUtils";
export function TextContentSection({ bookmark }: { bookmark: ZBookmark }) {
if (bookmark.content.type != BookmarkTypes.TEXT) {
diff --git a/apps/web/components/dashboard/search/SearchInput.tsx b/apps/web/components/dashboard/search/SearchInput.tsx
index c58542bf..e60c460c 100644
--- a/apps/web/components/dashboard/search/SearchInput.tsx
+++ b/apps/web/components/dashboard/search/SearchInput.tsx
@@ -100,7 +100,7 @@ const SearchInput = React.forwardRef<
</Button>
)}
<Input
- startIcon={SearchIcon}
+ startIcon={<SearchIcon size={18} className="text-muted-foreground" />}
ref={inputRef}
value={value}
onChange={onChange}
diff --git a/apps/web/components/dashboard/sidebar/AllLists.tsx b/apps/web/components/dashboard/sidebar/AllLists.tsx
index 4c97ffae..50a06106 100644
--- a/apps/web/components/dashboard/sidebar/AllLists.tsx
+++ b/apps/web/components/dashboard/sidebar/AllLists.tsx
@@ -76,7 +76,7 @@ export default function AllLists({
}
name={node.item.name}
path={`/dashboard/lists/${node.item.id}`}
- className="px-0.5"
+ className="group px-0.5"
right={
<ListOptions
onOpenChange={(open) => {
@@ -88,34 +88,32 @@ export default function AllLists({
}}
list={node.item}
>
- <Button size="none" variant="ghost">
- <div className="relative">
- <MoreHorizontal
- className={cn(
- "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100",
- selectedListId == node.item.id
- ? "opacity-100"
- : "opacity-0",
- )}
- />
+ <Button size="none" variant="ghost" className="relative">
+ <MoreHorizontal
+ className={cn(
+ "absolute inset-0 m-auto size-4 opacity-0 transition-opacity duration-100 group-hover:opacity-100",
+ selectedListId == node.item.id
+ ? "opacity-100"
+ : "opacity-0",
+ )}
+ />
- <Badge
- variant="outline"
- className={cn(
- "font-normal opacity-100 transition-opacity duration-100 group-hover:opacity-0",
- selectedListId == node.item.id ||
- numBookmarks === undefined
- ? "opacity-0"
- : "opacity-100",
- )}
- >
- {numBookmarks}
- </Badge>
- </div>
+ <Badge
+ variant="outline"
+ className={cn(
+ "font-normal opacity-100 transition-opacity duration-100 group-hover:opacity-0",
+ selectedListId == node.item.id ||
+ numBookmarks === undefined
+ ? "opacity-0"
+ : "opacity-100",
+ )}
+ >
+ {numBookmarks}
+ </Badge>
</Button>
</ListOptions>
}
- linkClassName="group py-0.5"
+ linkClassName="py-0.5"
style={{ marginLeft: `${level * 1}rem` }}
/>
)}
diff --git a/apps/web/components/dashboard/tags/TagOptions.tsx b/apps/web/components/dashboard/tags/TagOptions.tsx
index 8d8cc9db..1419e6c3 100644
--- a/apps/web/components/dashboard/tags/TagOptions.tsx
+++ b/apps/web/components/dashboard/tags/TagOptions.tsx
@@ -7,8 +7,9 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { useShowArchived } from "@/components/utils/useShowArchived";
import { useTranslation } from "@/lib/i18n/client";
-import { Combine, Trash2 } from "lucide-react";
+import { Combine, Square, SquareCheck, Trash2 } from "lucide-react";
import DeleteTagConfirmationDialog from "./DeleteTagConfirmationDialog";
import { MergeTagModal } from "./MergeTagModal";
@@ -21,6 +22,8 @@ export function TagOptions({
children?: React.ReactNode;
}) {
const { t } = useTranslation();
+ const { showArchived, onClickShowArchived } = useShowArchived();
+
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false);
const [mergeTagDialogOpen, setMergeTagDialogOpen] = useState(false);
@@ -45,7 +48,14 @@ export function TagOptions({
<Combine className="size-4" />
<span>{t("actions.merge")}</span>
</DropdownMenuItem>
-
+ <DropdownMenuItem className="flex gap-2" onClick={onClickShowArchived}>
+ {showArchived ? (
+ <SquareCheck className="size-4" />
+ ) : (
+ <Square className="size-4" />
+ )}
+ <span>{t("actions.toggle_show_archived")}</span>
+ </DropdownMenuItem>
<DropdownMenuItem
className="flex gap-2"
onClick={() => setDeleteTagDialogOpen(true)}
diff --git a/apps/web/components/dashboard/tags/TagPill.tsx b/apps/web/components/dashboard/tags/TagPill.tsx
index c6b08d64..91bd8504 100644
--- a/apps/web/components/dashboard/tags/TagPill.tsx
+++ b/apps/web/components/dashboard/tags/TagPill.tsx
@@ -81,6 +81,7 @@ export function TagPill({
}
href={`/dashboard/tags/${id}`}
data-id={id}
+ draggable={false}
>
{name} <Separator orientation="vertical" /> {count}
</Link>
diff --git a/apps/web/components/public/lists/PublicBookmarkGrid.tsx b/apps/web/components/public/lists/PublicBookmarkGrid.tsx
new file mode 100644
index 00000000..038ac3ae
--- /dev/null
+++ b/apps/web/components/public/lists/PublicBookmarkGrid.tsx
@@ -0,0 +1,247 @@
+"use client";
+
+import { useEffect, useMemo } from "react";
+import Link from "next/link";
+import BookmarkFormattedCreatedAt from "@/components/dashboard/bookmarks/BookmarkFormattedCreatedAt";
+import { BookmarkMarkdownComponent } from "@/components/dashboard/bookmarks/BookmarkMarkdownComponent";
+import FooterLinkURL from "@/components/dashboard/bookmarks/FooterLinkURL";
+import { ActionButton } from "@/components/ui/action-button";
+import { badgeVariants } from "@/components/ui/badge";
+import { Card, CardContent } from "@/components/ui/card";
+import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
+import { api } from "@/lib/trpc";
+import { cn } from "@/lib/utils";
+import tailwindConfig from "@/tailwind.config";
+import { Expand, FileIcon, ImageIcon } from "lucide-react";
+import { useInView } from "react-intersection-observer";
+import Masonry from "react-masonry-css";
+import resolveConfig from "tailwindcss/resolveConfig";
+
+import {
+ BookmarkTypes,
+ ZPublicBookmark,
+} from "@karakeep/shared/types/bookmarks";
+import { ZCursor } from "@karakeep/shared/types/pagination";
+
+function TagPill({ tag }: { tag: string }) {
+ return (
+ <div
+ className={cn(
+ badgeVariants({ variant: "secondary" }),
+ "text-nowrap font-light text-gray-700 hover:bg-foreground hover:text-secondary dark:text-gray-400",
+ )}
+ key={tag}
+ >
+ {tag}
+ </div>
+ );
+}
+
+function BookmarkCard({ bookmark }: { bookmark: ZPublicBookmark }) {
+ const renderContent = () => {
+ switch (bookmark.content.type) {
+ case BookmarkTypes.LINK:
+ return (
+ <div className="space-y-2">
+ {bookmark.bannerImageUrl && (
+ <div className="aspect-video w-full overflow-hidden rounded bg-gray-100">
+ <Link href={bookmark.content.url} target="_blank">
+ <img
+ src={bookmark.bannerImageUrl}
+ alt={bookmark.title ?? "Link preview"}
+ className="h-full w-full object-cover"
+ />
+ </Link>
+ </div>
+ )}
+ <div className="space-y-2">
+ <Link
+ href={bookmark.content.url}
+ target="_blank"
+ className="line-clamp-2 text-ellipsis text-lg font-medium leading-tight text-gray-900"
+ >
+ {bookmark.title}
+ </Link>
+ </div>
+ </div>
+ );
+
+ case BookmarkTypes.TEXT:
+ return (
+ <div className="space-y-2">
+ {bookmark.title && (
+ <h3 className="line-clamp-2 text-ellipsis text-lg font-medium leading-tight text-gray-900">
+ {bookmark.title}
+ </h3>
+ )}
+ <div className="group relative max-h-64 overflow-hidden">
+ <BookmarkMarkdownComponent readOnly={true}>
+ {{
+ id: bookmark.id,
+ content: {
+ text: bookmark.content.text,
+ },
+ }}
+ </BookmarkMarkdownComponent>
+ <Dialog>
+ <DialogTrigger className="absolute bottom-2 right-2 z-50 h-4 w-4 opacity-0 group-hover:opacity-100">
+ <Expand className="h-4 w-4" />
+ </DialogTrigger>
+ <DialogContent className="max-h-96 max-w-3xl overflow-auto">
+ <BookmarkMarkdownComponent readOnly={true}>
+ {{
+ id: bookmark.id,
+ content: {
+ text: bookmark.content.text,
+ },
+ }}
+ </BookmarkMarkdownComponent>
+ </DialogContent>
+ </Dialog>
+ </div>
+ </div>
+ );
+
+ case BookmarkTypes.ASSET:
+ return (
+ <div className="space-y-2">
+ {bookmark.bannerImageUrl ? (
+ <div className="aspect-video w-full overflow-hidden rounded bg-gray-100">
+ <Link href={bookmark.content.assetUrl}>
+ <img
+ src={bookmark.bannerImageUrl}
+ alt={bookmark.title ?? "Asset preview"}
+ className="h-full w-full object-cover"
+ />
+ </Link>
+ </div>
+ ) : (
+ <div className="flex aspect-video w-full items-center justify-center overflow-hidden rounded bg-gray-100">
+ {bookmark.content.assetType === "image" ? (
+ <ImageIcon className="h-8 w-8 text-gray-400" />
+ ) : (
+ <FileIcon className="h-8 w-8 text-gray-400" />
+ )}
+ </div>
+ )}
+ <div className="space-y-1">
+ <Link
+ href={bookmark.content.assetUrl}
+ target="_blank"
+ className="line-clamp-2 text-ellipsis text-lg font-medium leading-tight text-gray-900"
+ >
+ {bookmark.title}
+ </Link>
+ </div>
+ </div>
+ );
+ }
+ };
+
+ return (
+ <Card className="group mb-3 border-0 shadow-sm transition-all duration-200 hover:shadow-lg">
+ <CardContent className="p-3">
+ {renderContent()}
+
+ {/* Tags */}
+ {bookmark.tags.length > 0 && (
+ <div className="mt-2 flex flex-wrap gap-1">
+ {bookmark.tags.map((tag, index) => (
+ <TagPill key={index} tag={tag} />
+ ))}
+ </div>
+ )}
+
+ {/* Footer */}
+ <div className="mt-3 flex items-center justify-between pt-2">
+ <div className="flex items-center gap-2 text-xs text-gray-500">
+ {bookmark.content.type === BookmarkTypes.LINK && (
+ <>
+ <FooterLinkURL url={bookmark.content.url} />
+ <span>•</span>
+ </>
+ )}
+ <BookmarkFormattedCreatedAt createdAt={bookmark.createdAt} />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ );
+}
+
+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 PublicBookmarkGrid({
+ bookmarks: initialBookmarks,
+ nextCursor,
+ list,
+}: {
+ list: {
+ id: string;
+ name: string;
+ description: string | null | undefined;
+ icon: string;
+ numItems: number;
+ };
+ bookmarks: ZPublicBookmark[];
+ nextCursor: ZCursor | null;
+}) {
+ const { ref: loadMoreRef, inView: loadMoreButtonInView } = useInView();
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
+ api.publicBookmarks.getPublicBookmarksInList.useInfiniteQuery(
+ { listId: list.id },
+ {
+ initialData: () => ({
+ pages: [{ bookmarks: initialBookmarks, nextCursor, list }],
+ pageParams: [null],
+ }),
+ initialCursor: null,
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
+ refetchOnMount: true,
+ },
+ );
+
+ useEffect(() => {
+ if (loadMoreButtonInView && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage();
+ }
+ }, [loadMoreButtonInView]);
+
+ const breakpointConfig = useMemo(() => getBreakpointConfig(), []);
+
+ const bookmarks = useMemo(() => {
+ return data.pages.flatMap((b) => b.bookmarks);
+ }, [data]);
+ return (
+ <>
+ <Masonry className="flex gap-4" breakpointCols={breakpointConfig}>
+ {bookmarks.map((bookmark) => (
+ <BookmarkCard key={bookmark.id} bookmark={bookmark} />
+ ))}
+ </Masonry>
+ {hasNextPage && (
+ <div className="flex justify-center">
+ <ActionButton
+ ref={loadMoreRef}
+ ignoreDemoMode={true}
+ loading={isFetchingNextPage}
+ onClick={() => fetchNextPage()}
+ variant="ghost"
+ >
+ Load More
+ </ActionButton>
+ </div>
+ )}
+ </>
+ );
+}
diff --git a/apps/web/components/public/lists/PublicListHeader.tsx b/apps/web/components/public/lists/PublicListHeader.tsx
new file mode 100644
index 00000000..1f016351
--- /dev/null
+++ b/apps/web/components/public/lists/PublicListHeader.tsx
@@ -0,0 +1,17 @@
+export default function PublicListHeader({
+ list,
+}: {
+ list: {
+ id: string;
+ numItems: number;
+ };
+}) {
+ return (
+ <div className="flex w-full justify-between">
+ <span />
+ <p className="text-xs font-light uppercase text-gray-500">
+ {list.numItems} bookmarks
+ </p>
+ </div>
+ );
+}
diff --git a/apps/web/components/settings/FeedSettings.tsx b/apps/web/components/settings/FeedSettings.tsx
index ff8590c9..fa019cf6 100644
--- a/apps/web/components/settings/FeedSettings.tsx
+++ b/apps/web/components/settings/FeedSettings.tsx
@@ -13,6 +13,7 @@ import {
} from "@/components/ui/form";
import { FullPageSpinner } from "@/components/ui/full-page-spinner";
import { Input } from "@/components/ui/input";
+import { Switch } from "@/components/ui/switch";
import { toast } from "@/components/ui/use-toast";
import { useTranslation } from "@/lib/i18n/client";
import { api } from "@/lib/trpc";
@@ -70,6 +71,7 @@ export function FeedsEditorDialog() {
defaultValues: {
name: "",
url: "",
+ enabled: true,
},
});
@@ -199,12 +201,16 @@ export function EditFeedDialog({ feed }: { feed: ZFeed }) {
});
return (
<Dialog open={open} onOpenChange={setOpen}>
- <DialogTrigger asChild>
- <Button variant="secondary">
- <Edit className="mr-2 size-4" />
- {t("actions.edit")}
- </Button>
- </DialogTrigger>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <DialogTrigger asChild>
+ <Button variant="ghost">
+ <Edit className="size-4" />
+ </Button>
+ </DialogTrigger>
+ </TooltipTrigger>
+ <TooltipContent>{t("actions.edit")}</TooltipContent>
+ </Tooltip>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Feed</DialogTitle>
@@ -309,6 +315,27 @@ export function FeedRow({ feed }: { feed: ZFeed }) {
},
});
+ const { mutate: updateFeedEnabled } = api.feeds.update.useMutation({
+ onSuccess: () => {
+ toast({
+ description: feed.enabled
+ ? t("settings.feeds.feed_disabled")
+ : t("settings.feeds.feed_enabled"),
+ });
+ apiUtils.feeds.list.invalidate();
+ },
+ onError: (error) => {
+ toast({
+ description: `Error: ${error.message}`,
+ variant: "destructive",
+ });
+ },
+ });
+
+ const handleToggle = (checked: boolean) => {
+ updateFeedEnabled({ feedId: feed.id, enabled: checked });
+ };
+
return (
<TableRow>
<TableCell>
@@ -319,7 +346,12 @@ export function FeedRow({ feed }: { feed: ZFeed }) {
{feed.name}
</Link>
</TableCell>
- <TableCell>{feed.url}</TableCell>
+ <TableCell
+ className="max-w-64 overflow-clip text-ellipsis"
+ title={feed.url}
+ >
+ {feed.url}
+ </TableCell>
<TableCell>{feed.lastFetchedAt?.toLocaleString()}</TableCell>
<TableCell>
{feed.lastFetchedStatus === "success" ? (
@@ -337,16 +369,21 @@ export function FeedRow({ feed }: { feed: ZFeed }) {
)}
</TableCell>
<TableCell className="flex items-center gap-2">
+ <Switch checked={feed.enabled} onCheckedChange={handleToggle} />
<EditFeedDialog feed={feed} />
- <ActionButton
- loading={isFetching}
- variant="secondary"
- className="items-center"
- onClick={() => fetchNow({ feedId: feed.id })}
- >
- <ArrowDownToLine className="mr-2 size-4" />
- {t("actions.fetch_now")}
- </ActionButton>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <ActionButton
+ loading={isFetching}
+ variant="ghost"
+ className="items-center"
+ onClick={() => fetchNow({ feedId: feed.id })}
+ >
+ <ArrowDownToLine className="size-4" />
+ </ActionButton>
+ </TooltipTrigger>
+ <TooltipContent>{t("actions.fetch_now")}</TooltipContent>
+ </Tooltip>
<ActionConfirmingDialog
title={`Delete Feed "${feed.name}"?`}
description={`Are you sure you want to delete the feed "${feed.name}"?`}
@@ -364,8 +401,7 @@ export function FeedRow({ feed }: { feed: ZFeed }) {
)}
>
<Button variant="destructive" disabled={isDeleting}>
- <Trash2 className="mr-2 size-4" />
- {t("actions.delete")}
+ <Trash2 className="size-4" />
</Button>
</ActionConfirmingDialog>
</TableCell>
diff --git a/apps/web/components/settings/ImportExport.tsx b/apps/web/components/settings/ImportExport.tsx
index 43b934a6..35c2b88f 100644
--- a/apps/web/components/settings/ImportExport.tsx
+++ b/apps/web/components/settings/ImportExport.tsx
@@ -6,6 +6,13 @@ import { useRouter } from "next/navigation";
import { buttonVariants } from "@/components/ui/button";
import FilePickerButton from "@/components/ui/file-picker-button";
import { Progress } from "@/components/ui/progress";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
import { toast } from "@/components/ui/use-toast";
import { useTranslation } from "@/lib/i18n/client";
import {
@@ -20,7 +27,6 @@ import {
} from "@/lib/importBookmarkParser";
import { cn } from "@/lib/utils";
import { useMutation } from "@tanstack/react-query";
-import { TRPCClientError } from "@trpc/client";
import { Download, Upload } from "lucide-react";
import {
@@ -63,6 +69,8 @@ function ImportCard({
function ExportButton() {
const { t } = useTranslation();
+ const [format, setFormat] = useState<"json" | "netscape">("json");
+
return (
<Card className="transition-all hover:shadow-md">
<CardContent className="flex items-center gap-3 p-4">
@@ -72,9 +80,21 @@ function ExportButton() {
<div className="flex-1">
<h3 className="font-medium">Export File</h3>
<p>{t("settings.import.export_links_and_notes")}</p>
+ <Select
+ value={format}
+ onValueChange={(value) => setFormat(value as "json" | "netscape")}
+ >
+ <SelectTrigger className="mt-2 w-[180px]">
+ <SelectValue placeholder="Format" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="json">JSON (Karakeep format)</SelectItem>
+ <SelectItem value="netscape">HTML (Netscape format)</SelectItem>
+ </SelectContent>
+ </Select>
</div>
<Link
- href="/api/bookmarks/export"
+ href={`/api/bookmarks/export?format=${format}`}
className={cn(
buttonVariants({ variant: "default", size: "sm" }),
"flex items-center gap-2",
@@ -104,7 +124,7 @@ export function ImportExportRow() {
const { mutateAsync: parseAndCreateBookmark } = useMutation({
mutationFn: async (toImport: {
bookmark: ParsedBookmark;
- listId: string;
+ listIds: string[];
}) => {
const bookmark = toImport.bookmark;
if (bookmark.content === undefined) {
@@ -116,6 +136,7 @@ export function ImportExportRow() {
? new Date(bookmark.addDate * 1000)
: undefined,
note: bookmark.notes,
+ archived: bookmark.archived,
...(bookmark.content.type === BookmarkTypes.LINK
? {
type: BookmarkTypes.LINK,
@@ -129,20 +150,14 @@ export function ImportExportRow() {
await Promise.all([
// Add to import list
- addToList({
- bookmarkId: created.id,
- listId: toImport.listId,
- }).catch((e) => {
- if (
- e instanceof TRPCClientError &&
- e.message.includes("already in the list")
- ) {
- /* empty */
- } else {
- throw e;
- }
- }),
-
+ ...[
+ toImport.listIds.map((listId) =>
+ addToList({
+ bookmarkId: created.id,
+ listId,
+ }),
+ ),
+ ],
// Update tags
bookmark.tags.length > 0
? updateTags({
@@ -192,7 +207,7 @@ export function ImportExportRow() {
return;
}
- const importList = await createList({
+ const rootList = await createList({
name: t("settings.import.imported_bookmarks"),
icon: "⬆️",
});
@@ -201,33 +216,83 @@ export function ImportExportRow() {
setImportProgress({ done: 0, total: finalBookmarksToImport.length });
+ // Precreate folder lists
+ const allRequiredPaths = new Set<string>();
+ // collect the paths of all bookmarks that have non-empty paths
+ for (const bookmark of finalBookmarksToImport) {
+ for (const path of bookmark.paths) {
+ if (path && path.length > 0) {
+ // We need every prefix of the path for the hierarchy
+ for (let i = 1; i <= path.length; i++) {
+ const subPath = path.slice(0, i);
+ const pathKey = subPath.join("/");
+ allRequiredPaths.add(pathKey);
+ }
+ }
+ }
+ }
+
+ // Convert to array and sort by depth (so that parent paths come first)
+ const allRequiredPathsArray = Array.from(allRequiredPaths).sort(
+ (a, b) => a.split("/").length - b.split("/").length,
+ );
+
+ const pathMap: Record<string, string> = {};
+
+ // Root list is the parent for top-level folders
+ // Represent root as empty string
+ pathMap[""] = rootList.id;
+
+ for (const pathKey of allRequiredPathsArray) {
+ const parts = pathKey.split("/");
+ const parentKey = parts.slice(0, -1).join("/");
+ const parentId = pathMap[parentKey] || rootList.id;
+
+ const folderName = parts[parts.length - 1];
+ // Create the list
+ const folderList = await createList({
+ name: folderName,
+ parentId: parentId,
+ icon: "📁",
+ });
+ pathMap[pathKey] = folderList.id;
+ }
+
const importPromises = finalBookmarksToImport.map(
- (bookmark) => () =>
- parseAndCreateBookmark({
- bookmark: bookmark,
- listId: importList.id,
- }).then(
- (value) => {
- setImportProgress((prev) => {
- const newDone = (prev?.done ?? 0) + 1;
- return {
- done: newDone,
- total: finalBookmarksToImport.length,
- };
- });
- return { status: "fulfilled" as const, value };
- },
- () => {
- setImportProgress((prev) => {
- const newDone = (prev?.done ?? 0) + 1;
- return {
- done: newDone,
- total: finalBookmarksToImport.length,
- };
- });
- return { status: "rejected" as const };
- },
- ),
+ (bookmark) => async () => {
+ // Determine the target list ids
+ const listIds = bookmark.paths.map(
+ (path) => pathMap[path.join("/")] || rootList.id,
+ );
+ if (listIds.length === 0) {
+ listIds.push(rootList.id);
+ }
+
+ try {
+ const created = await parseAndCreateBookmark({
+ bookmark: bookmark,
+ listIds,
+ });
+
+ setImportProgress((prev) => {
+ const newDone = (prev?.done ?? 0) + 1;
+ return {
+ done: newDone,
+ total: finalBookmarksToImport.length,
+ };
+ });
+ return { status: "fulfilled" as const, value: created };
+ } catch (e) {
+ setImportProgress((prev) => {
+ const newDone = (prev?.done ?? 0) + 1;
+ return {
+ done: newDone,
+ total: finalBookmarksToImport.length,
+ };
+ });
+ return { status: "rejected" as const };
+ }
+ },
);
const CONCURRENCY_LIMIT = 20;
@@ -268,7 +333,7 @@ export function ImportExportRow() {
});
}
- router.push(`/dashboard/lists/${importList.id}`);
+ router.push(`/dashboard/lists/${rootList.id}`);
},
onError: (error) => {
setImportProgress(null); // Clear progress on initial parsing error
diff --git a/apps/web/components/settings/UserOptions.tsx b/apps/web/components/settings/UserOptions.tsx
index 33ffc46a..3918ceed 100644
--- a/apps/web/components/settings/UserOptions.tsx
+++ b/apps/web/components/settings/UserOptions.tsx
@@ -1,11 +1,23 @@
"use client";
+import { useEffect } from "react";
+import { useClientConfig } from "@/lib/clientConfig";
import { useTranslation } from "@/lib/i18n/client";
import { useInterfaceLang } from "@/lib/userLocalSettings/bookmarksLayout";
import { updateInterfaceLang } from "@/lib/userLocalSettings/userLocalSettings";
+import { useUserSettings } from "@/lib/userSettings";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { useUpdateUserSettings } from "@karakeep/shared-react/hooks/users";
import { langNameMappings } from "@karakeep/shared/langs";
+import {
+ ZUserSettings,
+ zUserSettingsSchema,
+} from "@karakeep/shared/types/users";
+import { Form, FormField } from "../ui/form";
import { Label } from "../ui/label";
import {
Select,
@@ -14,6 +26,7 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
+import { toast } from "../ui/use-toast";
const LanguageSelect = () => {
const lang = useInterfaceLang();
@@ -38,6 +51,132 @@ const LanguageSelect = () => {
);
};
+export default function UserSettings() {
+ const { t } = useTranslation();
+ const clientConfig = useClientConfig();
+ const data = useUserSettings();
+ const { mutate } = useUpdateUserSettings({
+ onSuccess: () => {
+ toast({
+ description: t("settings.info.user_settings.user_settings_updated"),
+ });
+ },
+ onError: () => {
+ toast({
+ description: t("common.something_went_wrong"),
+ variant: "destructive",
+ });
+ },
+ });
+
+ const bookmarkClickActionTranslation: Record<
+ ZUserSettings["bookmarkClickAction"],
+ string
+ > = {
+ open_original_link: t(
+ "settings.info.user_settings.bookmark_click_action.open_external_url",
+ ),
+ expand_bookmark_preview: t(
+ "settings.info.user_settings.bookmark_click_action.open_bookmark_details",
+ ),
+ };
+
+ const archiveDisplayBehaviourTranslation: Record<
+ ZUserSettings["archiveDisplayBehaviour"],
+ string
+ > = {
+ show: t("settings.info.user_settings.archive_display_behaviour.show"),
+ hide: t("settings.info.user_settings.archive_display_behaviour.hide"),
+ };
+
+ const form = useForm<z.infer<typeof zUserSettingsSchema>>({
+ resolver: zodResolver(zUserSettingsSchema),
+ defaultValues: data,
+ });
+
+ // When the actual user setting is loaded, reset the form to the current value
+ useEffect(() => {
+ form.reset(data);
+ }, [data]);
+
+ return (
+ <Form {...form}>
+ <FormField
+ control={form.control}
+ name="bookmarkClickAction"
+ render={({ field }) => (
+ <div className="flex w-full flex-col gap-2">
+ <Label>
+ {t("settings.info.user_settings.bookmark_click_action.title")}
+ </Label>
+ <Select
+ disabled={!!clientConfig.demoMode}
+ value={field.value}
+ onValueChange={(value) => {
+ mutate({
+ bookmarkClickAction:
+ value as ZUserSettings["bookmarkClickAction"],
+ });
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue>
+ {bookmarkClickActionTranslation[field.value]}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(bookmarkClickActionTranslation).map(
+ ([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ),
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="archiveDisplayBehaviour"
+ render={({ field }) => (
+ <div className="flex w-full flex-col gap-2">
+ <Label>
+ {t("settings.info.user_settings.archive_display_behaviour.title")}
+ </Label>
+ <Select
+ disabled={!!clientConfig.demoMode}
+ value={field.value}
+ onValueChange={(value) => {
+ mutate({
+ archiveDisplayBehaviour:
+ value as ZUserSettings["archiveDisplayBehaviour"],
+ });
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue>
+ {archiveDisplayBehaviourTranslation[field.value]}
+ </SelectValue>
+ </SelectTrigger>
+ <SelectContent>
+ {Object.entries(archiveDisplayBehaviourTranslation).map(
+ ([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ),
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+ />
+ </Form>
+ );
+}
+
export function UserOptions() {
const { t } = useTranslation();
@@ -46,9 +185,12 @@ export function UserOptions() {
<div className="mb-4 w-full text-lg font-medium sm:w-1/3">
{t("settings.info.options")}
</div>
- <div className="flex w-full flex-col gap-2">
- <Label>{t("settings.info.interface_lang")}</Label>
- <LanguageSelect />
+ <div className="flex w-full flex-col gap-3">
+ <div className="flex w-full flex-col gap-2">
+ <Label>{t("settings.info.interface_lang")}</Label>
+ <LanguageSelect />
+ </div>
+ <UserSettings />
</div>
</div>
);
diff --git a/apps/web/components/ui/copy-button.tsx b/apps/web/components/ui/copy-button.tsx
index a51ce902..1cb405da 100644
--- a/apps/web/components/ui/copy-button.tsx
+++ b/apps/web/components/ui/copy-button.tsx
@@ -1,6 +1,10 @@
-import React, { useEffect } from "react";
+import React, { useEffect, useState } from "react";
+import { cn } from "@/lib/utils";
import { Check, Copy } from "lucide-react";
+import { Button } from "./button";
+import { toast } from "./use-toast";
+
export default function CopyBtn({
className,
getStringToCopy,
@@ -35,3 +39,38 @@ export default function CopyBtn({
</button>
);
}
+
+export function CopyBtnV2({
+ className,
+ getStringToCopy,
+}: {
+ className?: string;
+ getStringToCopy: () => string;
+}) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async (url: string) => {
+ try {
+ await navigator.clipboard.writeText(url);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ toast({
+ description:
+ "Failed to copy link. Browsers only support copying to the clipboard from https pages.",
+ variant: "destructive",
+ });
+ }
+ };
+
+ return (
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => handleCopy(getStringToCopy())}
+ className={cn("shrink-0", className)}
+ >
+ {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
+ </Button>
+ );
+}
diff --git a/apps/web/components/ui/input.tsx b/apps/web/components/ui/input.tsx
index 09f9def9..66cd1108 100644
--- a/apps/web/components/ui/input.tsx
+++ b/apps/web/components/ui/input.tsx
@@ -1,23 +1,19 @@
import * as React from "react";
import { cn } from "@/lib/utils";
-import { LucideIcon } from "lucide-react";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
- startIcon?: LucideIcon;
- endIcon?: LucideIcon;
+ startIcon?: React.ReactNode;
+ endIcon?: React.ReactNode;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, startIcon, endIcon, ...props }, ref) => {
- const StartIcon = startIcon;
- const EndIcon = endIcon;
-
return (
<div className="relative w-full">
- {StartIcon && (
+ {startIcon && (
<div className="absolute left-2 top-1/2 -translate-y-1/2 transform">
- <StartIcon size={18} className="text-muted-foreground" />
+ {startIcon}
</div>
)}
<input
@@ -31,9 +27,9 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
ref={ref}
{...props}
/>
- {EndIcon && (
+ {endIcon && (
<div className="absolute right-3 top-1/2 -translate-y-1/2 transform">
- <EndIcon className="text-muted-foreground" size={18} />
+ {endIcon}
</div>
)}
</div>
diff --git a/apps/web/components/utils/useShowArchived.tsx b/apps/web/components/utils/useShowArchived.tsx
new file mode 100644
index 00000000..3fc66e91
--- /dev/null
+++ b/apps/web/components/utils/useShowArchived.tsx
@@ -0,0 +1,24 @@
+import { useCallback } from "react";
+import { useUserSettings } from "@/lib/userSettings";
+import { parseAsBoolean, useQueryState } from "nuqs";
+
+export function useShowArchived() {
+ const userSettings = useUserSettings();
+ const [showArchived, setShowArchived] = useQueryState(
+ "includeArchived",
+ parseAsBoolean
+ .withOptions({
+ shallow: false,
+ })
+ .withDefault(userSettings.archiveDisplayBehaviour === "show"),
+ );
+
+ const onClickShowArchived = useCallback(() => {
+ setShowArchived((prev) => !prev);
+ }, [setShowArchived]);
+
+ return {
+ showArchived,
+ onClickShowArchived,
+ };
+}
diff --git a/apps/web/lib/clientConfig.tsx b/apps/web/lib/clientConfig.tsx
index ef8e0815..03089e49 100644
--- a/apps/web/lib/clientConfig.tsx
+++ b/apps/web/lib/clientConfig.tsx
@@ -3,12 +3,15 @@ import { createContext, useContext } from "react";
import type { ClientConfig } from "@karakeep/shared/config";
export const ClientConfigCtx = createContext<ClientConfig>({
+ publicUrl: "",
+ publicApiUrl: "",
demoMode: undefined,
auth: {
disableSignups: false,
disablePasswordAuth: false,
},
inference: {
+ isConfigured: false,
inferredTagLang: "english",
},
serverVersion: undefined,
diff --git a/apps/web/lib/exportBookmarks.ts b/apps/web/lib/exportBookmarks.ts
index 45db104f..5dc26e78 100644
--- a/apps/web/lib/exportBookmarks.ts
+++ b/apps/web/lib/exportBookmarks.ts
@@ -19,6 +19,7 @@ export const zExportBookmarkSchema = z.object({
])
.nullable(),
note: z.string().nullable(),
+ archived: z.boolean().optional().default(false),
});
export const zExportSchema = z.object({
@@ -56,5 +57,55 @@ export function toExportFormat(
tags: bookmark.tags.map((t) => t.name),
content,
note: bookmark.note ?? null,
+ archived: bookmark.archived,
};
}
+
+export function toNetscapeFormat(bookmarks: ZBookmark[]): string {
+ const header = `<!DOCTYPE NETSCAPE-Bookmark-file-1>
+<!-- This is an automatically generated file.
+ It will be read and overwritten.
+ DO NOT EDIT! -->
+<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
+<TITLE>Bookmarks</TITLE>
+<H1>Bookmarks</H1>
+<DL><p>`;
+
+ const footer = `</DL><p>`;
+
+ const bookmarkEntries = bookmarks
+ .map((bookmark) => {
+ if (bookmark.content?.type !== BookmarkTypes.LINK) {
+ return "";
+ }
+ const addDate = bookmark.createdAt
+ ? `ADD_DATE="${Math.floor(bookmark.createdAt.getTime() / 1000)}"`
+ : "";
+
+ const tagNames = bookmark.tags.map((t) => t.name).join(",");
+ const tags = tagNames.length > 0 ? `TAGS="${tagNames}"` : "";
+
+ const encodedUrl = encodeURI(bookmark.content.url);
+ const displayTitle = bookmark.title ?? bookmark.content.url;
+ const encodedTitle = escapeHtml(displayTitle);
+
+ return ` <DT><A HREF="${encodedUrl}" ${addDate} ${tags}>${encodedTitle}</A>`;
+ })
+ .filter(Boolean)
+ .join("\n");
+
+ return `${header}\n${bookmarkEntries}\n${footer}`;
+}
+
+function escapeHtml(input: string): string {
+ const escapeMap: Record<string, string> = {
+ "&": "&amp;",
+ "'": "&#x27;",
+ "`": "&#x60;",
+ '"': "&quot;",
+ "<": "&lt;",
+ ">": "&gt;",
+ };
+
+ return input.replace(/[&'`"<>]/g, (match) => escapeMap[match] || "");
+}
diff --git a/apps/web/lib/hooks/bookmark-search.ts b/apps/web/lib/hooks/bookmark-search.ts
index 1bccd280..b6af94ee 100644
--- a/apps/web/lib/hooks/bookmark-search.ts
+++ b/apps/web/lib/hooks/bookmark-search.ts
@@ -6,6 +6,11 @@ import { keepPreviousData } from "@tanstack/react-query";
import { parseSearchQuery } from "@karakeep/shared/searchQueryParser";
+export function useIsSearchPage() {
+ const pathname = usePathname();
+ return pathname.startsWith("/dashboard/search");
+}
+
function useSearchQuery() {
const searchParams = useSearchParams();
const searchQuery = decodeURIComponent(searchParams.get("q") ?? "");
@@ -17,8 +22,8 @@ function useSearchQuery() {
export function useDoBookmarkSearch() {
const router = useRouter();
const { searchQuery, parsedSearchQuery } = useSearchQuery();
+ const isInSearchPage = useIsSearchPage();
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | undefined>();
- const pathname = usePathname();
useEffect(() => {
return () => {
@@ -49,7 +54,7 @@ export function useDoBookmarkSearch() {
debounceSearch,
searchQuery,
parsedSearchQuery,
- isInSearchPage: pathname.startsWith("/dashboard/search"),
+ isInSearchPage,
};
}
diff --git a/apps/web/lib/i18n/locales/en/translation.json b/apps/web/lib/i18n/locales/en/translation.json
index 71dc93ef..3ad4a25e 100644
--- a/apps/web/lib/i18n/locales/en/translation.json
+++ b/apps/web/lib/i18n/locales/en/translation.json
@@ -51,6 +51,7 @@
"favorite": "Favorite",
"unfavorite": "Unfavorite",
"delete": "Delete",
+ "toggle_show_archived": "Show Archived",
"refresh": "Refresh",
"recrawl": "Recrawl",
"download_full_page_archive": "Download Full Page Archive",
@@ -79,6 +80,7 @@
"ignore": "Ignore",
"sort": {
"title": "Sort",
+ "relevant_first": "Most Relevant First",
"newest_first": "Newest First",
"oldest_first": "Oldest First"
}
@@ -97,7 +99,20 @@
"new_password": "New Password",
"confirm_new_password": "Confirm New Password",
"options": "Options",
- "interface_lang": "Interface Language"
+ "interface_lang": "Interface Language",
+ "user_settings": {
+ "user_settings_updated": "User settings have been updated!",
+ "bookmark_click_action": {
+ "title": "Bookmark Click Action",
+ "open_external_url": "Open Original URL",
+ "open_bookmark_details": "Open Bookmark Details"
+ },
+ "archive_display_behaviour": {
+ "title": "Archived Bookmarks",
+ "show": "Show archived bookmarks in tags and lists",
+ "hide": "Hide archived bookmarks in tags and lists"
+ }
+ }
},
"ai": {
"ai_settings": "AI Settings",
@@ -114,7 +129,9 @@
},
"feeds": {
"rss_subscriptions": "RSS Subscriptions",
- "add_a_subscription": "Add a Subscription"
+ "add_a_subscription": "Add a Subscription",
+ "feed_enabled": "RSS Feed enabled",
+ "feed_disabled": "RSS Feed disabled"
},
"webhooks": {
"webhooks": "Webhooks",
@@ -246,6 +263,8 @@
"without_inference": "Without Inference",
"regenerate_ai_tags_for_failed_bookmarks_only": "Regenerate AI Tags for Failed Bookmarks Only",
"regenerate_ai_tags_for_all_bookmarks": "Regenerate AI Tags for All Bookmarks",
+ "regenerate_ai_summaries_for_failed_bookmarks_only": "Regenerate AI Summaries for Failed Bookmarks Only",
+ "regenerate_ai_summaries_for_all_bookmarks": "Regenerate AI Summaries for All Bookmarks",
"reindex_all_bookmarks": "Reindex All Bookmarks",
"compact_assets": "Compact Assets",
"reprocess_assets_fix_mode": "Reprocess Assets (Fix Mode)"
@@ -271,6 +290,7 @@
"favourites": "Favourites",
"new_list": "New List",
"edit_list": "Edit List",
+ "share_list": "Share List",
"new_nested_list": "New Nested List",
"merge_list": "Merge List",
"destination_list": "Destination List",
@@ -283,7 +303,17 @@
"smart_list": "Smart List",
"search_query": "Search Query",
"search_query_help": "Learn more about the search query language.",
- "description": "Description (Optional)"
+ "description": "Description (Optional)",
+ "rss": {
+ "title": "RSS Feed",
+ "description": "Enable an RSS feed for this list",
+ "feed_url": "RSS Feed URL"
+ },
+ "public_list": {
+ "title": "Public List",
+ "description": "Allow others to view this list",
+ "share_link": "Share Link"
+ }
},
"tags": {
"all_tags": "All Tags",
@@ -338,7 +368,11 @@
"preview": {
"view_original": "View Original",
"cached_content": "Cached Content",
- "reader_view": "Reader View"
+ "reader_view": "Reader View",
+ "tabs": {
+ "content": "Content",
+ "details": "Details"
+ }
},
"editor": {
"quickly_focus": "You can quickly focus on this field by pressing ⌘ + E",
diff --git a/apps/web/lib/i18n/locales/en_US/translation.json b/apps/web/lib/i18n/locales/en_US/translation.json
index 8783cfc0..a80ecb84 100644
--- a/apps/web/lib/i18n/locales/en_US/translation.json
+++ b/apps/web/lib/i18n/locales/en_US/translation.json
@@ -149,10 +149,10 @@
"align_right": "Right Align"
},
"quickly_focus": "You can quickly focus on this field by pressing ⌘ + E",
- "multiple_urls_dialog_title": "Importing URLs as separate Bookmarks?",
+ "multiple_urls_dialog_title": "Importing URLs as separate bookmarks?",
"multiple_urls_dialog_desc": "The input contains multiple URLs on separate lines. Do you want to import them as separate bookmarks?",
- "import_as_text": "Import as Text Bookmark",
- "import_as_separate_bookmarks": "Import as separate Bookmarks",
+ "import_as_text": "Import as text bookmark",
+ "import_as_separate_bookmarks": "Import as separate bookmarks",
"placeholder": "Paste a link or an image, write a note, or drag and drop an image in here…",
"placeholder_v2": "Paste a link, write a note, or drop an image…",
"new_item": "NEW ITEM",
@@ -205,7 +205,8 @@
"title": "Events",
"crawled": "Crawled",
"created": "Created",
- "edited": "Edited"
+ "edited": "Edited",
+ "deleted": "Deleted"
},
"auth_token": "Auth Token",
"delete_webhook": "Delete Webhook",
@@ -266,7 +267,7 @@
"rule_has_been_created": "Rule's been created!",
"rule_has_been_updated": "Rule has been updated!",
"rule_has_been_deleted": "Rule's been deleted!",
- "no_rules_created_yet": "No rules created yet, dude",
+ "no_rules_created_yet": "No rules have been created yet",
"create_your_first_rule": "Create your first rule to automate your workflow",
"conditions_types": {
"always": "Always",
@@ -381,7 +382,7 @@
"created_on_or_before": "Created on or Before",
"not_created_on_or_before": "Not Created on or Before",
"created_within": "Created Within",
- "created_earlier_than": "Created Earlier Than",
+ "created_earlier_than": "Created earlier than",
"day_s": " Day(s)",
"week_s": " Week(s)",
"month_s": " Month(s)",
@@ -390,17 +391,17 @@
"week_s_ago": " Week(s) Ago",
"month_s_ago": " Month(s) Ago",
"year_s_ago": " Year(s) Ago",
- "url_contains": "URL Contains",
+ "url_contains": "URL contains",
"is_in_list": "Is In List",
"is_not_in_list": "Is not In List",
"has_tag": "Has Tag",
- "does_not_have_tag": "Does Not Have Tag",
- "full_text_search": "Full Text Search",
+ "does_not_have_tag": "Does not have tag",
+ "full_text_search": "Full text search",
"type_is": "Type is",
"type_is_not": "Type is not",
- "url_does_not_contain": "URL Does Not Contain",
- "is_from_feed": "Is from RSS Feed",
- "is_not_from_feed": "Is not from RSS Feed",
+ "url_does_not_contain": "URL does not contain",
+ "is_from_feed": "Is from RSS feed",
+ "is_not_from_feed": "Is not from RSS feed",
"and": "And",
"or": "Or"
},
@@ -411,18 +412,18 @@
},
"toasts": {
"bookmarks": {
- "deleted": "The bookmark has been deleted!",
- "refetch": "Re-fetch has been added to the queue!",
+ "deleted": "The bookmark has been deleted",
+ "refetch": "Re-fetch has been added to the queue",
"full_page_archive": "Full Page Archive creation has been triggered",
"delete_from_list": "The bookmark has been deleted from the list",
- "clipboard_copied": "Link has been added to your clipboard!",
- "updated": "The bookmark has been updated!"
+ "clipboard_copied": "Link has been added to your clipboard",
+ "updated": "The bookmark has been updated"
},
"lists": {
- "created": "List has been created!",
- "updated": "List has been updated!",
- "merged": "List has been merged!",
- "deleted": "List has been deleted!"
+ "created": "List has been created",
+ "updated": "List has been updated",
+ "merged": "List has been merged",
+ "deleted": "List has been deleted"
}
},
"dialogs": {
diff --git a/apps/web/lib/importBookmarkParser.ts b/apps/web/lib/importBookmarkParser.ts
index a97e4da9..2e354ffe 100644
--- a/apps/web/lib/importBookmarkParser.ts
+++ b/apps/web/lib/importBookmarkParser.ts
@@ -15,6 +15,8 @@ export interface ParsedBookmark {
tags: string[];
addDate?: number;
notes?: string;
+ archived?: boolean;
+ paths: string[][];
}
export async function parseNetscapeBookmarkFile(
@@ -41,11 +43,24 @@ export async function parseNetscapeBookmarkFile(
/* empty */
}
const url = $a.attr("href");
+
+ // Build folder path by traversing up the hierarchy
+ const path: string[] = [];
+ let current = $a.parent();
+ while (current && current.length > 0) {
+ const h3 = current.find("> h3").first();
+ if (h3.length > 0) {
+ path.unshift(h3.text());
+ }
+ current = current.parent();
+ }
+
return {
title: $a.text(),
content: url ? { type: BookmarkTypes.LINK as const, url } : undefined,
tags,
addDate: typeof addDate === "undefined" ? undefined : parseInt(addDate),
+ paths: [path],
};
})
.get();
@@ -64,6 +79,7 @@ export async function parsePocketBookmarkFile(
url: string;
time_added: string;
tags: string;
+ status?: string;
}[];
return records.map((record) => {
@@ -72,6 +88,8 @@ export async function parsePocketBookmarkFile(
content: { type: BookmarkTypes.LINK as const, url: record.url },
tags: record.tags.length > 0 ? record.tags.split("|") : [],
addDate: parseInt(record.time_added),
+ archived: record.status === "archive",
+ paths: [], // TODO
};
});
}
@@ -107,6 +125,8 @@ export async function parseKarakeepBookmarkFile(
tags: bookmark.tags,
addDate: bookmark.createdAt,
notes: bookmark.note ?? undefined,
+ archived: bookmark.archived,
+ paths: [], // TODO
};
});
}
@@ -121,6 +141,7 @@ export async function parseOmnivoreBookmarkFile(
url: z.string(),
labels: z.array(z.string()),
savedAt: z.coerce.date(),
+ state: z.string().optional(),
}),
);
@@ -137,6 +158,8 @@ export async function parseOmnivoreBookmarkFile(
content: { type: BookmarkTypes.LINK as const, url: bookmark.url },
tags: bookmark.labels,
addDate: bookmark.savedAt.getTime() / 1000,
+ archived: bookmark.state === "Archived",
+ paths: [],
};
});
}
@@ -173,6 +196,7 @@ export async function parseLinkwardenBookmarkFile(
content: { type: BookmarkTypes.LINK as const, url: bookmark.url },
tags: bookmark.tags.map((tag) => tag.name),
addDate: bookmark.createdAt.getTime() / 1000,
+ paths: [], // TODO
}));
});
}
@@ -213,6 +237,7 @@ export async function parseTabSessionManagerStateFile(
content: { type: BookmarkTypes.LINK as const, url: tab.url },
tags: [],
addDate: tab.lastAccessed,
+ paths: [], // Tab Session Manager doesn't have folders
})),
);
}
@@ -230,7 +255,8 @@ export function deduplicateBookmarks(
const existing = deduplicatedBookmarksMap.get(url)!;
// Merge tags
existing.tags = [...new Set([...existing.tags, ...bookmark.tags])];
- // Keep earliest date
+ // Merge paths
+ existing.paths = [...existing.paths, ...bookmark.paths];
const existingDate = existing.addDate ?? Infinity;
const newDate = bookmark.addDate ?? Infinity;
if (newDate < existingDate) {
@@ -242,6 +268,10 @@ export function deduplicateBookmarks(
} else if (bookmark.notes) {
existing.notes = bookmark.notes;
}
+ // For archived status, prefer archived if either is archived
+ if (bookmark.archived === true) {
+ existing.archived = true;
+ }
// Title: keep existing one for simplicity
} else {
deduplicatedBookmarksMap.set(url, bookmark);
diff --git a/apps/web/lib/userSettings.tsx b/apps/web/lib/userSettings.tsx
new file mode 100644
index 00000000..1590f727
--- /dev/null
+++ b/apps/web/lib/userSettings.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { createContext, useContext } from "react";
+
+import { ZUserSettings } from "@karakeep/shared/types/users";
+
+import { api } from "./trpc";
+
+export const UserSettingsContext = createContext<ZUserSettings>({
+ bookmarkClickAction: "open_original_link",
+ archiveDisplayBehaviour: "show",
+});
+
+export function UserSettingsContextProvider({
+ userSettings,
+ children,
+}: {
+ userSettings: ZUserSettings;
+ children: React.ReactNode;
+}) {
+ const { data } = api.users.settings.useQuery(undefined, {
+ initialData: userSettings,
+ });
+
+ return (
+ <UserSettingsContext.Provider value={data}>
+ {children}
+ </UserSettingsContext.Provider>
+ );
+}
+
+export function useUserSettings() {
+ return useContext(UserSettingsContext);
+}
diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs
index 3eadbf91..df864f22 100644
--- a/apps/web/next.config.mjs
+++ b/apps/web/next.config.mjs
@@ -34,7 +34,7 @@ const nextConfig = withPWA({
// Allows for specific methods accepted
{
key: "Access-Control-Allow-Methods",
- value: "GET, POST, PUT, DELETE, OPTIONS",
+ value: "GET, POST, PUT, PATCH, DELETE, OPTIONS",
},
// Allows for specific headers accepted (These are a few standard ones)
{
diff --git a/apps/web/package.json b/apps/web/package.json
index 34e4752a..7efb1830 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -21,6 +21,7 @@
"@emoji-mart/data": "^1.1.2",
"@emoji-mart/react": "^1.1.1",
"@hookform/resolvers": "^3.3.4",
+ "@karakeep/api": "workspace:^0.1.0",
"@karakeep/db": "workspace:^0.1.0",
"@karakeep/shared": "workspace:^0.1.0",
"@karakeep/shared-react": "workspace:^0.1.0",
@@ -70,6 +71,7 @@
"next-i18next": "^15.3.1",
"next-pwa": "^5.6.0",
"next-themes": "^0.3.0",
+ "nuqs": "^2.4.3",
"prettier": "^3.4.2",
"react": "^18.3.1",
"react-day-picker": "8.10.1",