Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 199 additions & 16 deletions pages/premium/saved-posts/[domain].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -17,6 +18,7 @@

const router = useRouter()
const route = useRoute()
const config = useRuntimeConfig()

const { $pocketBase } = useNuxtApp()

Expand All @@ -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
}
Expand All @@ -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
})
Expand Down Expand Up @@ -227,38 +243,169 @@
/**
* Listeners
*/
let currentSearchRequestId = 0

async function onSearchTag(tag: string) {
toast.error('Autocomplete not implemented')
const requestId = ++currentSearchRequestId
Comment on lines +246 to +249
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Invalidate autocomplete state when the domain changes.

Changing domains does not bump currentSearchRequestId or clear tagResults, 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
Verify each finding against the current code and only fix it if needed.

In `@pages/premium/saved-posts/`[domain].vue around lines 246 - 249, When the
route domain changes, invalidate any pending autocomplete by incrementing
currentSearchRequestId and clearing tagResults so stale suggestions or in-flight
responses cannot populate the new domain; update the code that handles domain
changes (e.g., a watch on the route param or the navigation handler) to call
++currentSearchRequestId and set tagResults = [] before navigating, and ensure
onSearchTag still checks requestId against currentSearchRequestId to ignore old
responses; apply the same fix at the other domain-change site referenced (the
block around the second occurrence of these variables).

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t build the tags endpoint from the synthetic saved-post selectedBooru.

This page does not hold real booru metadata for every saved-post domain: DB-derived entries are created with type: booruTypeList[0] and config: null, and the first entry is effectively the all-domains view. Using selectedBooru.value.type.type and selectedBooru.value.config here means autocomplete can hit the wrong adapter/scheme for non-default domains, and the default view can only suggest tags from one booru instead of the full saved-post set.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/premium/saved-posts/`[domain].vue around lines 251 - 266, The code
builds the tags endpoint using the synthetic saved-post object (apiUrl using
selectedBooru.value.type.type and passing selectedBooru.value.config), which can
point to the wrong adapter/scheme; instead, call the tags route in a way that
uses the real booru metadata or lets the backend resolve adapter by domain: stop
using selectedBooru.value.type.type and selectedBooru.value.config when forming
apiUrl and params, send only the domain (selectedBooru.value.domain /
baseEndpoint) to the server (or use the unified '/booru/tags' route without a
specific type) and, if you must pass an httpScheme, derive it from the actual
booru config lookup (find the real booru entry by domain) and only include it
when present so autocomplete queries use the correct adapter/scheme rather than
the synthetic saved-post defaults.

})
} 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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 tagResults here and let the menu render its empty state instead.

💡 Minimal fix
          case 404:
-            toast.error('No tags found for query "' + trimmedTag + '"')
             tagResults.value = []
             break
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (error instanceof FetchError) {
switch (error.status) {
case 404:
toast.error('No tags found for query "' + trimmedTag + '"')
tagResults.value = []
break
if (error instanceof FetchError) {
switch (error.status) {
case 404:
tagResults.value = []
break
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/premium/saved-posts/`[domain].vue around lines 288 - 293, The 404
branch in the FetchError handler currently shows a toast for no-match
autocomplete queries; instead treat this as an empty-state: remove the
toast.error call and simply set tagResults.value = [] (keep the existing break),
so in the catch handling for FetchError (the switch on error.status) you only
clear tagResults for case 404 and let the autocomplete render its empty state;
update the block around the FetchError check (references: FetchError,
error.status switch, trimmedTag, and tagResults) accordingly.


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')
}
})
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
}

tagResults.value = response.data
}

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

/**
* 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() {
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
})
Expand Down