Skip to content
Open
Show file tree
Hide file tree
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
7 changes: 7 additions & 0 deletions composables/useUserSettings.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { useLocalStorage } from '@vueuse/core'

export type PostsLayout = 'list' | 'grid'

export default function () {
let postFullSizeImages = ref<boolean>(false)
let postsPerPage = ref<number>(29)
let postsLayout = ref<PostsLayout>('list')
let autoplayAnimatedMedia = ref<boolean>(false)
let blockAiGeneratedImages = ref<boolean>(false)

Expand All @@ -13,6 +16,9 @@ export default function () {
postsPerPage = useLocalStorage('settings-postsPerPage', 29, {
writeDefaults: false
})
postsLayout = useLocalStorage<PostsLayout>('settings-postsLayout', 'list', {
writeDefaults: false
})
Comment on lines +19 to +21
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.

🧹 Nitpick | 🔵 Trivial

Consider validating stored value against allowed options.

If localStorage contains an invalid value (e.g., user manually edited it), the ref will hold that invalid value. This could cause unexpected behavior in components that switch on postsLayout.

🛡️ Optional defensive validation
+const validLayouts: PostsLayout[] = ['list', 'grid']
+
 if (import.meta.client) {
   // ... other settings ...
-  postsLayout = useLocalStorage<PostsLayout>('settings-postsLayout', 'list', {
-    writeDefaults: false
-  })
+  const storedLayout = useLocalStorage<PostsLayout>('settings-postsLayout', 'list', {
+    writeDefaults: false
+  })
+  // Reset to default if invalid value stored
+  if (!validLayouts.includes(storedLayout.value)) {
+    storedLayout.value = 'list'
+  }
+  postsLayout = storedLayout
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@composables/useUserSettings.ts` around lines 19 - 21, The postsLayout ref
created via useLocalStorage<PostsLayout>('settings-postsLayout', 'list', ...)
can end up with an invalid string if localStorage was tampered with; update
useUserSettings to validate the stored value against the allowed PostsLayout
union (e.g., 'list'|'grid') when initializing postsLayout and whenever it
changes, and if the value is not one of the allowed options, replace it with the
default ('list')—implement this by adding a small validator/helper inside
useUserSettings that checks postsLayout (and/or useLocalStorage's initial read)
and assigns the default when invalid so downstream consumers of postsLayout
always receive a safe value.

autoplayAnimatedMedia = useLocalStorage('settings-autoplayAnimatedMedia', false, {
writeDefaults: false
})
Expand All @@ -24,6 +30,7 @@ export default function () {
return {
postFullSizeImages,
postsPerPage,
postsLayout,
autoplayAnimatedMedia,
blockAiGeneratedImages
}
Expand Down
251 changes: 171 additions & 80 deletions pages/posts/[domain].vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script lang="ts" setup>
import { Bars3BottomRightIcon, EyeIcon, MagnifyingGlassIcon, StarIcon } from '@heroicons/vue/24/outline'
import { Bars3BottomRightIcon, Bars3Icon, EyeIcon, MagnifyingGlassIcon, Squares2X2Icon, StarIcon } from '@heroicons/vue/24/outline'
import { ArrowPathIcon, QuestionMarkCircleIcon } from '@heroicons/vue/24/solid'
import { useInfiniteQuery } from '@tanstack/vue-query'
import { useWindowVirtualizer } from '@tanstack/vue-virtual'
Expand All @@ -18,7 +18,7 @@
const route = useRoute()
const config = useRuntimeConfig()

const { postsPerPage } = useUserSettings()
const { postsPerPage, postsLayout } = useUserSettings()
const { isPremium } = useUserData()
const { hasInteracted } = useInteractionDetector()
const { booruList } = useBooruList()
Expand Down Expand Up @@ -375,6 +375,12 @@
window.location.reload()
}

const isGridPostsLayout = computed(() => postsLayout.value === 'grid')

function togglePostsLayout() {
postsLayout.value = postsLayout.value === 'list' ? 'grid' : 'list'
}

/**
* Data fetching
*/
Expand Down Expand Up @@ -610,6 +616,11 @@
return
}

// Grid layout uses a manual "Load more" button
if (postsLayout.value !== 'list') {
return
}

// Skip if there is no data
if (!allRows.value) {
return
Expand Down Expand Up @@ -934,11 +945,34 @@
</template>
</PageHeader>

<!-- TODO: strip page -->
<ShareButton
:title="completeTitle"
class="my-auto p-3"
/>
<div class="my-auto flex items-center gap-1">
<button
:aria-label="`Switch to ${isGridPostsLayout ? 'list' : 'grid'} layout`"
class="focus-visible:focus-outline-util hover:hover-text-util hover:hover-bg-util ring-base-0/20 rounded-md p-2 ring-1"
type="button"
@click="togglePostsLayout"
>
<span class="sr-only"> Toggle posts layout </span>

<Bars3Icon
v-if="isGridPostsLayout"
aria-hidden="true"
class="h-5 w-5"
/>

<Squares2X2Icon
v-else
aria-hidden="true"
class="h-5 w-5"
/>
</button>

<!-- TODO: strip page -->
<ShareButton
:title="completeTitle"
class="p-3"
/>
</div>
</div>

<section class="my-4">
Expand All @@ -958,7 +992,7 @@
</template>

<!-- Error (initial load only) -->
<template v-else-if="isError && !allRows.length">
<template v-else-if="isError && !allRows.length && !isBlockedTagSelected && !hasHiddenPosts">
<PostPageError
:error="error"
:on-retry="onRetryClick"
Expand All @@ -976,7 +1010,17 @@

<h3 class="text-lg leading-10 font-semibold">No results</h3>

<span class="w-full overflow-x-auto text-pretty">Try changing the tags or filters</span>
<span class="w-full overflow-x-auto text-pretty">
<template v-if="isBlockedTagSelected">
Your selected tag is in your blocklist
</template>
<template v-else-if="hasHiddenPosts">
Results were hidden by your tag blocklist
</template>
<template v-else>
Try changing the tags or filters
</template>
</span>
</div>
</template>

Expand All @@ -989,88 +1033,135 @@

<!-- TODO: Animate adding posts https://vuejs.org/guide/built-ins/transition-group.html#staggering-list-transitions -->

<div
:style="{
height: `${totalSize}px`,
width: '100%',
position: 'relative'
}"
>
<!-- TODO: Fix SSR mismatches -->
<ol
<template v-if="!isGridPostsLayout">
<div
:style="{
position: 'absolute',
top: 0,
left: 0,
height: `${totalSize}px`,
width: '100%',
transform: `translateY(${virtualRows[0]?.start - rowVirtualizer.options.scrollMargin}px)`
position: 'relative'
}"
class="space-y-4"
>
<li
v-for="virtualRow in virtualRows"
:key="virtualRow.key"
:ref="measureElement"
:data-index="virtualRow.index"
<!-- TODO: Fix SSR mismatches -->
<ol
:style="{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRows[0]?.start - rowVirtualizer.options.scrollMargin}px)`
}"
class="space-y-4"
>
<!-- Next Pagination -->
<div
v-if="virtualRow.index > allRows.length - 1"
class="text-base-content flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium"
<li
v-for="virtualRow in virtualRows"
:key="virtualRow.key"
:ref="measureElement"
:data-index="virtualRow.index"
>
<!-- Error loading next page -->
<div v-if="isFetchNextPageError">
<PostPageError
:error="error"
:on-retry="fetchNextPage"
class="my-12"
/>
<!-- Next Pagination -->
<div
v-if="virtualRow.index > allRows.length - 1"
class="text-base-content flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium"
>
<!-- Error loading next page -->
<div v-if="isFetchNextPageError">
<PostPageError
:error="error"
:on-retry="fetchNextPage"
class="my-12"
/>
</div>

<!-- Normal pagination states -->
<span
v-else
class="block rounded-md px-1.5 py-1"
>
<template v-if="isFetching"> Loading more... </template>

<template v-else-if="hasNextPage"> Reach here to load more </template>
</span>
</div>

<!-- Normal pagination states -->
<span
v-else
class="block rounded-md px-1.5 py-1"
>
<template v-if="isFetching"> Loading more... </template>

<template v-else-if="hasNextPage"> Reach here to load more </template>
</span>
</div>

<!-- Content -->
<template v-else>
<!-- Page indicator -->
<!-- TODO: Show individually, not attached to a post-->
<button
v-if="virtualRow.index !== 0 && allRows[virtualRow.index].isFirstPost"
class="hover:hover-text-util hover:hover-bg-util focus-visible:focus-outline-util mx-auto mb-4 block rounded-md px-1.5 py-1 text-sm"
type="button"
@click="onPageIndicatorClick"
>
&dharl; Page {{ allRows[virtualRow.index].current_page }} &dharr;
</button>

<!-- Post -->
<!-- Fix: use domain + post.id as unique key, since virtualRow.index could be the same on different Boorus/pages -->
<PostComponent
:key="selectedBooru.domain + '-' + allRows[virtualRow.index].id"
:post="allRows[virtualRow.index]"
:postIndex="virtualRow.index"
:selectedTags="selectedTags"
@addTag="onPostAddTag"
@openTagInNewTab="onPostOpenTagInNewTab"
@setTag="onPostSetTag"
/>

<!-- Promoted content -->
<template v-if="!isPremium && virtualRow.index !== 0 && virtualRow.index % 7 === 0">
<PromotedContent class="mt-4" />
<!-- Content -->
<template v-else>
<!-- Page indicator -->
<!-- TODO: Show individually, not attached to a post-->
<button
v-if="virtualRow.index !== 0 && allRows[virtualRow.index].isFirstPost"
class="hover:hover-text-util hover:hover-bg-util focus-visible:focus-outline-util mx-auto mb-4 block rounded-md px-1.5 py-1 text-sm"
type="button"
@click="onPageIndicatorClick"
>
&dharl; Page {{ allRows[virtualRow.index].current_page }} &dharr;
</button>

<!-- Post -->
<!-- Fix: use domain + post.id as unique key, since virtualRow.index could be the same on different Boorus/pages -->
<PostComponent
:key="selectedBooru.domain + '-' + allRows[virtualRow.index].id"
:post="allRows[virtualRow.index]"
:postIndex="virtualRow.index"
:selectedTags="selectedTags"
@addTag="onPostAddTag"
@openTagInNewTab="onPostOpenTagInNewTab"
@setTag="onPostSetTag"
/>

<!-- Promoted content -->
<template v-if="!isPremium && virtualRow.index !== 0 && virtualRow.index % 7 === 0">
<PromotedContent class="mt-4" />
</template>
</template>
</template>
</li>
</ol>
</div>
</template>

<template v-else>
<ol class="grid grid-cols-2 gap-4 sm:grid-cols-3">
<li
v-for="(post, postIndex) in allRows"
:key="selectedBooru.domain + '-' + post.id"
>
<PostComponent
:post="post"
:postIndex="postIndex"
:selectedTags="selectedTags"
@addTag="onPostAddTag"
@openTagInNewTab="onPostOpenTagInNewTab"
@setTag="onPostSetTag"
/>

</li>
</ol>
Comment on lines +1121 to 1137
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.

🧹 Nitpick | 🔵 Trivial

Grid layout does not virtualize, which may impact performance with many posts.

The grid renders all posts in allRows without virtualization. With manual "Load more", this is acceptable, but users loading many pages may experience degraded performance.

Consider documenting this as a known limitation or adding a warning/limit on how many pages can be loaded in grid mode.

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

In `@pages/posts/`[domain].vue around lines 1121 - 1137, The grid template
rendering allRows via the v-for in the template v-else (the OL that instantiates
PostComponent for each post) is unvirtualized and can cause performance issues
when many pages are loaded; either document this limitation and add a UI
warning/soft cap on how many pages can be loaded in grid mode (e.g., disable
"Load more" after N pages and show a message) or implement virtualization for
the grid (replace the v-for over allRows with a virtual-scroller component such
as vue-virtual-scroller / VirtualList that renders PostComponent on demand);
update the code managing allRows/pagination and the UI around the grid (the OL +
PostComponent usage and the logic that appends to allRows) to enforce the cap or
integrate the virtual list so only visible items are mounted.

</div>

<div
v-if="isFetchNextPageError"
class="mt-4"
>
<PostPageError
:error="error"
:on-retry="fetchNextPage"
class="my-12"
/>
</div>

<div
v-else-if="hasNextPage"
class="text-base-content mt-4 flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium"
>
<button
:disabled="isFetching || isFetchingNextPage"
class="hover:hover-bg-util focus-visible:focus-outline-util hover:hover-text-util ring-base-0/20 rounded-md px-3 py-1.5 ring-1 disabled:opacity-60"
type="button"
@click="onLoadNextPostPage"
>
<template v-if="isFetching || isFetchingNextPage"> Loading more... </template>
<template v-else> Load more </template>
</button>
</div>
</template>

<!-- Nothing more to load message -->
<div
Expand Down
16 changes: 15 additions & 1 deletion pages/settings.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@

const appVersion = version

const { postFullSizeImages, postsPerPage, autoplayAnimatedMedia, blockAiGeneratedImages } = useUserSettings()
const { postFullSizeImages, postsPerPage, postsLayout, autoplayAnimatedMedia, blockAiGeneratedImages } =
useUserSettings()
const { isPremium } = useUserData()
const { selectedList, selectedBlockList, defaultBlockList, customBlockList, resetCustomBlockList } = useBlockLists()
const postsLayoutOptions = ['list', 'grid']
Comment on lines +16 to +20
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.

🧹 Nitpick | 🔵 Trivial

Consider deriving options from the PostsLayout type.

The postsLayoutOptions array duplicates the values from PostsLayout type. If the type changes, this array could fall out of sync.

♻️ Suggested improvement for type safety
+import type { PostsLayout } from '~/composables/useUserSettings'
+
 const { postFullSizeImages, postsPerPage, postsLayout, autoplayAnimatedMedia, blockAiGeneratedImages } =
   useUserSettings()
 const { isPremium } = useUserData()
 const { selectedList, selectedBlockList, defaultBlockList, customBlockList, resetCustomBlockList } = useBlockLists()
-const postsLayoutOptions = ['list', 'grid']
+const postsLayoutOptions: PostsLayout[] = ['list', 'grid']
📝 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
const { postFullSizeImages, postsPerPage, postsLayout, autoplayAnimatedMedia, blockAiGeneratedImages } =
useUserSettings()
const { isPremium } = useUserData()
const { selectedList, selectedBlockList, defaultBlockList, customBlockList, resetCustomBlockList } = useBlockLists()
const postsLayoutOptions = ['list', 'grid']
import type { PostsLayout } from '~/composables/useUserSettings'
const { postFullSizeImages, postsPerPage, postsLayout, autoplayAnimatedMedia, blockAiGeneratedImages } =
useUserSettings()
const { isPremium } = useUserData()
const { selectedList, selectedBlockList, defaultBlockList, customBlockList, resetCustomBlockList } = useBlockLists()
const postsLayoutOptions: PostsLayout[] = ['list', 'grid']
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/settings.vue` around lines 16 - 20, postsLayoutOptions is hardcoded and
duplicates the PostsLayout type, risking divergence; replace the literal array
with a derived list from the PostsLayout type/enum (e.g., use
Object.values(PostsLayout) or map its keys) so options always reflect the type,
and import/ensure PostsLayout is available in pages/settings.vue; update any
usage assuming strings if PostsLayout is an enum so types line up with
postsLayoutOptions.


function onSelectedListChange(value: blockListOptions) {
if (value === blockListOptions.Custom && !isPremium.value) {
Expand Down Expand Up @@ -166,6 +168,18 @@
</SettingNumber>
</li>

<!-- postsLayout -->
<li>
<SettingSelect
v-model="postsLayout"
:options="postsLayoutOptions"
>
<template #name> Posts layout</template>

<template #description> Choose how posts are displayed by default </template>
</SettingSelect>
</li>

<!-- BlockList -->
<li>
<SettingSelect
Expand Down