Add gallery search and account menu
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||
import { Clock, Location, Settings, User } from '@vicons/tabler'
|
||||
import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
|
||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
@@ -44,6 +44,7 @@ const loadingMore = ref(false)
|
||||
const loadError = ref('')
|
||||
const galleryItems = ref<GalleryCloud[]>([])
|
||||
const selectedTypeId = ref<number | 'all'>('all')
|
||||
const searchQuery = ref('')
|
||||
const selectedCloud = ref<GalleryCloud | null>(null)
|
||||
const sentinel = ref<HTMLDivElement | null>(null)
|
||||
const totalLoaded = ref(0)
|
||||
@@ -54,6 +55,7 @@ const editSaving = ref(false)
|
||||
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
||||
|
||||
let observer: IntersectionObserver | null = null
|
||||
let searchTimer: number | null = null
|
||||
|
||||
const rarityMeta = {
|
||||
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
|
||||
@@ -84,6 +86,37 @@ function formatCoordinate(value: number | null) {
|
||||
return value === null ? '未记录' : value.toFixed(2)
|
||||
}
|
||||
|
||||
const normalizedSearch = computed(() => searchQuery.value.trim())
|
||||
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
|
||||
|
||||
function sanitizeSearchTerm(term: string) {
|
||||
return term.replace(/[(),*%]/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function getMatchedCloudTypeIds(term: string) {
|
||||
const lowerTerm = term.toLocaleLowerCase('zh-CN')
|
||||
return cloudsStore.cloudTypes
|
||||
.filter(type => {
|
||||
return type.name.toLocaleLowerCase('zh-CN').includes(lowerTerm) ||
|
||||
type.name_en.toLocaleLowerCase('zh-CN').includes(lowerTerm)
|
||||
})
|
||||
.map(type => type.id)
|
||||
}
|
||||
|
||||
async function fetchUserIdsBySearch(term: string) {
|
||||
const sanitized = sanitizeSearchTerm(term)
|
||||
if (!sanitized) return []
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.ilike('username', `%${sanitized}%`)
|
||||
.limit(100)
|
||||
|
||||
if (error) throw error
|
||||
return ((data || []) as Array<{ id: string }>).map(profile => profile.id)
|
||||
}
|
||||
|
||||
function toGalleryCloud(row: Record<string, unknown>) {
|
||||
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
|
||||
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
|
||||
@@ -112,6 +145,16 @@ function toGalleryCloud(row: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
async function fetchPage(offset: number) {
|
||||
const search = normalizedSearch.value
|
||||
const usernameTerm = isUserSearch.value ? sanitizeSearchTerm(search.slice(1)) : ''
|
||||
const cloudTypeTerm = !isUserSearch.value ? sanitizeSearchTerm(search) : ''
|
||||
const userIds = usernameTerm ? await fetchUserIdsBySearch(usernameTerm) : []
|
||||
const matchedCloudTypeIds = cloudTypeTerm ? getMatchedCloudTypeIds(cloudTypeTerm) : []
|
||||
|
||||
if (isUserSearch.value && !usernameTerm) return []
|
||||
if (search && !isUserSearch.value && !cloudTypeTerm) return []
|
||||
if (usernameTerm && userIds.length === 0) return []
|
||||
|
||||
let query = supabase
|
||||
.from('clouds')
|
||||
.select('id,user_id,cloud_type_id,image_url,thumbnail_url,location_name,description,latitude,longitude,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
|
||||
@@ -124,6 +167,16 @@ async function fetchPage(offset: number) {
|
||||
query = query.eq('cloud_type_id', selectedTypeId.value)
|
||||
}
|
||||
|
||||
if (usernameTerm) {
|
||||
query = query.in('user_id', userIds)
|
||||
} else if (cloudTypeTerm) {
|
||||
if (matchedCloudTypeIds.length) {
|
||||
query = query.or(`cloud_type_id.in.(${matchedCloudTypeIds.join(',')}),custom_cloud_type.ilike.*${cloudTypeTerm}*`)
|
||||
} else {
|
||||
query = query.ilike('custom_cloud_type', `%${cloudTypeTerm}%`)
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) throw error
|
||||
|
||||
@@ -348,6 +401,10 @@ function handleCloudAction(key: string | number) {
|
||||
if (key === 'toggle-privacy') hideSelectedCloud()
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
const filterTabs = computed(() => [
|
||||
{ id: 'all' as const, label: '全部' },
|
||||
...cloudsStore.cloudTypes.map(type => ({ id: type.id, label: type.name })),
|
||||
@@ -363,11 +420,25 @@ watch(selectedTypeId, async () => {
|
||||
await loadInitial()
|
||||
})
|
||||
|
||||
watch(searchQuery, () => {
|
||||
if (searchTimer !== null) {
|
||||
window.clearTimeout(searchTimer)
|
||||
}
|
||||
searchTimer = window.setTimeout(async () => {
|
||||
selectedCloud.value = null
|
||||
await loadInitial()
|
||||
searchTimer = null
|
||||
}, 250)
|
||||
})
|
||||
|
||||
watch(sentinel, () => {
|
||||
if (!loading.value) setupObserver()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (searchTimer !== null) {
|
||||
window.clearTimeout(searchTimer)
|
||||
}
|
||||
observer?.disconnect()
|
||||
})
|
||||
</script>
|
||||
@@ -386,6 +457,40 @@ onUnmounted(() => {
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<section>
|
||||
<div class="mb-4 border border-slate-200 bg-white p-3 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)]">
|
||||
<div class="flex items-center gap-3">
|
||||
<NIcon size="20" class="shrink-0 text-slate-400">
|
||||
<Search />
|
||||
</NIcon>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="min-w-0 flex-1 bg-transparent text-sm text-slate-800 outline-none placeholder:text-slate-400"
|
||||
placeholder="搜索云型名称,或输入 @用户名 查看某个用户的图片"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
type="button"
|
||||
class="flex h-8 w-8 shrink-0 items-center justify-center text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||
title="清空搜索"
|
||||
aria-label="清空搜索"
|
||||
@click="clearSearch"
|
||||
>
|
||||
<NIcon size="18">
|
||||
<X />
|
||||
</NIcon>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="normalizedSearch" class="mt-2 text-xs text-slate-500">
|
||||
<template v-if="isUserSearch">
|
||||
正在按上传者搜索:{{ normalizedSearch.slice(1) || '请输入用户名' }}
|
||||
</template>
|
||||
<template v-else>
|
||||
正在匹配云型名称和自定义云型:{{ normalizedSearch }}
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 overflow-x-auto pb-2">
|
||||
<NButton
|
||||
v-for="tab in filterTabs"
|
||||
|
||||
Reference in New Issue
Block a user