-
-
Notifications
You must be signed in to change notification settings - Fork 44
feat: add posts layout preference #100
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
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 |
|---|---|---|
| @@ -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' | ||
|
|
@@ -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() | ||
|
|
@@ -375,6 +375,12 @@ | |
| window.location.reload() | ||
| } | ||
|
|
||
| const isGridPostsLayout = computed(() => postsLayout.value === 'grid') | ||
|
|
||
| function togglePostsLayout() { | ||
| postsLayout.value = postsLayout.value === 'list' ? 'grid' : 'list' | ||
| } | ||
|
|
||
| /** | ||
| * Data fetching | ||
| */ | ||
|
|
@@ -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 | ||
|
|
@@ -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"> | ||
|
|
@@ -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" | ||
|
|
@@ -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> | ||
|
|
||
|
|
@@ -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" | ||
| > | ||
| ⇃ Page {{ allRows[virtualRow.index].current_page }} ⇂ | ||
| </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" | ||
| > | ||
| ⇃ Page {{ allRows[virtualRow.index].current_page }} ⇂ | ||
| </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
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. 🧹 Nitpick | 🔵 Trivial Grid layout does not virtualize, which may impact performance with many posts. The grid renders all posts in 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 |
||
| </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 | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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
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. 🧹 Nitpick | 🔵 Trivial Consider deriving options from the The ♻️ 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| function onSelectedListChange(value: blockListOptions) { | ||||||||||||||||||||||||||
| if (value === blockListOptions.Custom && !isPremium.value) { | ||||||||||||||||||||||||||
|
|
@@ -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 | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
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.
🧹 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
🤖 Prompt for AI Agents