From 35104cdbc87a5bb0b383c5f30d22d1ebcdb06a73 Mon Sep 17 00:00:00 2001 From: robonen Date: Sun, 15 Jun 2025 15:40:54 +0700 Subject: [PATCH] feat(blog): implement BlogHeader and BlogList components, add post fetching and filtering logic --- src/pages/blog.vue | 16 ++- src/shared/composables/useBodyScrollLock.ts | 35 ++++++ src/widgets/Blog/context.ts | 100 +++++++++++++++++ src/widgets/Blog/index.ts | 3 + src/widgets/Blog/ui/BlogHeader.vue | 92 +++++++++++++++ src/widgets/Blog/ui/BlogList.vue | 62 ++++++++++ src/widgets/Blog/ui/BlogModal.vue | 118 ++++++++++++++++++++ 7 files changed, 425 insertions(+), 1 deletion(-) create mode 100644 src/shared/composables/useBodyScrollLock.ts create mode 100644 src/widgets/Blog/context.ts create mode 100644 src/widgets/Blog/index.ts create mode 100644 src/widgets/Blog/ui/BlogHeader.vue create mode 100644 src/widgets/Blog/ui/BlogList.vue create mode 100644 src/widgets/Blog/ui/BlogModal.vue diff --git a/src/pages/blog.vue b/src/pages/blog.vue index ffaefa3..766bdb3 100644 --- a/src/pages/blog.vue +++ b/src/pages/blog.vue @@ -1,3 +1,17 @@ + + diff --git a/src/shared/composables/useBodyScrollLock.ts b/src/shared/composables/useBodyScrollLock.ts new file mode 100644 index 0000000..fbb3926 --- /dev/null +++ b/src/shared/composables/useBodyScrollLock.ts @@ -0,0 +1,35 @@ +import { type MaybeRefOrGetter, onScopeDispose, toValue, watchEffect } from 'vue'; + +export function useBodyScrollLock(isLocked: MaybeRefOrGetter) { + let originalOverflow: string | null = null; + + const lock = () => { + if (originalOverflow === null) + originalOverflow = document.body.style.overflow || ''; + + document.body.style.overflow = 'hidden'; + }; + + const unlock = () => { + if (originalOverflow !== null) { + document.body.style.overflow = originalOverflow; + originalOverflow = null; + } + }; + + watchEffect(() => { + if (toValue(isLocked)) + lock(); + else + unlock(); + }); + + onScopeDispose(() => { + unlock(); + }); + + return { + lock, + unlock, + }; +} diff --git a/src/widgets/Blog/context.ts b/src/widgets/Blog/context.ts new file mode 100644 index 0000000..483fe77 --- /dev/null +++ b/src/widgets/Blog/context.ts @@ -0,0 +1,100 @@ +import type { PostComment } from '@/entities/Comment'; +import type { Post } from '@/entities/Post'; +import { useInjectionStore } from '@robonen/vue'; +import { computed, reactive, ref, shallowRef } from 'vue'; +import { kmpSearch } from '@/shared/utils'; + +interface PostItem extends Post { + comments: PostComment[]; +} + +export const { + useProvidingState: useProvidingPosts, + useInjectedState: useInjectedPosts, +} = useInjectionStore((loader: () => Promise) => { + const posts = shallowRef([]); + const loading = ref(false); + + const selectedPost = shallowRef(null); + + const filters = reactive({ + search: '', + tags: new Set(), + }); + + const filtersActive = computed(() => { + return filters.search || filters.tags.size > 0; + }); + + const filteredPosts = computed(() => { + const filteredByTags = posts.value.filter((post) => { + if (filters.tags.size > 0) + return Array.from(filters.tags).every(tag => post.tags.includes(tag)); + + return true; + }); + + if (!filters.search) + return filteredByTags; + + return filteredByTags.filter(post => kmpSearch(post.title, filters.search)); + }); + + const availableTags = computed(() => { + const tagsSet = new Set(); + + posts.value.forEach((post) => { + post.tags.forEach(tag => tagsSet.add(tag)); + }); + + return Array.from(tagsSet); + }); + + const loadPosts = async () => { + loading.value = true; + + try { + const data = await loader(); + posts.value = data!.data as PostItem[]; + } + catch (error) { + console.error('Failed to load posts:', error); + } + finally { + loading.value = false; + } + }; + + function toggleTag(tag: string) { + if (filters.tags.has(tag)) + filters.tags.delete(tag); + else + filters.tags.add(tag); + } + + function isTagActive(tag: string) { + return filters.tags.has(tag); + } + + function resetFilters() { + filters.search = ''; + filters.tags.clear(); + } + + // Initial load + loadPosts(); + + return { + posts, + loading, + selectedPost, + filters, + filtersActive, + filteredPosts, + availableTags, + loadPosts, + toggleTag, + isTagActive, + resetFilters, + }; +}); diff --git a/src/widgets/Blog/index.ts b/src/widgets/Blog/index.ts new file mode 100644 index 0000000..a1ed493 --- /dev/null +++ b/src/widgets/Blog/index.ts @@ -0,0 +1,3 @@ +export * from './context'; +export { default as BlogHeader } from './ui/BlogHeader.vue'; +export { default as BlogList } from './ui/BlogList.vue'; \ No newline at end of file diff --git a/src/widgets/Blog/ui/BlogHeader.vue b/src/widgets/Blog/ui/BlogHeader.vue new file mode 100644 index 0000000..23940bb --- /dev/null +++ b/src/widgets/Blog/ui/BlogHeader.vue @@ -0,0 +1,92 @@ + + + + + diff --git a/src/widgets/Blog/ui/BlogList.vue b/src/widgets/Blog/ui/BlogList.vue new file mode 100644 index 0000000..6f90bb5 --- /dev/null +++ b/src/widgets/Blog/ui/BlogList.vue @@ -0,0 +1,62 @@ + + + diff --git a/src/widgets/Blog/ui/BlogModal.vue b/src/widgets/Blog/ui/BlogModal.vue new file mode 100644 index 0000000..63a87a9 --- /dev/null +++ b/src/widgets/Blog/ui/BlogModal.vue @@ -0,0 +1,118 @@ + + +