Browse Source
- Implemented a new `favorites` table in the database schema to store user favorites. - Added API endpoint to toggle favorite status for cards. - Enhanced card listing to include favorited status for the current user. - Created service functions to manage favorites, including toggling and listing favorites. - Updated migrations to include the new `favorites` table and its associated indexes.as
21 changed files with 1694 additions and 419 deletions
@ -0,0 +1,95 @@ |
|||
import { useAuthSession } from "./useAuthSession"; |
|||
|
|||
/** |
|||
* Favorite management composable. |
|||
* Provides a reactive set of favorited card IDs and toggle function. |
|||
*/ |
|||
export function useFavorite() { |
|||
const { loggedIn } = useAuthSession() |
|||
const { $toast } = useNuxtApp() |
|||
|
|||
const favoritedCardIds = ref<Set<number>>(new Set()) |
|||
const loadingIds = ref<Set<number>>(new Set()) |
|||
|
|||
/** |
|||
* Check if a card is favorited. |
|||
*/ |
|||
function isFavorited(cardId: number): boolean { |
|||
return favoritedCardIds.value.has(cardId) |
|||
} |
|||
|
|||
/** |
|||
* Toggle favorite for a card. |
|||
* Optimistically updates local state. |
|||
*/ |
|||
async function toggle(cardId: number): Promise<boolean> { |
|||
if (!loggedIn.value) { |
|||
$toast.info('请先登录') |
|||
return false |
|||
} |
|||
if (loadingIds.value.has(cardId)) return isFavorited(cardId) |
|||
|
|||
const wasFavorited = favoritedCardIds.value.has(cardId) |
|||
// Optimistic update
|
|||
const next = new Set(favoritedCardIds.value) |
|||
if (wasFavorited) { |
|||
next.delete(cardId) |
|||
} else { |
|||
next.add(cardId) |
|||
} |
|||
favoritedCardIds.value = next |
|||
loadingIds.value = new Set(loadingIds.value).add(cardId) |
|||
|
|||
try { |
|||
const raw = await $fetch<{ code: number; data: { favorited: boolean } }>( |
|||
`/api/cards/${cardId}/favorite`, |
|||
{ method: 'POST' }, |
|||
) |
|||
if (raw.code !== 0 || !raw.data.favorited) { |
|||
// Server says unfavorited — ensure it's removed
|
|||
const corrected = new Set(favoritedCardIds.value) |
|||
corrected.delete(cardId) |
|||
favoritedCardIds.value = corrected |
|||
} |
|||
return raw.data.favorited |
|||
} catch { |
|||
// Revert optimistic update
|
|||
const reverted = new Set(favoritedCardIds.value) |
|||
if (wasFavorited) { |
|||
reverted.add(cardId) |
|||
} else { |
|||
reverted.delete(cardId) |
|||
} |
|||
favoritedCardIds.value = reverted |
|||
$toast.error('操作失败,请稍后重试') |
|||
return wasFavorited |
|||
} finally { |
|||
const next = new Set(loadingIds.value) |
|||
next.delete(cardId) |
|||
loadingIds.value = next |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Sync favorited status from server response (batch). |
|||
*/ |
|||
function syncFromCards(cards: Array<{ id: number; isFavorited?: boolean }>) { |
|||
const next = new Set(favoritedCardIds.value) |
|||
for (const card of cards) { |
|||
if (card.isFavorited) { |
|||
next.add(card.id) |
|||
} else { |
|||
next.delete(card.id) |
|||
} |
|||
} |
|||
favoritedCardIds.value = next |
|||
} |
|||
|
|||
return { |
|||
favoritedCardIds, |
|||
loadingIds, |
|||
isFavorited, |
|||
toggle, |
|||
syncFromCards, |
|||
} |
|||
} |
|||
@ -1,360 +0,0 @@ |
|||
<script setup lang="ts"> |
|||
interface CardItem { |
|||
id: number |
|||
type: 'text' | 'image' | 'image-text' | 'portfolio' | 'project' |
|||
image?: string |
|||
images?: string[] |
|||
title: string |
|||
description?: string |
|||
tags?: string[] |
|||
aspectRatio: number |
|||
} |
|||
|
|||
const masonryEl = ref<HTMLElement | null>(null) |
|||
|
|||
const allItems = ref<CardItem[]>([]) |
|||
const page = ref(1) |
|||
const hasMore = ref(true) |
|||
const loading = ref(false) |
|||
const initLoading = ref(true) |
|||
const sentinel = ref<HTMLElement | null>(null) |
|||
|
|||
const columnCount = ref(4) |
|||
const columns = ref<CardItem[][]>([[], [], [], []]) |
|||
const columnHeights = ref<number[]>([0, 0, 0, 0]) |
|||
|
|||
function getColumnWidth() { |
|||
const padding = 48 |
|||
const maxWidth = 1400 |
|||
const containerWidth = Math.min(window.innerWidth - padding, maxWidth) |
|||
const gap = 20 |
|||
return (containerWidth - gap * (columnCount.value - 1)) / columnCount.value |
|||
} |
|||
|
|||
function estimateCardHeight(item: CardItem) { |
|||
const colWidth = getColumnWidth() |
|||
if (item.type === 'text') { |
|||
const textWidth = colWidth - 48 |
|||
const charsPerLine = Math.max(1, Math.floor(textWidth / 13.5)) |
|||
const charCount = (item.description || '').length |
|||
const lines = Math.ceil(charCount / charsPerLine) |
|||
return 109 + lines * 24 |
|||
} |
|||
return colWidth / item.aspectRatio |
|||
} |
|||
|
|||
function addToShortestColumn(item: CardItem) { |
|||
let minIdx = 0 |
|||
for (let i = 1; i < columnCount.value; i++) { |
|||
if (columnHeights.value[i]! < columnHeights.value[minIdx]!) minIdx = i |
|||
} |
|||
columns.value[minIdx]!.push(item) |
|||
columnHeights.value[minIdx]! += estimateCardHeight(item) |
|||
} |
|||
|
|||
function distributeAll() { |
|||
const n = columnCount.value |
|||
columns.value = Array.from({ length: n }, () => []) |
|||
columnHeights.value = new Array(n).fill(0) |
|||
for (const item of allItems.value) { |
|||
addToShortestColumn(item) |
|||
} |
|||
} |
|||
|
|||
let resizeTimer: ReturnType<typeof setTimeout> | null = null |
|||
function onResize() { |
|||
if (resizeTimer) clearTimeout(resizeTimer) |
|||
resizeTimer = setTimeout(() => { |
|||
updateColumns() |
|||
}, 150) |
|||
} |
|||
|
|||
function updateColumns() { |
|||
const w = window.innerWidth |
|||
let n: number |
|||
if (w < 640) n = 1 |
|||
else if (w < 768) n = 2 |
|||
else if (w < 1024) n = 3 |
|||
else if (w < 1280) n = 4 |
|||
else n = 5 |
|||
|
|||
if (n !== columnCount.value) { |
|||
columnCount.value = n |
|||
distributeAll() |
|||
} |
|||
} |
|||
|
|||
async function loadMore() { |
|||
if (loading.value || !hasMore.value) return |
|||
loading.value = true |
|||
try { |
|||
const data = await $fetch<{ items: CardItem[]; hasMore: boolean }>('/api/cards', { |
|||
query: { page: page.value, pageSize: 12 }, |
|||
}) |
|||
for (const item of data.items) { |
|||
allItems.value.push(item) |
|||
addToShortestColumn(item) |
|||
} |
|||
hasMore.value = data.hasMore |
|||
page.value++ |
|||
} finally { |
|||
loading.value = false |
|||
initLoading.value = false |
|||
} |
|||
|
|||
await nextTick() |
|||
tryLoadMoreIfSentinelVisible() |
|||
} |
|||
|
|||
function tryLoadMoreIfSentinelVisible() { |
|||
if (!sentinel.value || loading.value || !hasMore.value) return |
|||
const rect = sentinel.value.getBoundingClientRect() |
|||
if (rect.top < window.innerHeight + 300) { |
|||
loadMore() |
|||
} |
|||
} |
|||
|
|||
onMounted(() => { |
|||
updateColumns() |
|||
window.addEventListener('resize', onResize) |
|||
loadMore() |
|||
}) |
|||
|
|||
onUnmounted(() => { |
|||
window.removeEventListener('resize', onResize) |
|||
if (resizeTimer) clearTimeout(resizeTimer) |
|||
}) |
|||
|
|||
let observer: IntersectionObserver | null = null |
|||
watch(sentinel, (el) => { |
|||
observer?.disconnect() |
|||
if (!el) return |
|||
observer = new IntersectionObserver( |
|||
([entry]) => { |
|||
if (entry!.isIntersecting) loadMore() |
|||
}, |
|||
{ rootMargin: '300px' }, |
|||
) |
|||
observer.observe(el) |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="photo"> |
|||
<header class="page-header"> |
|||
<h1>摄影</h1> |
|||
<p class="subtitle">灵感与美学的无声对话</p> |
|||
</header> |
|||
|
|||
<div ref="masonryEl" class="masonry"> |
|||
<div |
|||
v-for="(col, ci) in columns" |
|||
:key="ci" |
|||
class="masonry-col" |
|||
> |
|||
<template v-for="(item, ri) in col" :key="item.id"> |
|||
<div |
|||
class="card-reveal" |
|||
:style="{ '--enter-delay': `${ci * 80 + ri * 60}ms` }" |
|||
> |
|||
<IndexWaterfallCard |
|||
v-if="item.type === 'image-text'" |
|||
:image="item.image!" |
|||
:title="item.title" |
|||
:description="item.description!" |
|||
:aspect-ratio="item.aspectRatio" |
|||
/> |
|||
<IndexWaterfallTextCard |
|||
v-else-if="item.type === 'text'" |
|||
:title="item.title" |
|||
:description="item.description!" |
|||
:aspect-ratio="item.aspectRatio" |
|||
/> |
|||
<IndexWaterfallImageCard |
|||
v-else-if="item.type === 'image'" |
|||
:image="item.image!" |
|||
:title="item.title" |
|||
:aspect-ratio="item.aspectRatio" |
|||
/> |
|||
<IndexWaterfallPortfolioCard |
|||
v-else-if="item.type === 'portfolio'" |
|||
:images="item.images!" |
|||
:title="item.title" |
|||
:description="item.description!" |
|||
:aspect-ratio="item.aspectRatio" |
|||
/> |
|||
<IndexWaterfallProjectCard |
|||
v-else |
|||
:image="item.image!" |
|||
:title="item.title" |
|||
:description="item.description!" |
|||
:tags="item.tags!" |
|||
:aspect-ratio="item.aspectRatio" |
|||
/> |
|||
</div> |
|||
</template> |
|||
</div> |
|||
</div> |
|||
|
|||
<div ref="sentinel" class="sentinel"> |
|||
<template v-if="initLoading"> |
|||
<div class="loading-bars"> |
|||
<span v-for="i in 3" :key="i" class="bar" :style="{ animationDelay: `${(i - 1) * 0.15}s` }" /> |
|||
</div> |
|||
</template> |
|||
<template v-else-if="loading"> |
|||
<div class="loading-bars"> |
|||
<span v-for="i in 3" :key="i" class="bar" :style="{ animationDelay: `${(i - 1) * 0.15}s` }" /> |
|||
</div> |
|||
</template> |
|||
<span v-else-if="hasMore" class="sentinel-text">继续滚动查看更多</span> |
|||
<span v-else class="sentinel-text end">—— 已经到底了 ——</span> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
.photo { |
|||
max-width: 1400px; |
|||
margin: 0 auto; |
|||
} |
|||
|
|||
/* ── Header: cream canvas, coral type ── */ |
|||
|
|||
.page-header { |
|||
padding: clamp(64px, 10vw, 108px) 0 clamp(20px, 3vw, 32px); |
|||
animation: header-reveal 0.8s var(--ease-out-expo) both; |
|||
} |
|||
|
|||
@keyframes header-reveal { |
|||
from { |
|||
opacity: 0; |
|||
transform: translateY(8px); |
|||
} |
|||
to { |
|||
opacity: 1; |
|||
transform: translateY(0); |
|||
} |
|||
} |
|||
|
|||
.page-header h1 { |
|||
font-family: var(--font-display); |
|||
font-size: clamp(64px, 10vw, 132px); |
|||
font-weight: 400; |
|||
color: var(--color-primary); |
|||
margin: 0; |
|||
line-height: 0.88; |
|||
letter-spacing: -0.02em; |
|||
} |
|||
|
|||
.subtitle { |
|||
color: var(--color-muted); |
|||
font-size: clamp(16px, 2vw, 20px); |
|||
font-weight: 400; |
|||
margin: clamp(14px, 2.5vw, 24px) 0 0; |
|||
letter-spacing: 0.06em; |
|||
animation: fade-slide-up 0.7s var(--ease-out-expo) both; |
|||
animation-delay: 200ms; |
|||
} |
|||
|
|||
@keyframes fade-slide-up { |
|||
from { |
|||
opacity: 0; |
|||
transform: translateY(16px); |
|||
} |
|||
to { |
|||
opacity: 1; |
|||
transform: translateY(0); |
|||
} |
|||
} |
|||
|
|||
/* ── Masonry ── */ |
|||
|
|||
.masonry { |
|||
display: flex; |
|||
gap: 20px; |
|||
align-items: flex-start; |
|||
} |
|||
|
|||
.masonry-col { |
|||
flex: 1; |
|||
min-width: 0; |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 20px; |
|||
} |
|||
|
|||
/* ── Card reveal ── */ |
|||
|
|||
.card-reveal { |
|||
animation: card-enter 0.65s var(--ease-out-expo) both; |
|||
animation-delay: var(--enter-delay, 0ms); |
|||
} |
|||
|
|||
@keyframes card-enter { |
|||
from { |
|||
opacity: 0; |
|||
transform: translateY(24px); |
|||
} |
|||
to { |
|||
opacity: 1; |
|||
transform: translateY(0); |
|||
} |
|||
} |
|||
|
|||
/* ── Sentinel ── */ |
|||
|
|||
.sentinel { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
padding: 56px 0 96px; |
|||
min-height: 60px; |
|||
} |
|||
|
|||
.sentinel-text { |
|||
font-size: 14px; |
|||
color: var(--color-muted-soft); |
|||
} |
|||
|
|||
.sentinel-text.end { |
|||
color: var(--color-hairline); |
|||
} |
|||
|
|||
.loading-bars { |
|||
display: flex; |
|||
gap: 6px; |
|||
align-items: center; |
|||
} |
|||
|
|||
.bar { |
|||
width: 5px; |
|||
height: 20px; |
|||
background: var(--color-primary); |
|||
border-radius: 3px; |
|||
opacity: 0.5; |
|||
animation: pulse 0.9s ease-in-out infinite; |
|||
} |
|||
|
|||
@keyframes pulse { |
|||
0%, 100% { opacity: 0.3; transform: scaleY(0.6); } |
|||
50% { opacity: 0.8; transform: scaleY(1); } |
|||
} |
|||
|
|||
/* ── Responsive ── */ |
|||
|
|||
@media (max-width: 768px) { |
|||
.photo { |
|||
padding: 30px 0 0; |
|||
} |
|||
|
|||
.page-header { |
|||
margin: 16px 0 40px; |
|||
padding: 40px 24px; |
|||
} |
|||
|
|||
.page-header h1 { |
|||
font-size: clamp(40px, 12vw, 64px); |
|||
} |
|||
} |
|||
</style> |
|||
@ -1,5 +0,0 @@ |
|||
<template> |
|||
<div> |
|||
dsa |
|||
</div> |
|||
</template> |
|||
@ -1,5 +0,0 @@ |
|||
<template> |
|||
<div> |
|||
dsa |
|||
</div> |
|||
</template> |
|||
Binary file not shown.
@ -0,0 +1,11 @@ |
|||
CREATE TABLE `favorites` ( |
|||
`user_id` integer NOT NULL, |
|||
`card_id` integer NOT NULL, |
|||
`created_at` integer DEFAULT (cast((julianday('now') - 2440587.5)*86400000 as integer)) NOT NULL, |
|||
PRIMARY KEY(`user_id`, `card_id`), |
|||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade, |
|||
FOREIGN KEY (`card_id`) REFERENCES `cards`(`id`) ON UPDATE no action ON DELETE cascade |
|||
); |
|||
--> statement-breakpoint |
|||
CREATE INDEX `idx_favorite_user` ON `favorites` (`user_id`);--> statement-breakpoint |
|||
CREATE INDEX `idx_favorite_card` ON `favorites` (`card_id`); |
|||
File diff suppressed because it is too large
@ -0,0 +1,13 @@ |
|||
import { toggleFavorite } from "../../../service/favorite"; |
|||
import { requireUser } from "#server/utils/context"; |
|||
|
|||
export default defineWrappedResponseHandler(async (event) => { |
|||
const user = await requireUser(event); |
|||
const idParam = getRouterParam(event, "id"); |
|||
if (!idParam) return R.throwError(400, "缺少卡片 ID", null); |
|||
const cardId = parseInt(idParam); |
|||
if (isNaN(cardId)) return R.throwError(400, "无效的卡片 ID", null); |
|||
|
|||
const result = await toggleFavorite(user.id, cardId); |
|||
return R.success(result); |
|||
}); |
|||
@ -0,0 +1,195 @@ |
|||
import { dbGlobal } from "drizzle-pkg/lib/db"; |
|||
import { favorites, cards, cardImages, cardTags, tags } from "drizzle-pkg/lib/schema/content"; |
|||
import { eq, and, inArray, desc, sql, asc } from "drizzle-orm"; |
|||
import type { CardWithRelations } from "../card"; |
|||
|
|||
// ============ Types ============
|
|||
|
|||
export interface ListFavoritesOptions { |
|||
page?: number |
|||
pageSize?: number |
|||
} |
|||
|
|||
// ============ Toggle ============
|
|||
|
|||
/** |
|||
* Toggle favorite status for a card. |
|||
* Returns the new favorited state. |
|||
*/ |
|||
export async function toggleFavorite( |
|||
userId: number, |
|||
cardId: number, |
|||
): Promise<{ favorited: boolean }> { |
|||
const [existing] = await dbGlobal |
|||
.select() |
|||
.from(favorites) |
|||
.where( |
|||
and( |
|||
eq(favorites.userId, userId), |
|||
eq(favorites.cardId, cardId), |
|||
), |
|||
) |
|||
.limit(1); |
|||
|
|||
if (existing) { |
|||
await dbGlobal |
|||
.delete(favorites) |
|||
.where( |
|||
and( |
|||
eq(favorites.userId, userId), |
|||
eq(favorites.cardId, cardId), |
|||
), |
|||
); |
|||
return { favorited: false }; |
|||
} |
|||
|
|||
await dbGlobal.insert(favorites).values({ |
|||
userId, |
|||
cardId, |
|||
}); |
|||
return { favorited: true }; |
|||
} |
|||
|
|||
// ============ Batch check ============
|
|||
|
|||
/** |
|||
* Given a list of card IDs, return which ones the user has favorited. |
|||
*/ |
|||
export async function batchIsFavorited( |
|||
userId: number, |
|||
cardIds: number[], |
|||
): Promise<Set<number>> { |
|||
if (cardIds.length === 0) return new Set(); |
|||
|
|||
const rows = await dbGlobal |
|||
.select({ cardId: favorites.cardId }) |
|||
.from(favorites) |
|||
.where( |
|||
and( |
|||
eq(favorites.userId, userId), |
|||
inArray(favorites.cardId, cardIds), |
|||
), |
|||
); |
|||
|
|||
return new Set(rows.map((r) => r.cardId)); |
|||
} |
|||
|
|||
// ============ List ============
|
|||
|
|||
/** |
|||
* List favorited card IDs for a user. |
|||
*/ |
|||
export async function listFavoriteCardIds( |
|||
userId: number, |
|||
opts: ListFavoritesOptions = {}, |
|||
): Promise<{ cardIds: number[]; total: number }> { |
|||
const page = opts.page ?? 1; |
|||
const pageSize = opts.pageSize ?? 12; |
|||
|
|||
const [rows, countResult] = await Promise.all([ |
|||
dbGlobal |
|||
.select({ cardId: favorites.cardId }) |
|||
.from(favorites) |
|||
.where(eq(favorites.userId, userId)) |
|||
.orderBy(desc(favorites.createdAt)) |
|||
.limit(pageSize) |
|||
.offset((page - 1) * pageSize), |
|||
dbGlobal |
|||
.select({ count: sql<number>`count(*)` }) |
|||
.from(favorites) |
|||
.where(eq(favorites.userId, userId)), |
|||
]); |
|||
|
|||
return { |
|||
cardIds: rows.map((r) => r.cardId), |
|||
total: countResult[0]?.count ?? 0, |
|||
}; |
|||
} |
|||
|
|||
/** |
|||
* List favorited cards with full card data. |
|||
*/ |
|||
export async function listFavoriteCards( |
|||
userId: number, |
|||
opts: ListFavoritesOptions = {}, |
|||
): Promise<{ items: CardWithRelations[]; total: number; page: number; pageSize: number; hasMore: boolean }> { |
|||
const page = opts.page ?? 1; |
|||
const pageSize = opts.pageSize ?? 12; |
|||
|
|||
const { cardIds, total } = await listFavoriteCardIds(userId, { page, pageSize }); |
|||
|
|||
if (cardIds.length === 0) { |
|||
return { items: [], total, page, pageSize, hasMore: false }; |
|||
} |
|||
|
|||
// Fetch full card data via existing listCards with id filter
|
|||
// Build a simple query with the IDs
|
|||
const rows = await dbGlobal |
|||
.select() |
|||
.from(cards) |
|||
.where(inArray(cards.id, cardIds)) |
|||
.orderBy(desc(cards.createdAt)); |
|||
|
|||
// Load relations
|
|||
const allImages = await dbGlobal |
|||
.select() |
|||
.from(cardImages) |
|||
.where(inArray(cardImages.cardId, cardIds)); |
|||
|
|||
const allCardTags = await dbGlobal |
|||
.select() |
|||
.from(cardTags) |
|||
.where(inArray(cardTags.cardId, cardIds)); |
|||
|
|||
const tagIds = [...new Set(allCardTags.map((ct) => ct.tagId))]; |
|||
const tagRows = |
|||
tagIds.length > 0 |
|||
? await dbGlobal.select().from(tags).where(inArray(tags.id, tagIds)) |
|||
: []; |
|||
|
|||
const tagMap = new Map(tagRows.map((t) => [t.id, t])); |
|||
const imagesByCard = new Map<number, { id: number; url: string; sortOrder: number }[]>(); |
|||
for (const img of allImages) { |
|||
if (!imagesByCard.has(img.cardId)) imagesByCard.set(img.cardId, []); |
|||
imagesByCard.get(img.cardId)!.push({ |
|||
id: img.id, |
|||
url: img.url, |
|||
sortOrder: img.sortOrder, |
|||
}); |
|||
} |
|||
const tagsByCard = new Map<number, { id: number; name: string; slug: string }[]>(); |
|||
for (const ct of allCardTags) { |
|||
const tag = tagMap.get(ct.tagId); |
|||
if (!tag) continue; |
|||
if (!tagsByCard.has(ct.cardId)) tagsByCard.set(ct.cardId, []); |
|||
tagsByCard.get(ct.cardId)!.push({ |
|||
id: tag.id, |
|||
name: tag.name, |
|||
slug: tag.slug, |
|||
}); |
|||
} |
|||
|
|||
const items: CardWithRelations[] = rows |
|||
.filter((row) => cardIds.includes(row.id)) |
|||
.sort((a, b) => cardIds.indexOf(a.id) - cardIds.indexOf(b.id)) |
|||
.map((row) => ({ |
|||
id: row.id, |
|||
type: row.type as CardWithRelations["type"], |
|||
title: row.title, |
|||
description: row.description, |
|||
aspectRatio: row.aspectRatio, |
|||
categoryId: row.categoryId, |
|||
createdAt: row.createdAt, |
|||
updatedAt: row.updatedAt, |
|||
images: imagesByCard.get(row.id) ?? [], |
|||
tags: tagsByCard.get(row.id) ?? [], |
|||
})); |
|||
|
|||
return { |
|||
items, |
|||
total, |
|||
page, |
|||
pageSize, |
|||
hasMore: page * pageSize < total, |
|||
}; |
|||
} |
|||
Loading…
Reference in new issue