Browse Source

feat: add favorites functionality for cards

- 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
npmrun 2 weeks ago
parent
commit
7681e5e0f2
  1. 8
      app/components/TopNav.vue
  2. 57
      app/components/index/CardDetailModal.vue
  3. 1
      app/components/index/LeftSidebar.vue
  4. 95
      app/composables/useFavorite.ts
  5. 42
      app/pages/admin/scheduler/[id]/index.vue
  6. 35
      app/pages/admin/scheduler/index.vue
  7. 132
      app/pages/index/index.vue
  8. 360
      app/pages/photo/index.vue
  9. 5
      app/pages/portfolio/index.vue
  10. 5
      app/pages/profile/index.vue
  11. 2
      docs/superpowers/oauth2/API.md
  12. 11
      packages/bolt-ui/components/Dialog/src/Dialog.vue
  13. BIN
      packages/drizzle-pkg/db.sqlite
  14. 22
      packages/drizzle-pkg/lib/schema/content.ts
  15. 11
      packages/drizzle-pkg/migrations/0005_favorites.sql
  16. 1069
      packages/drizzle-pkg/migrations/meta/0005_snapshot.json
  17. 7
      packages/drizzle-pkg/migrations/meta/_journal.json
  18. 13
      server/api/cards/[id]/favorite.post.ts
  19. 10
      server/api/cards/index.get.ts
  20. 33
      server/service/card/index.ts
  21. 195
      server/service/favorite/index.ts

8
app/components/TopNav.vue

@ -8,10 +8,8 @@ const router = useRouter()
const { loggedIn, user, clear, initialized } = useAuthSession()
const links = [
{ label: '摄影', to: '/photo' },
{ label: '作品集', to: '/portfolio' },
{ label: '关于', to: '/about' },
const links: any = [
]
const searchQuery = ref((route.query.q as string) ?? '')
@ -104,7 +102,7 @@ watch(() => route.query.q, (val) => {
<NuxtLink to="/auth/register" class="auth-btn">注册</NuxtLink>
</li>
<li v-else-if="loggedIn && initialized" class="mobile-auth">
<NuxtLink to="/profile" class="auth-link">{{ user?.username }}</NuxtLink>
<NuxtLink class="auth-link">{{ user?.username }}</NuxtLink>
</li>
</ul>

57
app/components/index/CardDetailModal.vue

@ -24,6 +24,8 @@ const emit = defineEmits<{
'update:visible': [v: boolean]
}>()
const { isFavorited, toggle, loadingIds } = useFavorite()
const categoryName = computed(() => {
if (!props.card?.categoryId) return null
function find(nodes: CategoryNode[]): string | null {
@ -117,7 +119,18 @@ function formatDate(d?: string) {
<span v-if="card.createdAt" class="detail-date">{{ formatDate(card.createdAt) }}</span>
</div>
<h2 class="detail-title">{{ card.title }}</h2>
<div class="detail-title-row">
<h2 class="detail-title">{{ card.title }}</h2>
<button
type="button"
class="detail-fav-btn"
:class="{ favorited: isFavorited(card.id), loading: loadingIds.has(card.id) }"
:disabled="loadingIds.has(card.id)"
@click="toggle(card.id)"
>
<Icon :name="isFavorited(card.id) ? 'ph:star-fill' : 'ph:star'" />
</button>
</div>
<p v-if="card.description" class="detail-desc">{{ card.description }}</p>
@ -283,7 +296,49 @@ function formatDate(d?: string) {
margin-left: auto;
}
.detail-title-row {
display: flex;
align-items: flex-start;
gap: 12px;
}
.detail-fav-btn {
flex-shrink: 0;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 6px;
background: transparent;
color: var(--color-muted-soft);
cursor: pointer;
transition: all 0.15s ease;
margin-top: 2px;
}
.detail-fav-btn :deep(svg) {
width: 18px;
height: 18px;
}
.detail-fav-btn:hover {
color: var(--color-accent-amber);
background: rgba(232, 165, 90, 0.1);
}
.detail-fav-btn.favorited {
color: var(--color-accent-amber);
}
.detail-fav-btn.loading {
opacity: 0.5;
pointer-events: none;
}
.detail-title {
flex: 1;
font-family: var(--font-display);
font-size: 26px;
font-weight: 400;

1
app/components/index/LeftSidebar.vue

@ -25,6 +25,7 @@ const props = withDefaults(defineProps<{
side: 'left',
tools: () => [
{ key: 'home', label: '主页', icon: 'lucide:home' },
{ key: 'collect', label: '收藏', icon: 'lucide:star' },
// { key: 'search', label: '', icon: 'lucide:search' },
// { key: 'tags', label: '', icon: 'lucide:tag' },
// { key: 'highlights', label: '', icon: 'lucide:highlighter' },

95
app/composables/useFavorite.ts

@ -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,
}
}

42
app/pages/admin/scheduler/[id]/index.vue

@ -19,16 +19,34 @@ async function handleToggle() {
refresh()
}
// confirmation dialog
const confirmShow = ref(false)
const confirmMsg = ref('')
let confirmAction: (() => void) | null = null
function showConfirm(msg: string, action: () => void) {
confirmMsg.value = msg
confirmAction = action
confirmShow.value = true
}
function onConfirm() {
confirmAction?.()
confirmShow.value = false
}
async function handleDeleteLog(logId: string) {
if (!confirm('确定删除这条执行记录?')) return
await $fetch(`/api/scheduler/executions/${logId}`, { method: "DELETE" })
refresh()
showConfirm('确定删除这条执行记录?', async () => {
await $fetch(`/api/scheduler/executions/${logId}`, { method: "DELETE" })
refresh()
})
}
async function handleClearAll() {
if (!confirm('确定清除该任务的所有执行记录?')) return
await $fetch(`/api/scheduler/executions/delete-all?taskId=${id}`, { method: "POST" })
refresh()
showConfirm('确定清除该任务的所有执行记录?', async () => {
await $fetch(`/api/scheduler/executions/delete-all?taskId=${id}`, { method: "POST" })
refresh()
})
}
function statusClass(status: string): string {
@ -176,6 +194,18 @@ function timeAgo(ts: number | string): string {
<div v-else-if="data" class="not-found">
任务未找到
</div>
<!-- Confirm Dialog -->
<BoDialog v-model:show="confirmShow" :mask-can-close="true">
<div class="rounded-lg bg-white p-6 shadow-xl border border-[#e6dfd8] confirm-dialog">
<h3 class="text-[16px] font-medium text-[#141413]">确认操作</h3>
<p class="mt-3 text-[14px] text-[#3d3d3a]">{{ confirmMsg }}</p>
<div class="mt-6 flex justify-end gap-2">
<button class="px-4 py-2 bg-[#faf9f5] border border-[#e6dfd8] rounded-lg text-[14px] hover:bg-white transition-colors" @click="confirmShow = false">取消</button>
<button class="px-4 py-2 bg-[#cc785c] text-white rounded-lg text-[14px] hover:bg-[#a9583e] transition-colors" @click="onConfirm">确定</button>
</div>
</div>
</BoDialog>
</div>
</template>

35
app/pages/admin/scheduler/index.vue

@ -22,6 +22,22 @@ const statsData = computed(() => (stats.data.value ?? {}) as {
const showCreateModal = ref(false)
const editingTask = ref<TaskRow | null>(null)
// confirmation dialog
const confirmShow = ref(false)
const confirmMsg = ref('')
let confirmAction: (() => void) | null = null
function showConfirm(msg: string, action: () => void) {
confirmMsg.value = msg
confirmAction = action
confirmShow.value = true
}
function onConfirm() {
confirmAction?.()
confirmShow.value = false
}
function statusBadge(enabled: number) {
return enabled
? { label: "运行中", class: "badge-active" }
@ -29,9 +45,10 @@ function statusBadge(enabled: number) {
}
async function handleDelete(id: string) {
if (!confirm("确定删除此任务?")) return
await $fetch(`/api/scheduler/tasks/${id}`, { method: "DELETE" })
refresh()
showConfirm("确定删除此任务?", async () => {
await $fetch(`/api/scheduler/tasks/${id}`, { method: "DELETE" })
refresh()
})
}
async function handleToggle(id: string, enabled: boolean) {
@ -144,6 +161,18 @@ function onModalClose() {
</table>
</div>
<!-- Confirm Dialog -->
<BoDialog v-model:show="confirmShow" :mask-can-close="true">
<div class="rounded-lg bg-white p-6 shadow-xl border border-[#e6dfd8] confirm-dialog">
<h3 class="text-[16px] font-medium text-[#141413]">确认操作</h3>
<p class="mt-3 text-[14px] text-[#3d3d3a]">{{ confirmMsg }}</p>
<div class="mt-6 flex justify-end gap-2">
<button class="px-4 py-2 bg-[#faf9f5] border border-[#e6dfd8] rounded-lg text-[14px] hover:bg-white transition-colors" @click="confirmShow = false">取消</button>
<button class="px-4 py-2 bg-[#cc785c] text-white rounded-lg text-[14px] hover:bg-[#a9583e] transition-colors" @click="onConfirm">确定</button>
</div>
</div>
</BoDialog>
<!-- Create/Edit Modal -->
<SchedulerTaskModal
v-if="showCreateModal"

132
app/pages/index/index.vue

@ -10,11 +10,28 @@ definePageMeta({
const { $toast } = useNuxtApp()
const { loggedIn } = useAuthSession()
const { isFavorited, toggle: toggleFavorite, syncFromCards } = useFavorite()
const route = useRoute()
const isMobile = useMediaQuery('(max-width: 767.98px)')
const drawerOpen = ref(false)
const sidebarMode = computed<'rail' | 'drawer'>(() => isMobile.value ? 'drawer' : 'rail')
// confirmation dialog
const confirmShow = ref(false)
const confirmMsg = ref('')
let confirmAction: (() => void) | null = null
function showConfirm(msg: string, action: () => void) {
confirmMsg.value = msg
confirmAction = action
confirmShow.value = true
}
function onConfirm() {
confirmAction?.()
confirmShow.value = false
}
// Server response types
interface ServerCategory {
@ -119,10 +136,6 @@ async function fetchCategories() {
}
}
const totalCardCount = computed(() =>
categories.value.reduce((sum, c) => sum + (c.count ?? 0), 0)
)
const activeCategoryName = computed(() => {
function find(nodes: CategoryNode[]): CategoryNode | null {
for (const n of nodes) {
@ -135,7 +148,7 @@ const activeCategoryName = computed(() => {
return null
}
if (activeCategoryId.value === '__uncategorized__') return '未分类'
return find(categories.value)?.name ?? '全部灵感'
return find(categories.value)?.name ?? '全部卡片'
})
// Tool selection
@ -171,6 +184,14 @@ function onSelectTool(key: string) {
case 'home':
activeCategoryId.value = 'all'
break
case 'collect':
activeCategoryId.value = 'all'
if (!loggedIn.value) {
$toast.info('请先登录')
activeToolKey.value = 'home'
return
}
break
case 'tags':
showTagsModal.value = true
fetchTags()
@ -365,24 +386,25 @@ function onCardEdit(card: CardDetail) {
}
async function onCardDelete(id: number) {
if (!window.confirm('确定删除这张卡片吗?')) return
try {
await request(`/api/cards/${id}`, { method: 'DELETE' })
$toast.success('已删除卡片')
showDetailModal.value = false
// Refresh category counts
fetchCategories()
// Reload cards
page.value = 1
hasMore.value = true
allItems.value = []
columns.value = Array.from({ length: columnCount.value }, () => [])
columnHeights.value = new Array(columnCount.value).fill(0)
initLoading.value = true
loadMore()
} catch {
$toast.error('删除失败')
}
showConfirm('确定删除这张卡片吗?', async () => {
try {
await request(`/api/cards/${id}`, { method: 'DELETE' })
$toast.success('已删除卡片')
showDetailModal.value = false
// Refresh category counts
fetchCategories()
// Reload cards
page.value = 1
hasMore.value = true
allItems.value = []
columns.value = Array.from({ length: columnCount.value }, () => [])
columnHeights.value = new Array(columnCount.value).fill(0)
initLoading.value = true
loadMore()
} catch {
$toast.error('删除失败')
}
})
}
function onNewCard() {
@ -499,6 +521,16 @@ function onContainerResize() {
}, 120)
}
async function handleToggleFavorite(cardId: number) {
const wasFavorited = isFavorited(cardId)
const result = await toggleFavorite(cardId)
// When unfavoriting on the collect page, remove the card from the list immediately
if (wasFavorited && !result && activeToolKey.value === 'collect') {
allItems.value = allItems.value.filter((item) => item.id !== cardId)
distributeAll()
}
}
async function loadMore() {
if (loading.value || !hasMore.value) return
loading.value = true
@ -510,6 +542,10 @@ async function loadMore() {
if (activeCategoryId.value !== 'all') {
query.categoryId = activeCategoryId.value
}
// favorites mode
if (activeToolKey.value === 'collect') {
query.favorited = 1
}
// '__uncategorized__' will be passed as-is to filter null categories
const q = (route.query.q as string)?.trim()
if (q) query.q = q
@ -523,6 +559,8 @@ async function loadMore() {
}>('/api/cards', { query })
const result = raw.data
// Sync favorite status from server
syncFromCards(result.items as Array<{ id: number; isFavorited?: boolean }>)
const mapped = result.items.map(mapCard)
for (const item of mapped) {
allItems.value.push(item)
@ -548,6 +586,11 @@ watch(() => route.query.q, () => {
resetAndReload()
})
// Reset when tool changes (e.g. collect <-> home)
watch(activeToolKey, () => {
resetAndReload()
})
function resetAndReload() {
page.value = 1
hasMore.value = true
@ -608,7 +651,6 @@ onUnmounted(() => {
:categories="categories"
:active-tool-key="activeToolKey"
:active-category-id="activeCategoryId"
:total-count="totalCardCount"
:logged-in="loggedIn"
@select-tool="onSelectTool"
@select-category="onSelectCategory"
@ -627,7 +669,6 @@ onUnmounted(() => {
:categories="categories"
:active-tool-key="activeToolKey"
:active-category-id="activeCategoryId"
:total-count="totalCardCount"
:logged-in="loggedIn"
@update:drawer-open="(v: boolean) => drawerOpen = v"
@select-tool="onSelectTool"
@ -652,9 +693,12 @@ onUnmounted(() => {
<Icon name="lucide:menu" />
<span>分类</span>
</button>
<span class="category-chip">{{ activeCategoryName }}</span>
<span class="category-chip">{{ activeToolKey === 'collect' ? '我的收藏' : activeCategoryName }}</span>
</div>
<!-- Category heading -->
<h2 v-if="!isMobile" class="category-heading">{{ activeToolKey === 'collect' ? '我的收藏' : activeCategoryName }}</h2>
<div class="masonry">
<div
v-for="(col, ci) in columns"
@ -674,6 +718,15 @@ onUnmounted(() => {
<button
type="button"
class="card-action-btn"
:class="{ 'card-action-faved': isFavorited(item.id) }"
:title="isFavorited(item.id) ? '取消收藏' : '收藏'"
@click.stop="handleToggleFavorite(item.id)"
>
<Icon :name="isFavorited(item.id) ? 'ph:star-fill' : 'ph:star'" />
</button>
<button
type="button"
class="card-action-btn"
title="编辑"
@click.stop="onCardEdit({ id: item.id, type: item.type, title: item.title, description: item.description, image: item.image, images: item.images, tags: item.tags, aspectRatio: item.aspectRatio, categoryId: item.categoryId, createdAt: item.createdAt })"
>
@ -833,6 +886,18 @@ onUnmounted(() => {
@update:visible="(v) => catFormVisible = v"
@submit="onCatFormSubmit"
/>
<!-- Confirm Dialog -->
<BoDialog v-model:show="confirmShow" :mask-can-close="true">
<div class="rounded-lg bg-white p-6 shadow-xl border border-[#e6dfd8] confirm-dialog">
<h3 class="text-[16px] font-medium text-[#141413]">确认操作</h3>
<p class="mt-3 text-[14px] text-[#3d3d3a]">{{ confirmMsg }}</p>
<div class="mt-6 flex justify-end gap-2">
<button class="px-4 py-2 bg-[#faf9f5] border border-[#e6dfd8] rounded-lg text-[14px] hover:bg-white transition-colors" @click="confirmShow = false">取消</button>
<button class="px-4 py-2 bg-[#cc785c] text-white rounded-lg text-[14px] hover:bg-[#a9583e] transition-colors" @click="onConfirm">确定</button>
</div>
</div>
</BoDialog>
</div>
</template>
@ -860,6 +925,17 @@ onUnmounted(() => {
flex-direction: column;
}
/* ── Category heading ── */
.category-heading {
font-family: var(--font-display);
font-size: 22px;
font-weight: 500;
color: var(--color-ink);
margin: 0 0 12px 0;
letter-spacing: -0.3px;
}
/* ── Mobile bar ── */
.mobile-bar {
@ -938,6 +1014,10 @@ onUnmounted(() => {
background: rgba(198, 69, 69, 0.8);
}
.card-action-faved {
color: var(--color-accent-amber);
}
@keyframes card-enter {
from {
opacity: 0;

360
app/pages/photo/index.vue

@ -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>

5
app/pages/portfolio/index.vue

@ -1,5 +0,0 @@
<template>
<div>
dsa
</div>
</template>

5
app/pages/profile/index.vue

@ -1,5 +0,0 @@
<template>
<div>
dsa
</div>
</template>

2
docs/superpowers/oauth2/API.md

@ -299,7 +299,7 @@ if (url.searchParams.get('bind_success') === '1') {
if (url.searchParams.get('oauth_error')) {
const error = url.searchParams.get('oauth_error');
alert(`登录失败: ${error}`);
$toast.error(`登录失败: ${error}`);
}
```

11
packages/bolt-ui/components/Dialog/src/Dialog.vue

@ -52,8 +52,6 @@ defineOptions({
inheritAttrs: false,
});
const showContent = ref(true);
const attrs = useAttrs();
function setStyle(el: HTMLElement, css: Partial<CSSStyleDeclaration>) {
@ -94,6 +92,9 @@ const props = withDefaults(
inBox: false, //
}
);
const showContent = ref(props.show);
const isShow = ref(props.show);
const isShowWraper = ref(props.show);
const emits = defineEmits<{
(e: "update:show", isShow: boolean): void;
(e: "close"): void;
@ -145,8 +146,8 @@ const getDialogAnimType = () => {
onMounted(() => {
watch(
() => props.show,
(isShow) => {
if (isShow) {
(isShowParam) => {
if (isShowParam) {
show();
} else {
hide();
@ -158,8 +159,6 @@ onMounted(() => {
);
});
const isShow = ref(false);
const isShowWraper = ref(false);
const isPlaying = ref(false);
watch(

BIN
packages/drizzle-pkg/db.sqlite

Binary file not shown.

22
packages/drizzle-pkg/lib/schema/content.ts

@ -7,6 +7,7 @@ import {
index,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
import { users } from "./auth";
// ============ CardType ENUM ============
export const CardTypes = [
@ -111,6 +112,27 @@ export const cardTags = sqliteTable(
],
);
// ============ Favorite(收藏表)============
export const favorites = sqliteTable(
"favorites",
{
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
cardId: integer("card_id")
.notNull()
.references(() => cards.id, { onDelete: "cascade" }),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.defaultNow()
.notNull(),
},
(table) => [
primaryKey({ name: "favorite_pk", columns: [table.userId, table.cardId] }),
index("idx_favorite_user").on(table.userId),
index("idx_favorite_card").on(table.cardId),
],
);
// ============ Tool(工具项表)============
export const tools = sqliteTable("tools", {
id: text("id").primaryKey(), // UUID

11
packages/drizzle-pkg/migrations/0005_favorites.sql

@ -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`);

1069
packages/drizzle-pkg/migrations/meta/0005_snapshot.json

File diff suppressed because it is too large

7
packages/drizzle-pkg/migrations/meta/_journal.json

@ -36,6 +36,13 @@
"when": 1780573558785,
"tag": "0004_talented_wasp",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1780625963260,
"tag": "0005_favorites",
"breakpoints": true
}
]
}

13
server/api/cards/[id]/favorite.post.ts

@ -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);
});

10
server/api/cards/index.get.ts

@ -1,4 +1,5 @@
import { listCards, type CardType } from "../../service/card";
import { getCurrentUser } from "#server/utils/context";
export default defineWrappedResponseHandler(async (event) => {
const query = getQuery(event);
@ -8,7 +9,14 @@ export default defineWrappedResponseHandler(async (event) => {
const tagId = query.tagId ? parseInt(String(query.tagId)) : undefined;
const type = query.type as CardType | undefined;
const q = query.q as string | undefined;
const favorited = query.favorited === "true" || query.favorited === "1";
const result = await listCards({ page, pageSize, categoryId, tagId, type, q });
const user = await getCurrentUser(event);
const result = await listCards({
page, pageSize, categoryId, tagId, type, q,
favorited: favorited || undefined,
userId: user?.id,
});
return R.success(result);
});

33
server/service/card/index.ts

@ -5,6 +5,7 @@ import {
cardTags,
tags,
categories,
favorites,
type CardType,
CardTypes,
} from "drizzle-pkg/lib/schema/content";
@ -36,6 +37,7 @@ export interface CardWithRelations {
updatedAt: Date;
images: CardImageData[];
tags: TagData[];
isFavorited?: boolean;
}
export interface CreateCardInput {
@ -65,6 +67,8 @@ export interface ListCardsOptions {
tagId?: number;
type?: CardType;
q?: string;
favorited?: boolean;
userId?: number;
}
// ============ Helpers ============
@ -127,6 +131,19 @@ export async function listCards(
conditions.push(inArray(cards.id, tagCardIds));
}
// favorites filter (requires authenticated user)
if (opts.favorited && opts.userId) {
const favCardIds = await dbGlobal
.select({ cardId: favorites.cardId })
.from(favorites)
.where(eq(favorites.userId, opts.userId));
const favIds = favCardIds.map((r) => r.cardId);
if (favIds.length === 0) {
return { items: [], total: 0, page, pageSize, hasMore: false };
}
conditions.push(inArray(cards.id, favIds));
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [rows, countResult] = await Promise.all([
@ -163,6 +180,21 @@ export async function listCards(
: Promise.resolve([]),
]);
// Load favorite status for current user
let favoritedSet = new Set<number>();
if (opts.userId && cardIds.length > 0) {
const favRows = await dbGlobal
.select({ cardId: favorites.cardId })
.from(favorites)
.where(
and(
eq(favorites.userId, opts.userId),
inArray(favorites.cardId, cardIds),
),
);
favoritedSet = new Set(favRows.map((r) => r.cardId));
}
// Load referenced tags
const tagIds = [...new Set(allCardTags.map((ct) => ct.tagId))];
const tagRows =
@ -203,6 +235,7 @@ export async function listCards(
updatedAt: row.updatedAt,
images: imagesByCard.get(row.id) ?? [],
tags: tagsByCard.get(row.id) ?? [],
isFavorited: opts.userId ? favoritedSet.has(row.id) : undefined,
}));
return {

195
server/service/favorite/index.ts

@ -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…
Cancel
Save