|
|
@ -12,6 +12,7 @@ definePageMeta({ |
|
|
const { $toast } = useNuxtApp() |
|
|
const { $toast } = useNuxtApp() |
|
|
const { isFavorited, toggle: toggleFavorite, syncFromCards } = useFavorite() |
|
|
const { isFavorited, toggle: toggleFavorite, syncFromCards } = useFavorite() |
|
|
const route = useRoute() |
|
|
const route = useRoute() |
|
|
|
|
|
const router = useRouter() |
|
|
const isMobile = useMediaQuery('(max-width: 767.98px)') |
|
|
const isMobile = useMediaQuery('(max-width: 767.98px)') |
|
|
const drawerOpen = ref(false) |
|
|
const drawerOpen = ref(false) |
|
|
const sidebarMode = computed<'rail' | 'drawer'>(() => isMobile.value ? 'drawer' : 'rail') |
|
|
const sidebarMode = computed<'rail' | 'drawer'>(() => isMobile.value ? 'drawer' : 'rail') |
|
|
@ -105,6 +106,8 @@ function mapCategoryTree(server: ServerCategory[]): CategoryNode[] { |
|
|
|
|
|
|
|
|
const activeToolKey = ref<string>('home') |
|
|
const activeToolKey = ref<string>('home') |
|
|
const activeCategoryId = ref<string>('all') |
|
|
const activeCategoryId = ref<string>('all') |
|
|
|
|
|
const searchQuery = ref('') |
|
|
|
|
|
const searchInputRef = ref<HTMLInputElement | null>(null) |
|
|
|
|
|
|
|
|
const categories = ref<CategoryNode[]>([]) |
|
|
const categories = ref<CategoryNode[]>([]) |
|
|
const categoriesLoading = ref(true) |
|
|
const categoriesLoading = ref(true) |
|
|
@ -183,9 +186,9 @@ function onSelectTool(key: string) { |
|
|
fetchTags() |
|
|
fetchTags() |
|
|
return |
|
|
return |
|
|
case 'search': |
|
|
case 'search': |
|
|
// Focus the TopNav search input |
|
|
activeCategoryId.value = 'all' |
|
|
const input = document.querySelector<HTMLInputElement>('.search-input') |
|
|
searchQuery.value = (route.query.q as string) ?? '' |
|
|
input?.focus() |
|
|
nextTick(() => searchInputRef.value?.focus()) |
|
|
break |
|
|
break |
|
|
case 'highlights': |
|
|
case 'highlights': |
|
|
$toast.info('高亮即将上线') |
|
|
$toast.info('高亮即将上线') |
|
|
@ -543,9 +546,21 @@ async function loadMore() { |
|
|
if (activeToolKey.value === 'collect') { |
|
|
if (activeToolKey.value === 'collect') { |
|
|
query.favorited = 1 |
|
|
query.favorited = 1 |
|
|
} |
|
|
} |
|
|
|
|
|
// search mode — skip category / favorite filters |
|
|
|
|
|
if (activeToolKey.value === 'search') { |
|
|
|
|
|
const q = (route.query.q as string)?.trim() |
|
|
|
|
|
if (q) query.q = q |
|
|
|
|
|
else { |
|
|
|
|
|
// No query — don't load anything |
|
|
|
|
|
loading.value = false |
|
|
|
|
|
initLoading.value = false |
|
|
|
|
|
return |
|
|
|
|
|
} |
|
|
|
|
|
} else { |
|
|
// '__uncategorized__' will be passed as-is to filter null categories |
|
|
// '__uncategorized__' will be passed as-is to filter null categories |
|
|
const q = (route.query.q as string)?.trim() |
|
|
const q = (route.query.q as string)?.trim() |
|
|
if (q) query.q = q |
|
|
if (q) query.q = q |
|
|
|
|
|
} |
|
|
const raw = await $fetch<{ |
|
|
const raw = await $fetch<{ |
|
|
code: number |
|
|
code: number |
|
|
data: { |
|
|
data: { |
|
|
@ -588,6 +603,21 @@ watch(activeToolKey, () => { |
|
|
resetAndReload() |
|
|
resetAndReload() |
|
|
}) |
|
|
}) |
|
|
|
|
|
|
|
|
|
|
|
// Debounced sync from search input to route.query.q |
|
|
|
|
|
let searchDebounce: ReturnType<typeof setTimeout> | null = null |
|
|
|
|
|
watch(searchQuery, (val) => { |
|
|
|
|
|
if (searchDebounce) clearTimeout(searchDebounce) |
|
|
|
|
|
searchDebounce = setTimeout(() => { |
|
|
|
|
|
const trimmed = val.trim() |
|
|
|
|
|
if (trimmed) { |
|
|
|
|
|
router.replace({ query: { ...route.query, q: trimmed } }) |
|
|
|
|
|
} else if (route.query.q) { |
|
|
|
|
|
const { q, ...rest } = route.query |
|
|
|
|
|
router.replace({ query: rest }) |
|
|
|
|
|
} |
|
|
|
|
|
}, 300) |
|
|
|
|
|
}) |
|
|
|
|
|
|
|
|
function resetAndReload() { |
|
|
function resetAndReload() { |
|
|
page.value = 1 |
|
|
page.value = 1 |
|
|
hasMore.value = true |
|
|
hasMore.value = true |
|
|
@ -688,11 +718,46 @@ onUnmounted(() => { |
|
|
<Icon name="lucide:menu" /> |
|
|
<Icon name="lucide:menu" /> |
|
|
<span>分类</span> |
|
|
<span>分类</span> |
|
|
</button> |
|
|
</button> |
|
|
<span class="category-chip">{{ activeToolKey === 'collect' ? '我的收藏' : activeCategoryName }}</span> |
|
|
<span class="category-chip"> |
|
|
|
|
|
<template v-if="activeToolKey === 'search'">搜索</template> |
|
|
|
|
|
<template v-else>{{ activeToolKey === 'collect' ? '我的收藏' : activeCategoryName }}</template> |
|
|
|
|
|
</span> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<!-- Search section --> |
|
|
|
|
|
<section v-if="activeToolKey === 'search'" class="search-section"> |
|
|
|
|
|
<div class="search-header"> |
|
|
|
|
|
<h2 class="search-heading">搜索卡片</h2> |
|
|
|
|
|
<p class="search-sub">按标题、描述查找卡片</p> |
|
|
|
|
|
</div> |
|
|
|
|
|
<div class="search-bar"> |
|
|
|
|
|
<Icon name="lucide:search" class="search-bar-icon" /> |
|
|
|
|
|
<input |
|
|
|
|
|
ref="searchInputRef" |
|
|
|
|
|
v-model="searchQuery" |
|
|
|
|
|
type="search" |
|
|
|
|
|
class="search-bar-input" |
|
|
|
|
|
placeholder="输入关键词…" |
|
|
|
|
|
> |
|
|
|
|
|
<button |
|
|
|
|
|
v-if="searchQuery" |
|
|
|
|
|
type="button" |
|
|
|
|
|
class="search-bar-clear" |
|
|
|
|
|
@click="searchQuery = ''" |
|
|
|
|
|
> |
|
|
|
|
|
<Icon name="lucide:x" /> |
|
|
|
|
|
</button> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<p |
|
|
|
|
|
v-if="route.query.q && !initLoading && allItems.length > 0" |
|
|
|
|
|
class="search-count" |
|
|
|
|
|
> |
|
|
|
|
|
找到 <strong>{{ allItems.length }}</strong> 条结果 |
|
|
|
|
|
</p> |
|
|
|
|
|
</section> |
|
|
|
|
|
|
|
|
<!-- Category heading --> |
|
|
<!-- Category heading --> |
|
|
<h2 v-if="!isMobile" class="category-heading">{{ activeToolKey === 'collect' ? '我的收藏' : activeCategoryName }}</h2> |
|
|
<h2 v-else-if="!isMobile" class="category-heading">{{ activeToolKey === 'collect' ? '我的收藏' : activeCategoryName }}</h2> |
|
|
|
|
|
|
|
|
<div class="masonry"> |
|
|
<div class="masonry"> |
|
|
<div |
|
|
<div |
|
|
@ -752,8 +817,10 @@ onUnmounted(() => { |
|
|
class="empty-state" |
|
|
class="empty-state" |
|
|
> |
|
|
> |
|
|
<Icon name="lucide:inbox" class="empty-icon" /> |
|
|
<Icon name="lucide:inbox" class="empty-icon" /> |
|
|
<h3>还没有内容</h3> |
|
|
<h3 v-if="activeToolKey === 'search' && !route.query.q">搜索卡片</h3> |
|
|
<p v-if="activeCategoryId !== 'all'">当前分类下暂无卡片</p> |
|
|
<h3 v-else>还没有内容</h3> |
|
|
|
|
|
<p v-if="activeToolKey === 'search' && !route.query.q">输入关键词搜索卡片标题和描述</p> |
|
|
|
|
|
<p v-else-if="activeCategoryId !== 'all'">当前分类下暂无卡片</p> |
|
|
<p v-else-if="route.query.q">没有找到匹配「{{ route.query.q }}」的卡片</p> |
|
|
<p v-else-if="route.query.q">没有找到匹配「{{ route.query.q }}」的卡片</p> |
|
|
<p v-else>点击右下角 + 按钮创建第一张卡片</p> |
|
|
<p v-else>点击右下角 + 按钮创建第一张卡片</p> |
|
|
</div> |
|
|
</div> |
|
|
@ -902,6 +969,112 @@ onUnmounted(() => { |
|
|
letter-spacing: -0.3px; |
|
|
letter-spacing: -0.3px; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/* ── Search section ── */ |
|
|
|
|
|
|
|
|
|
|
|
.search-section { |
|
|
|
|
|
margin-bottom: 8px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.search-header { |
|
|
|
|
|
margin-bottom: 16px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.search-heading { |
|
|
|
|
|
font-family: var(--font-display); |
|
|
|
|
|
font-size: 28px; |
|
|
|
|
|
font-weight: 400; |
|
|
|
|
|
line-height: 1.2; |
|
|
|
|
|
letter-spacing: -0.3px; |
|
|
|
|
|
color: var(--color-ink); |
|
|
|
|
|
margin: 0 0 6px 0; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.search-sub { |
|
|
|
|
|
font-family: var(--font-body); |
|
|
|
|
|
font-size: 14px; |
|
|
|
|
|
color: var(--color-muted); |
|
|
|
|
|
margin: 0; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.search-bar { |
|
|
|
|
|
position: relative; |
|
|
|
|
|
display: flex; |
|
|
|
|
|
align-items: center; |
|
|
|
|
|
max-width: 560px; |
|
|
|
|
|
height: 48px; |
|
|
|
|
|
margin-bottom: 12px; |
|
|
|
|
|
background: var(--color-canvas); |
|
|
|
|
|
border: 1px solid var(--color-hairline); |
|
|
|
|
|
border-radius: 12px; |
|
|
|
|
|
padding: 0 16px; |
|
|
|
|
|
transition: border-color 0.2s ease, box-shadow 0.2s ease; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.search-bar:focus-within { |
|
|
|
|
|
border-color: var(--color-primary); |
|
|
|
|
|
box-shadow: 0 0 0 4px rgba(204, 120, 92, 0.12); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.search-bar-icon { |
|
|
|
|
|
width: 20px; |
|
|
|
|
|
height: 20px; |
|
|
|
|
|
color: var(--color-muted); |
|
|
|
|
|
flex-shrink: 0; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.search-bar-input { |
|
|
|
|
|
flex: 1; |
|
|
|
|
|
height: 100%; |
|
|
|
|
|
border: none; |
|
|
|
|
|
background: transparent; |
|
|
|
|
|
outline: none; |
|
|
|
|
|
padding: 0 12px; |
|
|
|
|
|
font-family: var(--font-body); |
|
|
|
|
|
font-size: 16px; |
|
|
|
|
|
color: var(--color-ink); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.search-bar-input::placeholder { |
|
|
|
|
|
color: var(--color-muted-soft); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.search-bar-input::-webkit-search-cancel-button { |
|
|
|
|
|
-webkit-appearance: none; |
|
|
|
|
|
appearance: none; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.search-bar-clear { |
|
|
|
|
|
width: 28px; |
|
|
|
|
|
height: 28px; |
|
|
|
|
|
display: flex; |
|
|
|
|
|
align-items: center; |
|
|
|
|
|
justify-content: center; |
|
|
|
|
|
background: none; |
|
|
|
|
|
border: none; |
|
|
|
|
|
border-radius: 50%; |
|
|
|
|
|
cursor: pointer; |
|
|
|
|
|
color: var(--color-muted); |
|
|
|
|
|
flex-shrink: 0; |
|
|
|
|
|
transition: background 0.15s ease, color 0.15s ease; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.search-bar-clear:hover { |
|
|
|
|
|
background: var(--color-surface-soft); |
|
|
|
|
|
color: var(--color-ink); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.search-count { |
|
|
|
|
|
font-family: var(--font-body); |
|
|
|
|
|
font-size: 13px; |
|
|
|
|
|
color: var(--color-muted); |
|
|
|
|
|
margin: 0 0 4px 0; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.search-count strong { |
|
|
|
|
|
font-weight: 500; |
|
|
|
|
|
color: var(--color-ink); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
/* ── Mobile bar ── */ |
|
|
/* ── Mobile bar ── */ |
|
|
|
|
|
|
|
|
.mobile-bar { |
|
|
.mobile-bar { |
|
|
|