Skip to content

Commit b212382

Browse files
authored
feat: add shelf routes and public shelf reading (#6)
1 parent d57ed76 commit b212382

File tree

18 files changed

+1183
-0
lines changed

18 files changed

+1183
-0
lines changed

app/(protected)/me/page.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import Link from "next/link";
2+
13
import { UserAvatar } from "@/components/auth/user-avatar";
24
import { Badge } from "@/components/ui/badge";
5+
import { buttonStyles } from "@/components/ui/button";
36
import { requireCurrentUser } from "@/lib/auth/server";
47
import {
58
Card,
@@ -8,6 +11,7 @@ import {
811
CardHeader,
912
CardTitle,
1013
} from "@/components/ui/card";
14+
import { createMyShelvesHref } from "@/lib/shelves/view-paths";
1115

1216
function fallbackText(value: string | null | undefined, fallback: string) {
1317
const normalized = value?.trim();
@@ -60,6 +64,24 @@ export default async function MePage() {
6064
</code>
6165
</CardContent>
6266
</Card>
67+
68+
<Card>
69+
<CardHeader>
70+
<CardTitle className="text-lg">Shelves</CardTitle>
71+
<CardDescription>Personal reading lists</CardDescription>
72+
</CardHeader>
73+
<CardContent className="space-y-4">
74+
<p className="text-sm leading-6 text-(--muted)">
75+
Create public or private shelves to organize books outside your clubs.
76+
</p>
77+
<Link
78+
href={createMyShelvesHref()}
79+
className={buttonStyles({ variant: "secondary" })}
80+
>
81+
Open my shelves
82+
</Link>
83+
</CardContent>
84+
</Card>
6385
</div>
6486
</div>
6587
);
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { notFound } from "next/navigation";
2+
import Link from "next/link";
3+
4+
import {
5+
updateShelfAction,
6+
} from "@/app/(protected)/me/shelves/actions";
7+
import { DeleteShelfButton } from "@/components/shelves/delete-shelf-button";
8+
import { ShelfDetail } from "@/components/shelves/shelf-detail";
9+
import { ShelfForm } from "@/components/shelves/shelf-form";
10+
import { buttonStyles } from "@/components/ui/button";
11+
import {
12+
Card,
13+
CardContent,
14+
CardHeader,
15+
CardTitle,
16+
} from "@/components/ui/card";
17+
import { requireCurrentUser } from "@/lib/auth/server";
18+
import { loadOwnedShelfRouteAccess } from "@/lib/shelves/access";
19+
import {
20+
createMyShelfHref,
21+
createMyShelvesHref,
22+
createPublicShelfHref,
23+
} from "@/lib/shelves/view-paths";
24+
25+
type MyShelfDetailPageProps = {
26+
params: Promise<{ shelfId: string }>;
27+
searchParams: Promise<Record<string, string | string[] | undefined>>;
28+
};
29+
30+
function readMessage(value: string | string[] | undefined) {
31+
if (Array.isArray(value)) {
32+
return value[0] ?? null;
33+
}
34+
35+
return value ?? null;
36+
}
37+
38+
export default async function MyShelfDetailPage({
39+
params,
40+
searchParams,
41+
}: MyShelfDetailPageProps) {
42+
const [currentUser, paramsData, searchData] = await Promise.all([
43+
requireCurrentUser(),
44+
params,
45+
searchParams,
46+
]);
47+
const access = await loadOwnedShelfRouteAccess({
48+
currentUserId: currentUser.id,
49+
shelfId: paramsData.shelfId,
50+
});
51+
52+
if (access.status === "not_found") {
53+
notFound();
54+
}
55+
56+
const message = readMessage(searchData.message);
57+
const error = readMessage(searchData.error);
58+
const returnTo = createMyShelfHref(access.shelf.id);
59+
60+
return (
61+
<div className="space-y-6">
62+
<div className="flex flex-wrap items-center justify-between gap-3">
63+
<div className="space-y-2">
64+
<h1 className="text-3xl font-semibold sm:text-4xl">{access.shelf.name}</h1>
65+
<p className="text-(--muted)">
66+
Update shelf details or review how this shelf appears to public readers.
67+
</p>
68+
</div>
69+
70+
<Link
71+
href={createMyShelvesHref()}
72+
className={buttonStyles({ variant: "secondary" })}
73+
>
74+
Back to shelves
75+
</Link>
76+
</div>
77+
78+
{message ? (
79+
<p className="rounded-xl border border-[#b9d6cf] bg-[#eef9f5] px-4 py-3 text-sm text-[#125547]">
80+
{message}
81+
</p>
82+
) : null}
83+
{error ? (
84+
<p className="rounded-xl border border-[#d39e95] bg-[#fff2ef] px-4 py-3 text-sm text-[#7e1f14]">
85+
{error}
86+
</p>
87+
) : null}
88+
89+
<ShelfDetail shelf={access.shelf} mode="owner" />
90+
91+
<Card className="border-(--border)/90">
92+
<CardHeader>
93+
<CardTitle>Edit shelf</CardTitle>
94+
</CardHeader>
95+
<CardContent className="space-y-5">
96+
{access.shelf.isPublic ? (
97+
<Link
98+
href={createPublicShelfHref({
99+
userId: currentUser.id,
100+
shelfId: access.shelf.id,
101+
})}
102+
className={buttonStyles({ variant: "secondary" })}
103+
>
104+
Open public view
105+
</Link>
106+
) : null}
107+
108+
<ShelfForm
109+
action={updateShelfAction}
110+
submitLabel="Save shelf"
111+
shelfId={access.shelf.id}
112+
returnTo={returnTo}
113+
defaults={{
114+
name: access.shelf.name,
115+
description: access.shelf.description,
116+
isPublic: access.shelf.isPublic,
117+
}}
118+
/>
119+
120+
<DeleteShelfButton
121+
shelfId={access.shelf.id}
122+
shelfName={access.shelf.name}
123+
returnTo={returnTo}
124+
/>
125+
</CardContent>
126+
</Card>
127+
</div>
128+
);
129+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
"use server";
2+
3+
import { revalidatePath } from "next/cache";
4+
import { redirect } from "next/navigation";
5+
import { isRedirectError } from "next/dist/client/components/redirect-error";
6+
7+
import { requireCurrentUser } from "@/lib/auth/server";
8+
import { isShelfError } from "@/lib/shelves/errors";
9+
import {
10+
createShelf,
11+
deleteShelf,
12+
updateShelf,
13+
} from "@/lib/shelves/repository";
14+
import {
15+
parseSafeReturnTo,
16+
parseShelfDescription,
17+
parseShelfId,
18+
parseShelfIsPublic,
19+
parseShelfName,
20+
} from "@/lib/shelves/validation";
21+
import {
22+
createMyShelfHref,
23+
createMyShelvesHref,
24+
createNewShelfHref,
25+
createPublicShelfHref,
26+
} from "@/lib/shelves/view-paths";
27+
28+
const SHELF_ACTION_MESSAGES = {
29+
created: "Shelf created.",
30+
updated: "Shelf updated.",
31+
deleted: "Shelf deleted.",
32+
unexpectedError: "Something went wrong. Please try again.",
33+
} as const;
34+
35+
function appendMessage(pathname: string, key: string, value: string) {
36+
const url = new URL(pathname, "http://localhost");
37+
url.searchParams.set(key, value);
38+
return `${url.pathname}${url.search}${url.hash}`;
39+
}
40+
41+
function getErrorMessage(error: unknown) {
42+
if (isShelfError(error)) {
43+
return error.message;
44+
}
45+
46+
console.error(error);
47+
return SHELF_ACTION_MESSAGES.unexpectedError;
48+
}
49+
50+
function rethrowIfRedirect(error: unknown) {
51+
if (isRedirectError(error)) {
52+
throw error;
53+
}
54+
}
55+
56+
function revalidateShelfPaths(input: { userId: string; shelfId?: string }) {
57+
revalidatePath("/me");
58+
revalidatePath(createMyShelvesHref());
59+
revalidatePath(createNewShelfHref());
60+
61+
if (!input.shelfId) {
62+
return;
63+
}
64+
65+
revalidatePath(createMyShelfHref(input.shelfId));
66+
revalidatePath(
67+
createPublicShelfHref({
68+
userId: input.userId,
69+
shelfId: input.shelfId,
70+
}),
71+
);
72+
}
73+
74+
export async function createShelfAction(formData: FormData) {
75+
const currentUser = await requireCurrentUser();
76+
const name = parseShelfName(formData.get("name"));
77+
const description = parseShelfDescription(formData.get("description"));
78+
const isPublic = parseShelfIsPublic(formData.get("isPublic"));
79+
80+
try {
81+
const shelf = await createShelf({
82+
userId: currentUser.id,
83+
name,
84+
description,
85+
isPublic,
86+
});
87+
88+
revalidateShelfPaths({
89+
userId: currentUser.id,
90+
shelfId: shelf.id,
91+
});
92+
93+
redirect(
94+
appendMessage(
95+
createMyShelfHref(shelf.id),
96+
"message",
97+
SHELF_ACTION_MESSAGES.created,
98+
),
99+
);
100+
} catch (error) {
101+
rethrowIfRedirect(error);
102+
redirect(
103+
appendMessage(
104+
createNewShelfHref(),
105+
"error",
106+
getErrorMessage(error),
107+
),
108+
);
109+
}
110+
}
111+
112+
export async function updateShelfAction(formData: FormData) {
113+
const currentUser = await requireCurrentUser();
114+
const shelfId = parseShelfId(formData.get("shelfId"));
115+
const name = parseShelfName(formData.get("name"));
116+
const description = parseShelfDescription(formData.get("description"));
117+
const isPublic = parseShelfIsPublic(formData.get("isPublic"));
118+
const returnTo = parseSafeReturnTo(
119+
formData.get("returnTo"),
120+
createMyShelfHref(shelfId),
121+
);
122+
123+
try {
124+
await updateShelf({
125+
shelfId,
126+
userId: currentUser.id,
127+
name,
128+
description,
129+
isPublic,
130+
});
131+
132+
revalidateShelfPaths({
133+
userId: currentUser.id,
134+
shelfId,
135+
});
136+
137+
redirect(
138+
appendMessage(returnTo, "message", SHELF_ACTION_MESSAGES.updated),
139+
);
140+
} catch (error) {
141+
rethrowIfRedirect(error);
142+
redirect(appendMessage(returnTo, "error", getErrorMessage(error)));
143+
}
144+
}
145+
146+
export async function deleteShelfAction(formData: FormData) {
147+
const currentUser = await requireCurrentUser();
148+
const shelfId = parseShelfId(formData.get("shelfId"));
149+
const returnTo = parseSafeReturnTo(
150+
formData.get("returnTo"),
151+
createMyShelfHref(shelfId),
152+
);
153+
154+
try {
155+
revalidateShelfPaths({
156+
userId: currentUser.id,
157+
shelfId,
158+
});
159+
160+
await deleteShelf({
161+
shelfId,
162+
userId: currentUser.id,
163+
});
164+
165+
redirect(
166+
appendMessage(
167+
createMyShelvesHref(),
168+
"message",
169+
SHELF_ACTION_MESSAGES.deleted,
170+
),
171+
);
172+
} catch (error) {
173+
rethrowIfRedirect(error);
174+
redirect(appendMessage(returnTo, "error", getErrorMessage(error)));
175+
}
176+
}

0 commit comments

Comments
 (0)