-
-
Notifications
You must be signed in to change notification settings - Fork 44
fix: enable tag search in saved posts #87
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
66582fd
107f308
67853fa
851787d
6776bd3
3aa546e
6e85a65
b83a144
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -3,7 +3,8 @@ | |||||||||||||||||||||||
| import { ArrowPathIcon, QuestionMarkCircleIcon } from '@heroicons/vue/24/solid' | ||||||||||||||||||||||||
| import { useInfiniteQuery } from '@tanstack/vue-query' | ||||||||||||||||||||||||
| import { useWindowVirtualizer } from '@tanstack/vue-virtual' | ||||||||||||||||||||||||
| import { throttle } from 'es-toolkit' | ||||||||||||||||||||||||
| import { cloneDeep, throttle } from 'es-toolkit' | ||||||||||||||||||||||||
| import { FetchError } from 'ofetch' | ||||||||||||||||||||||||
| import type { Ref } from 'vue' | ||||||||||||||||||||||||
| import { toast } from 'vue-sonner' | ||||||||||||||||||||||||
| import type { Domain } from '~/assets/js/domain' | ||||||||||||||||||||||||
|
|
@@ -17,6 +18,7 @@ | |||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const router = useRouter() | ||||||||||||||||||||||||
| const route = useRoute() | ||||||||||||||||||||||||
| const config = useRuntimeConfig() | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const { $pocketBase } = useNuxtApp() | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
@@ -25,19 +27,31 @@ | |||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const { addUrlToPageHistory } = usePageHistory() | ||||||||||||||||||||||||
| const { savedPostList } = usePocketbase() | ||||||||||||||||||||||||
| const { booruList: availableBooruList } = useBooruList() | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * URL | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| const domainsFromPocketbase = await $pocketBase.collection('distinct_original_domain_from_posts').getFullList() | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const booruList = computed(() => { | ||||||||||||||||||||||||
| const getKnownBooruMetadata = (domain: string) => { | ||||||||||||||||||||||||
| const knownBooru = availableBooruList.value.find((booru) => booru.domain === domain) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||
| type: knownBooru?.type ?? booruTypeList[0], | ||||||||||||||||||||||||
| config: knownBooru?.config ?? null | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const productionBooruMetadata = getKnownBooruMetadata(project.urls.production.hostname) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const _booruList: Domain[] = [ | ||||||||||||||||||||||||
| // r34.app | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| domain: project.urls.production.hostname, | ||||||||||||||||||||||||
| type: booruTypeList[0], | ||||||||||||||||||||||||
| config: null, | ||||||||||||||||||||||||
| type: productionBooruMetadata.type, | ||||||||||||||||||||||||
| config: productionBooruMetadata.config, | ||||||||||||||||||||||||
| isCustom: false, | ||||||||||||||||||||||||
| isPremium: false | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
@@ -46,10 +60,12 @@ | |||||||||||||||||||||||
| const booruNamesInDb: string[] = domainsFromPocketbase.map((domain) => domain.original_domain) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| booruNamesInDb.forEach((booruNameInDb) => { | ||||||||||||||||||||||||
| const booruMetadata = getKnownBooruMetadata(booruNameInDb) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| _booruList.push({ | ||||||||||||||||||||||||
| domain: booruNameInDb, | ||||||||||||||||||||||||
| type: booruTypeList[0], | ||||||||||||||||||||||||
| config: null, | ||||||||||||||||||||||||
| type: booruMetadata.type, | ||||||||||||||||||||||||
| config: booruMetadata.config, | ||||||||||||||||||||||||
| isCustom: false, | ||||||||||||||||||||||||
| isPremium: false | ||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||
|
|
@@ -227,38 +243,169 @@ | |||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Listeners | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| let currentSearchRequestId = 0 | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| async function onSearchTag(tag: string) { | ||||||||||||||||||||||||
| toast.error('Autocomplete not implemented') | ||||||||||||||||||||||||
| const requestId = ++currentSearchRequestId | ||||||||||||||||||||||||
| const trimmedTag = tag.trim() | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (!trimmedTag) { | ||||||||||||||||||||||||
| tagResults.value = [] | ||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const apiBaseUrl = config.public.apiUrl | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (!apiBaseUrl) { | ||||||||||||||||||||||||
| toast.error('API URL is not configured') | ||||||||||||||||||||||||
| tagResults.value = [] | ||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const apiUrl = apiBaseUrl + '/booru/' + selectedBooru.value.type.type + '/tags' | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| let response: { data: Tag[] } | undefined | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||
| response = await $fetch<{ data: Tag[] }>(apiUrl, { | ||||||||||||||||||||||||
| params: { | ||||||||||||||||||||||||
| baseEndpoint: selectedBooru.value.domain, | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| tag: trimmedTag, | ||||||||||||||||||||||||
| order: 'count', | ||||||||||||||||||||||||
| limit: 20, | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Booru options | ||||||||||||||||||||||||
| httpScheme: selectedBooru.value.config?.options?.HTTPScheme ?? undefined | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
Comment on lines
+265
to
+280
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don’t build the tags endpoint from the synthetic saved-post This page does not hold real booru metadata for every saved-post domain: DB-derived entries are created with 🤖 Prompt for AI Agents |
||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||
| // Ignore if this is not the latest request | ||||||||||||||||||||||||
| if (requestId !== currentSearchRequestId) { | ||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (error instanceof FetchError) { | ||||||||||||||||||||||||
| switch (error.status) { | ||||||||||||||||||||||||
| case 404: | ||||||||||||||||||||||||
| toast.error('No tags found for query "' + trimmedTag + '"') | ||||||||||||||||||||||||
| tagResults.value = [] | ||||||||||||||||||||||||
| break | ||||||||||||||||||||||||
|
Comment on lines
+288
to
+293
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Treat no-match autocomplete responses as an empty state, not an error toast. This runs while the user types, so the 404 branch will enqueue a toast for normal intermediate queries that simply have no suggestions yet. That will flood the UI and make autocomplete feel broken. Clear 💡 Minimal fix case 404:
- toast.error('No tags found for query "' + trimmedTag + '"')
tagResults.value = []
break📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| case 429: | ||||||||||||||||||||||||
| // TODO: Cant always check if 429 is the status code, always show? | ||||||||||||||||||||||||
| toast.error(error.statusText, { | ||||||||||||||||||||||||
| description: 'You sent too many requests in a short period of time', | ||||||||||||||||||||||||
| action: { | ||||||||||||||||||||||||
| label: 'Verify I am not a Bot', | ||||||||||||||||||||||||
| onClick: () => window.open(apiBaseUrl + '/status', '_blank', 'noopener,noreferrer') | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||
| tagResults.value = [] | ||||||||||||||||||||||||
| break | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| default: { | ||||||||||||||||||||||||
| const Sentry = await import('@sentry/nuxt') | ||||||||||||||||||||||||
| Sentry.captureException(error) | ||||||||||||||||||||||||
| toast.error(`Failed to load tags: "${error.message}"`) | ||||||||||||||||||||||||
| tagResults.value = [] | ||||||||||||||||||||||||
| break | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const Sentry = await import('@sentry/nuxt') | ||||||||||||||||||||||||
| Sentry.captureException(error) | ||||||||||||||||||||||||
| toast.error('Failed to load tags') | ||||||||||||||||||||||||
| tagResults.value = [] | ||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Ignore if this is not the latest request | ||||||||||||||||||||||||
| if (requestId !== currentSearchRequestId) { | ||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (!(response && typeof response === 'object' && 'data' in response && Array.isArray(response.data))) { | ||||||||||||||||||||||||
| const Sentry = await import('@sentry/nuxt') | ||||||||||||||||||||||||
| const invalidTagsResponseError = Object.assign(new Error('Invalid tags response format'), { | ||||||||||||||||||||||||
| response | ||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||
| Sentry.captureException(invalidTagsResponseError) | ||||||||||||||||||||||||
| toast.error('Failed to load tags') | ||||||||||||||||||||||||
| tagResults.value = [] | ||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| tagResults.value = response.data | ||||||||||||||||||||||||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| async function onDomainChange(domain: Domain) { | ||||||||||||||||||||||||
| await reflectChangesInUrl({ domain: domain.domain, page: null, tags: null, filters: null }) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| async function onSearchSubmit({ tags, filters }) { | ||||||||||||||||||||||||
| // TODO: Tags | ||||||||||||||||||||||||
| await reflectChangesInUrl({ page: null, filters }) | ||||||||||||||||||||||||
| await reflectChangesInUrl({ page: null, tags, filters }) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Adds the tag, or removes it if it already exists | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| async function onPostAddTag(tag: string) { | ||||||||||||||||||||||||
| toast.error('Not implemented') | ||||||||||||||||||||||||
| const isTagNegative = tag.startsWith('-') | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| let newTags = cloneDeep(selectedTags.value) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Remove tag if it already exists | ||||||||||||||||||||||||
| const isTagAlreadySelected = newTags.some((selectedTag) => selectedTag.name === tag) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (isTagAlreadySelected) { | ||||||||||||||||||||||||
| newTags = newTags.filter((selectedTag) => selectedTag.name !== tag) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| await reflectChangesInUrl({ page: null, tags: newTags }) | ||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Remove opposite variant to prevent conflicts | ||||||||||||||||||||||||
| if (isTagNegative) { | ||||||||||||||||||||||||
| // Removing negative prefix, check for positive counterpart | ||||||||||||||||||||||||
| const positiveVariant = tag.slice(1) | ||||||||||||||||||||||||
| newTags = newTags.filter((selectedTag) => selectedTag.name !== positiveVariant) | ||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||
| // Adding positive tag, check for negative counterpart | ||||||||||||||||||||||||
| const negativeVariant = `-${tag}` | ||||||||||||||||||||||||
| newTags = newTags.filter((selectedTag) => selectedTag.name !== negativeVariant) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| newTags.push(new Tag({ name: tag }).toJSON()) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| await reflectChangesInUrl({ page: null, tags: newTags }) | ||||||||||||||||||||||||
coderabbitai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Sets tags to only the given tag | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| async function onPostSetTag(tag: string) { | ||||||||||||||||||||||||
| toast.error('Not implemented') | ||||||||||||||||||||||||
| await reflectChangesInUrl({ page: null, tags: [new Tag({ name: tag }).toJSON()] }) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Opens the tag in a new tab | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| async function onPostOpenTagInNewTab(tag: string) { | ||||||||||||||||||||||||
| toast.error('Not implemented') | ||||||||||||||||||||||||
| function onPostOpenTagInNewTab(tag: string) { | ||||||||||||||||||||||||
| const tagUrl = generatePostsRoute( | ||||||||||||||||||||||||
| '/premium/saved-posts', | ||||||||||||||||||||||||
| selectedBooru.value.domain, | ||||||||||||||||||||||||
| undefined, | ||||||||||||||||||||||||
| [new Tag({ name: tag }).toJSON()], | ||||||||||||||||||||||||
| undefined | ||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const resolvedTagUrl = router.resolve(tagUrl).href | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| window.open(resolvedTagUrl, '_blank', 'noopener,noreferrer') | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| async function onLoadNextPostPage() { | ||||||||||||||||||||||||
|
|
@@ -309,6 +456,18 @@ | |||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| interface IPostPageFromPocketBase extends Omit<IPostPage, 'links'> {} | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const tagCategoryFields = ['tags_artist', 'tags_character', 'tags_copyright', 'tags_general', 'tags_meta'] as const | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| function buildTagFilterInAnyCategory(tag: string, tagParamKey: string) { | ||||||||||||||||||||||||
| const tagFilterByCategory = tagCategoryFields.map((tagField) => { | ||||||||||||||||||||||||
| return $pocketBase.filter(`${tagField} ?= {:${tagParamKey}}`, { | ||||||||||||||||||||||||
| [tagParamKey]: tag | ||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return `(${tagFilterByCategory.join(' || ')})` | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| async function fetchPosts(options: any): Promise<IPostPageFromPocketBase> { | ||||||||||||||||||||||||
| const page = options.pageParam | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
|
|
@@ -344,11 +503,35 @@ | |||||||||||||||||||||||
| }) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // TODO | ||||||||||||||||||||||||
| // if (selectedTags.value.length > 0) { | ||||||||||||||||||||||||
| // } | ||||||||||||||||||||||||
| if (selectedTags.value.length > 0) { | ||||||||||||||||||||||||
| selectedTags.value.forEach((selectedTag, tagIndex) => { | ||||||||||||||||||||||||
| const isNegativeTag = selectedTag.name.startsWith('-') | ||||||||||||||||||||||||
| const normalizedTag = isNegativeTag ? selectedTag.name.slice(1) : selectedTag.name | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (!normalizedTag) { | ||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (pocketbaseRequestFilter !== '') { | ||||||||||||||||||||||||
| pocketbaseRequestFilter += ' && ' | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const tagFilter = buildTagFilterInAnyCategory(normalizedTag, `tag_${tagIndex}`) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (isNegativeTag) { | ||||||||||||||||||||||||
| pocketbaseRequestFilter += `!${tagFilter}` | ||||||||||||||||||||||||
| return | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| pocketbaseRequestFilter += tagFilter | ||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (selectedFilters.value.score !== undefined) { | ||||||||||||||||||||||||
| if (pocketbaseRequestFilter !== '') { | ||||||||||||||||||||||||
| pocketbaseRequestFilter += ' && ' | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (selectedFilters.value.score) { | ||||||||||||||||||||||||
| pocketbaseRequestFilter += $pocketBase.filter('score >= {:score}', { | ||||||||||||||||||||||||
| score: selectedFilters.value.score | ||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Invalidate autocomplete state when the domain changes.
Changing domains does not bump
currentSearchRequestIdor cleartagResults, so suggestions from the previous domain can remain visible, and an in-flight response for the old domain can still populate the new one if the user doesn’t type again. Invalidate pending searches and clear results before navigating.💡 Minimal fix
async function onDomainChange(domain: Domain) { + currentSearchRequestId++ + tagResults.value = [] await reflectChangesInUrl({ domain: domain.domain, page: null, tags: null, filters: null }) }Also applies to: 345-346
🤖 Prompt for AI Agents