Skip to content
This repository was archived by the owner on Jul 7, 2026. It is now read-only.

Commit 93fd72a

Browse files
ChanMeng666claude
andcommitted
feat: bookmarks (收藏) distinct from likes
Likes were doing double duty as bookmarks (the profile even labelled the likes list "收藏"). Split the concepts: ❤️ 点赞 stays an appreciation signal (drives popular sorting + 获赞 stats), 🔖 收藏 is a private save-for-later marker. - Schema: Bookmark model mirroring Like (unique [userId, storyId] + indexes); additive migration 20260622130000. - bookmark.ts: toggleBookmark + getBookmarkedStories (no notification — private). - StoryReader: 收藏 button beside 点赞, with initialBookmarked wired through the story page (parallel like+bookmark lookup). - Profile: relabel the likes tab 收藏 → 点赞, add a real 收藏 tab backed by bookmarks (now 4 tabs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e8a501d commit 93fd72a

7 files changed

Lines changed: 219 additions & 10 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- CreateTable
2+
CREATE TABLE "Bookmark" (
3+
"id" TEXT NOT NULL,
4+
"userId" TEXT NOT NULL,
5+
"storyId" TEXT NOT NULL,
6+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
7+
8+
CONSTRAINT "Bookmark_pkey" PRIMARY KEY ("id")
9+
);
10+
11+
-- CreateIndex
12+
CREATE INDEX "Bookmark_storyId_idx" ON "Bookmark"("storyId");
13+
14+
-- CreateIndex
15+
CREATE INDEX "Bookmark_userId_idx" ON "Bookmark"("userId");
16+
17+
-- CreateIndex
18+
CREATE UNIQUE INDEX "Bookmark_userId_storyId_key" ON "Bookmark"("userId", "storyId");
19+
20+
-- AddForeignKey
21+
ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
22+
23+
-- AddForeignKey
24+
ALTER TABLE "Bookmark" ADD CONSTRAINT "Bookmark_storyId_fkey" FOREIGN KEY ("storyId") REFERENCES "Story"("id") ON DELETE CASCADE ON UPDATE CASCADE;

prisma/schema.prisma

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ model User {
4343
payments Payment[]
4444
proposedBranches StoryBranch[] @relation("branchProposer")
4545
branchLikes BranchLike[]
46+
bookmarks Bookmark[]
4647
4748
@@index([stackAuthId])
4849
@@index([email])
@@ -103,6 +104,7 @@ model Story {
103104
chapters Chapter[]
104105
characters StoryCharacter[]
105106
likes Like[]
107+
bookmarks Bookmark[]
106108
comments Comment[]
107109
generations Generation[]
108110
branches StoryBranch[]
@@ -270,6 +272,22 @@ model Like {
270272
@@index([userId])
271273
}
272274

275+
// Save-for-later / reading list. Distinct from Like: Like is an appreciation
276+
// signal (drives "popular" sorting + author 获赞 stats), while a Bookmark is a
277+
// private "I want to come back to this" marker. Same shape as Like.
278+
model Bookmark {
279+
id String @id @default(cuid())
280+
userId String
281+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
282+
storyId String
283+
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
284+
createdAt DateTime @default(now())
285+
286+
@@unique([userId, storyId])
287+
@@index([storyId])
288+
@@index([userId])
289+
}
290+
273291
model Comment {
274292
id String @id @default(cuid())
275293
content String @db.Text

src/app/(main)/(protected)/profile/page.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ export default async function ProfilePage() {
3333
}
3434

3535
// Fetch all data server-side in a single pass (no race conditions)
36-
const [profile, stories, likedStories, statsRaw] = await Promise.all([
36+
const [profile, stories, likedStories, bookmarkedStories, statsRaw] = await Promise.all([
3737
prisma.user.findUnique({
3838
where: { id: dbUser.id },
3939
include: {
@@ -83,6 +83,26 @@ export default async function ProfilePage() {
8383
},
8484
},
8585
}),
86+
prisma.bookmark.findMany({
87+
where: { userId: dbUser.id },
88+
orderBy: { createdAt: "desc" },
89+
include: {
90+
story: {
91+
include: {
92+
author: {
93+
select: { id: true, username: true, avatarUrl: true },
94+
},
95+
_count: {
96+
select: {
97+
likes: true,
98+
comments: true,
99+
chapters: true,
100+
},
101+
},
102+
},
103+
},
104+
},
105+
}),
86106
prisma.$transaction([
87107
prisma.story.count({ where: { authorId: dbUser.id } }),
88108
prisma.story.count({ where: { authorId: dbUser.id, status: "PUBLISHED" } }),
@@ -113,6 +133,9 @@ export default async function ProfilePage() {
113133
const serializedLikedStories = JSON.parse(
114134
JSON.stringify(likedStories.map((l) => l.story))
115135
);
136+
const serializedBookmarkedStories = JSON.parse(
137+
JSON.stringify(bookmarkedStories.map((b) => b.story))
138+
);
116139

117140
return (
118141
<Suspense
@@ -134,6 +157,7 @@ export default async function ProfilePage() {
134157
profile={serializedProfile}
135158
stories={serializedStories}
136159
likedStories={serializedLikedStories}
160+
bookmarkedStories={serializedBookmarkedStories}
137161
stats={stats}
138162
/>
139163
</Suspense>

src/app/(main)/(protected)/profile/profile-client.tsx

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { useSearchParams } from "next/navigation";
66
import {
77
BookOpen,
88
Heart,
9+
Bookmark,
910
MessageSquare,
1011
FileText,
1112
Trash2,
@@ -103,6 +104,7 @@ interface ProfileClientProps {
103104
profile: UserProfile;
104105
stories: Story[];
105106
likedStories: Story[];
107+
bookmarkedStories: Story[];
106108
stats: UserStats;
107109
}
108110

@@ -154,6 +156,7 @@ export function ProfileClient({
154156
profile: initialProfile,
155157
stories: initialStories,
156158
likedStories,
159+
bookmarkedStories,
157160
stats,
158161
}: ProfileClientProps) {
159162
const searchParams = useSearchParams();
@@ -163,7 +166,9 @@ export function ProfileClient({
163166

164167
const tabParam = searchParams.get("tab");
165168
const initialTab =
166-
tabParam && ["stories", "drafts", "liked"].includes(tabParam) ? tabParam : "stories";
169+
tabParam && ["stories", "drafts", "liked", "bookmarked"].includes(tabParam)
170+
? tabParam
171+
: "stories";
167172
const [activeTab, setActiveTab] = useState(initialTab);
168173

169174
const publishedStories = stories.filter((s) => s.status !== "DRAFT");
@@ -662,7 +667,7 @@ export function ProfileClient({
662667
{/* Main Content */}
663668
<div>
664669
<Tabs value={activeTab} onValueChange={setActiveTab}>
665-
<TabsList className="grid w-full grid-cols-3 max-w-md">
670+
<TabsList className="grid w-full grid-cols-4 max-w-xl">
666671
<TabsTrigger value="stories" className="gap-1.5">
667672
<BookOpen className="size-4" />
668673
作品 ({publishedStories.length})
@@ -673,7 +678,11 @@ export function ProfileClient({
673678
</TabsTrigger>
674679
<TabsTrigger value="liked" className="gap-1.5">
675680
<Heart className="size-4" />
676-
收藏 ({likedStories.length})
681+
点赞 ({likedStories.length})
682+
</TabsTrigger>
683+
<TabsTrigger value="bookmarked" className="gap-1.5">
684+
<Bookmark className="size-4" />
685+
收藏 ({bookmarkedStories.length})
677686
</TabsTrigger>
678687
</TabsList>
679688

@@ -740,10 +749,10 @@ export function ProfileClient({
740749
<Heart className="size-8 text-accent" />
741750
</div>
742751
<h3 className="text-lg font-semibold text-foreground mb-2">
743-
还没有收藏的故事
752+
还没有点赞过的故事
744753
</h3>
745754
<p className="text-muted-foreground">
746-
浏览故事并点击收藏,你喜欢的故事会出现在这里
755+
浏览故事并点个赞,你赞过的故事会出现在这里
747756
</p>
748757
</CardContent>
749758
</Card>
@@ -758,6 +767,33 @@ export function ProfileClient({
758767
</div>
759768
)}
760769
</TabsContent>
770+
771+
<TabsContent value="bookmarked" className="mt-6">
772+
{bookmarkedStories.length === 0 ? (
773+
<Card>
774+
<CardContent className="py-12 text-center">
775+
<div className="flex items-center justify-center size-16 rounded-2xl bg-primary/10 mx-auto mb-4">
776+
<Bookmark className="size-8 text-primary" />
777+
</div>
778+
<h3 className="text-lg font-semibold text-foreground mb-2">
779+
还没有收藏的故事
780+
</h3>
781+
<p className="text-muted-foreground">
782+
在阅读页点击「收藏」,把想稍后再读的故事存到这里
783+
</p>
784+
</CardContent>
785+
</Card>
786+
) : (
787+
<div className="grid gap-4 sm:grid-cols-2">
788+
{bookmarkedStories.map((story) => (
789+
<StoryCard
790+
key={story.id}
791+
story={toCardData(story, fallbackAuthor)}
792+
/>
793+
))}
794+
</div>
795+
)}
796+
</TabsContent>
761797
</Tabs>
762798
</div>
763799
</div>

src/app/(main)/story/[id]/page.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ export default async function StoryPage({ params }: StoryPageProps) {
112112
}
113113

114114
let initialLiked = false;
115+
let initialBookmarked = false;
115116
let currentUserId: string | null = null;
116117
try {
117118
const stackUser = await stackServerApp.getUser();
@@ -122,10 +123,16 @@ export default async function StoryPage({ params }: StoryPageProps) {
122123
});
123124
if (dbUser) {
124125
currentUserId = dbUser.id;
125-
const like = await prisma.like.findUnique({
126-
where: { userId_storyId: { userId: dbUser.id, storyId: id } },
127-
});
126+
const [like, bookmark] = await Promise.all([
127+
prisma.like.findUnique({
128+
where: { userId_storyId: { userId: dbUser.id, storyId: id } },
129+
}),
130+
prisma.bookmark.findUnique({
131+
where: { userId_storyId: { userId: dbUser.id, storyId: id } },
132+
}),
133+
]);
128134
initialLiked = !!like;
135+
initialBookmarked = !!bookmark;
129136
}
130137
}
131138
} catch {
@@ -149,6 +156,7 @@ export default async function StoryPage({ params }: StoryPageProps) {
149156
firstChapterContent={firstChapterContent}
150157
initialLikeCount={story._count.likes}
151158
initialLiked={initialLiked}
159+
initialBookmarked={initialBookmarked}
152160
commentCount={story._count.comments}
153161
currentUserId={currentUserId}
154162
isOwner={isOwner}

src/components/story/StoryReader.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"use client";
22

33
import Link from "next/link";
4-
import { Heart, BookOpen, Calendar, User, Tag, MessageSquare, Pencil, Sparkles, Eye } from "lucide-react";
4+
import { Heart, BookOpen, Calendar, User, Tag, MessageSquare, Pencil, Sparkles, Eye, Bookmark, BookmarkCheck } from "lucide-react";
55
import { Button } from "@/components/ui/button";
66
import { Badge } from "@/components/ui/badge";
77
import { Separator } from "@/components/ui/separator";
88
import { useState } from "react";
99
import { toggleLike } from "@/lib/actions/story";
10+
import { toggleBookmark } from "@/lib/actions/bookmark";
1011
import { toast } from "sonner";
1112
import { formatError } from "@/lib/format-error";
1213
import { CommentsSection } from "./CommentsSection";
@@ -62,6 +63,7 @@ interface StoryReaderProps {
6263
firstChapterContent?: string | null;
6364
initialLikeCount?: number;
6465
initialLiked?: boolean;
66+
initialBookmarked?: boolean;
6567
commentCount?: number;
6668
currentUserId?: string | null;
6769
isOwner?: boolean;
@@ -73,13 +75,16 @@ export function StoryReader({
7375
firstChapterContent = null,
7476
initialLikeCount = 0,
7577
initialLiked = false,
78+
initialBookmarked = false,
7679
commentCount = 0,
7780
currentUserId = null,
7881
isOwner = false,
7982
}: StoryReaderProps) {
8083
const [liked, setLiked] = useState(initialLiked);
8184
const [likeCount, setLikeCount] = useState(initialLikeCount);
8285
const [liking, setLiking] = useState(false);
86+
const [bookmarked, setBookmarked] = useState(initialBookmarked);
87+
const [bookmarking, setBookmarking] = useState(false);
8388
const [continueOpen, setContinueOpen] = useState(false);
8489

8590
const { fontSize, lineHeight } = useReadingPrefs();
@@ -109,6 +114,27 @@ export function StoryReader({
109114
}
110115
}
111116

117+
async function handleBookmark() {
118+
if (bookmarking) return;
119+
if (!currentUserId) {
120+
toast.error("请先登录后再收藏");
121+
return;
122+
}
123+
setBookmarking(true);
124+
const wasBookmarked = bookmarked;
125+
setBookmarked(!wasBookmarked);
126+
try {
127+
const res = await toggleBookmark(story.id);
128+
setBookmarked(res.bookmarked);
129+
toast.success(res.bookmarked ? "已收藏" : "已取消收藏");
130+
} catch (err) {
131+
setBookmarked(wasBookmarked);
132+
toast.error(formatError(err, "收藏失败"));
133+
} finally {
134+
setBookmarking(false);
135+
}
136+
}
137+
112138
return (
113139
<article
114140
className="max-w-3xl mx-auto px-3 sm:px-4 py-6 sm:py-10"
@@ -235,6 +261,21 @@ export function StoryReader({
235261
<Heart className={`size-3.5 ${liked ? "fill-current" : ""}`} />
236262
{likeCount}
237263
</Button>
264+
<Button
265+
variant={bookmarked ? "default" : "outline"}
266+
size="sm"
267+
className="gap-1.5"
268+
onClick={handleBookmark}
269+
disabled={bookmarking}
270+
aria-label={bookmarked ? "取消收藏" : "收藏"}
271+
>
272+
{bookmarked ? (
273+
<BookmarkCheck className="size-3.5" />
274+
) : (
275+
<Bookmark className="size-3.5" />
276+
)}
277+
{bookmarked ? "已收藏" : "收藏"}
278+
</Button>
238279
<a
239280
href="#comments"
240281
className="inline-flex items-center justify-center gap-1.5 h-8 px-3 text-sm border border-border rounded-md text-muted-foreground hover:text-foreground hover:bg-accent/10 transition-colors"

0 commit comments

Comments
 (0)