diff options
Diffstat (limited to 'apps/web')
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're looking for doesn'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> = { + "&": "&", + "'": "'", + "`": "`", + '"': """, + "<": "<", + ">": ">", + }; + + 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", |
