Skip to content
Merged
Show file tree
Hide file tree
Changes from 57 commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
678e6b2
fix(public): polish shared list experience
absolutepraya Aug 14, 2026
e83abc8
fix(public): use standard owner lookup
absolutepraya Aug 14, 2026
25e92d5
fix(public): serve owner avatars with signed URLs
absolutepraya Aug 14, 2026
47664ea
fix(sharing): avoid textbox semantics for URLs
absolutepraya Aug 14, 2026
64b6da5
fix(sharing): keep URL displays non-interactive
absolutepraya Aug 14, 2026
79a1697
test(sharing): cover non-interactive URL display
absolutepraya Aug 14, 2026
836582e
refactor(public-list): keep bookmark grid changes focused
absolutepraya Aug 14, 2026
f00435f
refine(public-list): match the app list header
absolutepraya Aug 14, 2026
0dfeca9
refine(public-list): clarify unavailable list state
absolutepraya Aug 14, 2026
df2133b
refine(sharing): keep URL display minimal
absolutepraya Aug 14, 2026
db21c05
refactor(public-list): scope avatar lookup to list contents
absolutepraya Aug 14, 2026
032fbd4
style(sharing): format URL display
absolutepraya Aug 14, 2026
3383fbc
style(sharing): align URL display with input sizing
absolutepraya Aug 14, 2026
9108278
fix(public-list): include avatar in grid query data type
absolutepraya Aug 14, 2026
6728861
Merge main into fix/public-list-experience
absolutepraya Aug 14, 2026
2b2b529
test: cover share dialog description nesting
absolutepraya Aug 14, 2026
a68c4a4
fix: avoid invalid share dialog nesting
absolutepraya Aug 14, 2026
3dcc2f0
chore: regenerate SQLite lockfile on Node 24
absolutepraya Aug 14, 2026
b9bff11
chore: diagnose TRPC SQLite cleanup
absolutepraya Aug 14, 2026
da64648
chore: refine TRPC cleanup diagnostic
absolutepraya Aug 14, 2026
ea31791
chore: finalize TRPC SQLite cleanup
absolutepraya Aug 14, 2026
8ecfe07
fix: close TRPC test databases
github-actions[bot] Aug 14, 2026
fcb14f3
test: cover TRPC database cleanup
absolutepraya Aug 14, 2026
8b99c98
test: remove isolated TRPC cleanup worker
absolutepraya Aug 14, 2026
a6d3ace
chore: diagnose TRPC single-fork tests
absolutepraya Aug 14, 2026
7318fd7
fix: run TRPC tests in a single fork
absolutepraya Aug 14, 2026
71a50f1
chore: remove TRPC diagnostic workflow
absolutepraya Aug 14, 2026
38172d2
fix: constrain share list URLs
absolutepraya Aug 14, 2026
4fc49f3
fix: align share URL copy buttons
absolutepraya Aug 14, 2026
e7365e2
fix: stack collaborator invite controls on mobile
absolutepraya Aug 14, 2026
a14960f
chore: diagnose TRPC module DB cleanup
absolutepraya Aug 14, 2026
989d96f
fix(ci): run TRPC tests in one worker thread
absolutepraya Aug 14, 2026
52ab96c
fix: close TRPC module database after tests
absolutepraya Aug 14, 2026
839ae01
fix: register TRPC database teardown
absolutepraya Aug 14, 2026
50ca175
chore: remove TRPC database diagnostic workflow
absolutepraya Aug 14, 2026
894fddb
fix(ci): pin Node runtime for TRPC tests
absolutepraya Aug 14, 2026
6f4e566
fix(ci): use stable Node runtime for SQLite tests
absolutepraya Aug 14, 2026
2cd6320
fix(ci): upgrade better-sqlite3 for Node 24
absolutepraya Aug 14, 2026
7b1eb1b
fix(ci): upgrade web better-sqlite3 for Node 24
absolutepraya Aug 14, 2026
e180658
fix(ci): revert incompatible SQLite upgrade
absolutepraya Aug 14, 2026
71fd816
fix: address review feedback
absolutepraya Aug 14, 2026
0bd5be6
fix(ci): serialize SQLite test workers
absolutepraya Aug 14, 2026
7e396cf
fix(ci): avoid SQLite worker teardown
absolutepraya Aug 14, 2026
4507056
fix(ci): run SQLite tests on Node 22
absolutepraya Aug 14, 2026
3320043
chore: keep public list fix scoped
absolutepraya Aug 14, 2026
f40fe98
fix(public): avoid asset model import for avatar URLs
absolutepraya Aug 14, 2026
28e748e
test(trpc): close in-memory sqlite databases
absolutepraya Aug 14, 2026
7db8439
test(trpc): cover sqlite test cleanup
absolutepraya Aug 14, 2026
d5742cd
fix: pin Docker to Node 24.18.1
absolutepraya Aug 14, 2026
f7d8208
fix: encode public asset tokens
absolutepraya Aug 14, 2026
326940d
fix: close in-memory database on setup failure
absolutepraya Aug 14, 2026
417a7ef
fix: label collaborator invitation role selector
absolutepraya Aug 14, 2026
adc894b
docs: document SQLite test helpers
absolutepraya Aug 14, 2026
3f96540
docs: document public asset URL helpers
absolutepraya Aug 14, 2026
6066b8d
docs: document Vitest SQLite cleanup wrapper
absolutepraya Aug 14, 2026
2e412ca
Merge pull request #33 from absolutepraya/fix/trpc-sqlite-test-cleanup
absolutepraya Aug 14, 2026
003ee69
fix: pin CI to Node 24.18.1
absolutepraya Aug 14, 2026
bd28215
Merge main into fix/public-list-experience
absolutepraya Aug 14, 2026
811907d
fix(ci): run test suite on Node 22
absolutepraya Aug 15, 2026
51a19ae
docs: note CI test runtime workaround
absolutepraya Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
24
24.18.1
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ Start here:
## Local setup

This fork uses:
- Node 24 (`.nvmrc`)
- Node 24.18.1 (`.nvmrc`; temporarily pinned to avoid the Node 24.19 native-addon cleanup regression)
- `pnpm@11.2.1` via corepack
- root `.env` symlinked into `apps/web`, `apps/workers`, and `packages/db`

Expand Down
6 changes: 4 additions & 2 deletions apps/web/app/public/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ export default function PublicLayout({
children: React.ReactNode;
}) {
return (
<div className="h-screen items-center justify-center overflow-y-auto bg-muted">
<main className="container mt-3">{children}</main>
<div className="min-h-screen bg-background text-foreground">
<main className="mx-auto w-full max-w-7xl px-3 py-4 sm:px-6 sm:py-6 lg:px-8">
{children}
</main>
</div>
);
}
35 changes: 26 additions & 9 deletions apps/web/app/public/lists/[listId]/not-found.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
import { EmptyState } from "@/components/shared/EmptyState";
import { SearchX } from "lucide-react";
import Link from "next/link";
import KarakeepLogo from "@/components/KarakeepIcon";
import { Button } from "@/components/ui/button";
import { Home, SearchX } from "lucide-react";

export default function PublicListPageNotFound() {
return (
<div className="mx-auto flex max-w-xl flex-1 items-center justify-center px-4 py-16">
<EmptyState
icon={<SearchX strokeWidth={1.75} />}
title="List not found"
titleAs="h1"
description="The list you’re looking for doesn’t exist, is no longer public, or may have been removed."
/>
<div className="flex min-h-[calc(100vh-2rem)] items-center justify-center py-8 sm:min-h-[calc(100vh-3rem)]">
<div className="w-full max-w-md text-center">
<Link href="/" aria-label="Karakeep home" className="inline-flex">
<KarakeepLogo height={32} />
</Link>
<div className="mx-auto mt-8 flex size-14 items-center justify-center rounded-2xl bg-muted text-muted-foreground">
<SearchX className="size-7" strokeWidth={1.75} />
</div>
<h1 className="mt-5 text-2xl font-semibold tracking-tight">
This list isn’t available
</h1>
<p className="mx-auto mt-2 max-w-sm text-sm leading-6 text-muted-foreground sm:text-base">
This list may have been made private, deleted, or the link may be
incorrect.
</p>
<Button asChild className="mt-6">
<Link href="/">
<Home className="size-4" />
Go to Karakeep
</Link>
</Button>
</div>
</div>
);
}
34 changes: 14 additions & 20 deletions apps/web/app/public/lists/[listId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,28 +47,22 @@ export default async function PublicListPage(props: {
await api.publicBookmarks.getPublicBookmarksInList({
listId: params.listId,
});
const publicList = {
id: params.listId,
name: list.name,
description: list.description,
icon: list.icon,
numItems: list.numItems,
ownerName: list.ownerName,
ownerImage: list.ownerImage,
};

return (
<div className="mx-auto w-full max-w-7xl space-y-5">
<PublicListHeader
list={{
id: params.listId,
name: list.name,
description: list.description,
icon: list.icon,
numItems: list.numItems,
ownerName: list.ownerName,
}}
/>
<div className="mx-auto w-full space-y-4 sm:space-y-5">
<PublicListHeader list={publicList} />
{list.numItems > 0 ? (
<PublicBookmarkGrid
list={{
id: params.listId,
name: list.name,
description: list.description,
icon: list.icon,
numItems: list.numItems,
ownerName: list.ownerName,
}}
list={publicList}
bookmarks={bookmarks}
nextCursor={nextCursor}
/>
Expand All @@ -77,7 +71,7 @@ export default async function PublicListPage(props: {
icon={<BookmarkIcon className="size-7" />}
title="This public list is empty"
description="The list has been shared, but it does not have any bookmarks in it yet."
className="shadow-xs rounded-2xl border-border/70 bg-card/90"
className="rounded-2xl border-border/80 bg-card"
/>
)}
</div>
Expand Down
70 changes: 40 additions & 30 deletions apps/web/components/dashboard/lists/ManageCollaboratorsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,8 @@ export function ManageCollaboratorsModal({
{!readOnly && (
<div className="space-y-3">
<Label>{t("lists.collaborators.add")}</Label>
<div className="flex gap-2">
<div className="flex-1">
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_8rem_auto]">
<div className="min-w-0">
<Input
type="email"
placeholder={t("lists.collaborators.enter_email")}
Expand All @@ -224,34 +224,44 @@ export function ManageCollaboratorsModal({
}}
/>
</div>
<Select
value={newCollaboratorRole}
onValueChange={(value) =>
setNewCollaboratorRole(value as "viewer" | "editor")
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">
{t("lists.collaborators.viewer")}
</SelectItem>
<SelectItem value="editor">
{t("lists.collaborators.editor")}
</SelectItem>
</SelectContent>
</Select>
<Button
onClick={handleAddCollaborator}
disabled={addCollaborator.isPending}
>
{addCollaborator.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<UserPlus className="h-4 w-4" />
)}
</Button>
<div className="grid grid-cols-2 gap-2 sm:contents">
<Select
value={newCollaboratorRole}
onValueChange={(value) =>
setNewCollaboratorRole(value as "viewer" | "editor")
}
>
<SelectTrigger
className="h-10 w-full sm:w-32"
aria-label={`${t("lists.collaborators.add")} ${t("common.role")}`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">
{t("lists.collaborators.viewer")}
</SelectItem>
<SelectItem value="editor">
{t("lists.collaborators.editor")}
</SelectItem>
</SelectContent>
</Select>
<Button
className="h-10 w-full gap-2 sm:w-auto"
onClick={handleAddCollaborator}
disabled={addCollaborator.isPending}
aria-label={t("lists.collaborators.add")}
>
{addCollaborator.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<UserPlus className="h-4 w-4" />
)}
<span className="sm:hidden">
{t("lists.collaborators.add")}
</span>
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground">
<strong>{t("lists.collaborators.viewer")}:</strong>{" "}
Expand Down
28 changes: 13 additions & 15 deletions apps/web/components/dashboard/lists/PublicListLink.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"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";
Expand All @@ -10,6 +9,8 @@ import { useTranslation } from "react-i18next";
import { useEditBookmarkList } from "@karakeep/shared-react/hooks/lists";
import { ZBookmarkList } from "@karakeep/shared/types/lists";

import { UrlDisplay } from "./UrlDisplay";

export default function PublicListLink({ list }: { list: ZBookmarkList }) {
const { t } = useTranslation();
const clientConfig = useClientConfig();
Expand Down Expand Up @@ -46,21 +47,18 @@ export default function PublicListLink({ list }: { list: ZBookmarkList }) {

{/* 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 className="space-y-3">
<Label className="text-sm font-medium">
{t("lists.public_list.share_link")}
</Label>
<div className="flex min-w-0 items-center space-x-2">
<UrlDisplay
value={publicListUrl}
label={t("lists.public_list.share_link")}
/>
<CopyBtnV2 className="h-10" getStringToCopy={() => publicListUrl} />
</div>
</>
</div>
)}
</>
);
Expand Down
11 changes: 6 additions & 5 deletions apps/web/components/dashboard/lists/RssLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
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";
Expand All @@ -13,6 +12,8 @@ import { useTranslation } from "react-i18next";

import { useTRPC } from "@karakeep/shared-react/trpc";

import { UrlDisplay } from "./UrlDisplay";

export default function RssLink({ listId }: { listId: string }) {
const api = useTRPC();
const { t } = useTranslation();
Expand Down Expand Up @@ -46,7 +47,7 @@ export default function RssLink({ listId }: { listId: string }) {
return null;
}
return `${clientConfig.publicApiUrl}/v1/rss/lists/${listId}?token=${rssToken.token}`;
}, [rssToken]);
}, [clientConfig.publicApiUrl, listId, rssToken]);

const rssEnabled = rssUrl !== null;

Expand Down Expand Up @@ -82,9 +83,9 @@ export default function RssLink({ listId }: { listId: string }) {
<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} />
<div className="flex min-w-0 items-center space-x-2">
<UrlDisplay value={rssUrl} label={t("lists.rss.feed_url")} />
<CopyBtnV2 className="h-10" getStringToCopy={() => rssUrl} />
<Button
variant="outline"
size="sm"
Expand Down
44 changes: 44 additions & 0 deletions apps/web/components/dashboard/lists/ShareListModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// @vitest-environment jsdom

import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";

vi.mock("@/lib/i18n/client", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));

vi.mock("./PublicListLink", () => ({
default: () => <div data-testid="public-list-link">Public list settings</div>,
}));

vi.mock("./RssLink", () => ({
default: () => <div data-testid="rss-link">RSS settings</div>,
}));

import { ShareListModal } from "./ShareListModal";

describe("ShareListModal", () => {
it("keeps interactive sharing controls outside the dialog description", () => {
render(
<ShareListModal
open
setOpen={() => undefined}
list={{ id: "list-id" } as never}
/>,
);

const dialog = screen.getByRole("dialog");
const descriptionId = dialog.getAttribute("aria-describedby");
expect(descriptionId).toBeTruthy();

const description = document.getElementById(descriptionId!);
expect(description?.tagName).toBe("P");
expect(description?.querySelector("div")).toBeNull();

expect(screen.getByTestId("public-list-link")).toBeTruthy();
expect(screen.getByTestId("rss-link")).toBeTruthy();
});
});
9 changes: 6 additions & 3 deletions apps/web/components/dashboard/lists/ShareListModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useState } from "react";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
Expand Down Expand Up @@ -52,11 +52,14 @@ export function ShareListModal({
<ResponsiveDialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>{t("lists.share_list")}</DialogTitle>
<DialogDescription className="sr-only">
{t("lists.public_list.description")} {t("lists.rss.description")}
</DialogDescription>
</DialogHeader>
<DialogDescription className="mt-4 space-y-6">
<div className="mt-4 min-w-0 space-y-6">
<PublicListLink list={list} />
<RssLink listId={list.id} />
</DialogDescription>
</div>
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Expand Down
36 changes: 36 additions & 0 deletions apps/web/components/dashboard/lists/UrlDisplay.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// @vitest-environment jsdom

import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { UrlDisplay } from "./UrlDisplay";

describe("UrlDisplay", () => {
it.each([
{
label: "Share Link",
value: "https://example.com/public/lists/list-id",
},
{
label: "RSS Feed URL",
value: "https://api.example.com/v1/rss/lists/list-id?token=rss-token",
},
])("renders $label without textbox semantics", ({ label, value }) => {
render(<UrlDisplay value={value} label={label} />);

const display = screen.getByRole("group", { name: `${label}: ${value}` });
expect(display.tagName).toBe("DIV");
expect(screen.queryByRole("textbox")).toBeNull();
expect(display.getAttribute("contenteditable")).toBeNull();
expect(display.getAttribute("tabindex")).toBeNull();
expect(display.textContent).toBe(value);
expect(display.classList.contains("min-w-0")).toBe(true);
expect(display.classList.contains("flex-1")).toBe(true);
expect(display.firstElementChild?.classList.contains("min-w-0")).toBe(true);
expect(display.firstElementChild?.classList.contains("flex-1")).toBe(true);
expect(display.firstElementChild?.classList.contains("truncate")).toBe(
true,
);
});
});
Loading