1
0
mirror of https://github.com/robonen/lorem-blog.git synced 2026-03-20 02:44:39 +00:00

feat(blog): implement BlogHeader and BlogList components, add post fetching and filtering logic

This commit is contained in:
2025-06-15 15:40:54 +07:00
parent 8e408ead25
commit 35104cdbc8
7 changed files with 425 additions and 1 deletions

View File

@@ -1,3 +1,17 @@
<script setup lang="ts">
import { BlogHeader, useProvidingPosts } from '@/widgets/Blog';
import { BlogList} from '@/widgets/Blog';
useProvidingPosts(() => fetch('/posts.json').then((response) => {
if (!response.ok) {
throw new Error('Failed to fetch posts');
}
return response.json();
}));
</script>
<template>
Blog
<BlogHeader />
<BlogList />
</template>

View File

@@ -0,0 +1,35 @@
import { type MaybeRefOrGetter, onScopeDispose, toValue, watchEffect } from 'vue';
export function useBodyScrollLock(isLocked: MaybeRefOrGetter<boolean>) {
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,
};
}

100
src/widgets/Blog/context.ts Normal file
View File

@@ -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<any>) => {
const posts = shallowRef<PostItem[]>([]);
const loading = ref(false);
const selectedPost = shallowRef<PostItem | null>(null);
const filters = reactive({
search: '',
tags: new Set<string>(),
});
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<string>();
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,
};
});

View File

@@ -0,0 +1,3 @@
export * from './context';
export { default as BlogHeader } from './ui/BlogHeader.vue';
export { default as BlogList } from './ui/BlogList.vue';

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { ref } from 'vue';
import { ArrowIcon, CheckIcon, MagnifyingGlassIcon, PlusIcon } from '@/shared/icons';
import { Badge } from '@/shared/ui/Badge';
import { Button } from '@/shared/ui/Button';
import { Input } from '@/shared/ui/Input';
import { useInjectedPosts } from '../context';
const filtersVisible = ref(false);
const { filters, filtersActive, availableTags, toggleTag, isTagActive, resetFilters } = useInjectedPosts()!;
</script>
<template>
<div class="bg-surface text-surface-foreground py-6">
<div class="mx-auto px-2 container" :class="$style.container">
<h1 class="text-3xl font-bold text-gray-900" :class="$style.title">
Блог
</h1>
<div class="relative" :class="$style.search">
<Input v-model="filters.search" name="search" placeholder="Поиск" class="pl-8" />
<span class="absolute start-0 inset-y-0 flex items-center justify-center px-2" aria-hidden="true">
<MagnifyingGlassIcon class="size-4 text-gray-400" />
</span>
</div>
<div class="flex justify-end gap-2" :class="$style.filters">
<Button
v-if="filtersActive"
variant="ghost"
class="flex items-center gap-2 text-sm text-primary-dark"
@click="resetFilters"
>
Очистить
</Button>
<Button
variant="ghost"
class="flex items-center gap-2 text-sm text-foreground-muted"
@click="filtersVisible = !filtersVisible"
>
{{ filtersVisible ? 'Скрыть фильтры' : 'Фильтры' }}
<ArrowIcon class="size-4 transition-transform" :class="{ 'rotate-180': filtersVisible }" />
</Button>
</div>
</div>
</div>
<div v-if="filtersVisible" class="bg-surface text-surface-foreground pb-6">
<div class="mx-auto px-2 container flex flex-wrap gap-2">
<Button
v-for="tag in availableTags"
:key="tag"
variant="ghost"
@click="toggleTag(tag)"
>
<Badge :variant="isTagActive(tag) ? 'primary' : 'secondary'" class="flex items-center gap-2">
{{ tag }}
<PlusIcon v-if="!isTagActive(tag)" class="size-4" />
<CheckIcon v-else class="size-4" />
</Badge>
</Button>
</div>
</div>
</template>
<style module>
.container {
display: grid;
grid-template-areas: 'title search filters';
grid-template-columns: auto 30% 1fr;
column-gap: calc(var(--spacing) * 10);
row-gap: calc(var(--spacing) * 2);
}
@media (max-width: 768px) {
.container {
grid-template-areas:
'title filters'
'search search';
grid-template-columns: 1fr;
}
}
.title {
grid-area: title;
}
.search {
grid-area: search;
}
.filters {
grid-area: filters;
}
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { PostContent, PostMeta, PostTitle } from '@/entities/Post';
import { SearchListIcon } from '@/shared/icons';
import { Badge } from '@/shared/ui/Badge';
import { Button } from '@/shared/ui/Button';
import { useInjectedPosts } from '../context';
import BlogModal from './BlogModal.vue';
const { loading, filteredPosts, selectedPost } = useInjectedPosts()!;
</script>
<template>
<BlogModal />
<div
v-if="filteredPosts.length > 0"
class="mx-auto container mt-4 p-4 md:p-7 bg-surface text-surface-foreground rounded-lg grid md:grid-cols-2 xl:grid-cols-3 gap-x-5 gap-y-10"
>
<Button
v-for="post in filteredPosts"
:key="post.id"
variant="ghost"
@click="selectedPost = post"
>
<figure class="flex flex-col gap-y-2 text-start">
<img
class="rounded-lg object-cover aspect-[1.6]"
:src="post.cover"
:alt="post.title"
>
<figcaption class="contents">
<PostMeta
:created-at="post.created_at"
:content="post.content_full"
:comments-length="post.comments.length"
/>
<PostTitle as="h2">
{{ post.title }}
</PostTitle>
<PostContent>
{{ post.content_short }}
</PostContent>
<div class="flex items-center gap-x-2">
<Badge v-for="tag in post.tags" :key="tag">
{{ tag }}
</Badge>
</div>
</figcaption>
</figure>
</Button>
</div>
<div v-else-if="!loading" class="container mx-auto mt-4 p-16 bg-surface text-surface-foreground rounded-lg">
<SearchListIcon class="mx-auto size-12 text-gray-400" />
<div class="mx-auto max-w-[300px] text-sm text-center">
<h2 class="text-foreground-muted font-semibold mt-4">
Поиск не дал результатов
</h2>
<p class="text-foreground-muted-light">
Повторите поиск или используйте фильтр для структуризации контента
</p>
</div>
</div>
</template>

View File

@@ -0,0 +1,118 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import Comment from '@/entities/Comment/ui/Comment.vue';
import { PostMeta, PostTitle } from '@/entities/Post';
import { useBodyScrollLock } from '@/shared/composables';
import { XIcon } from '@/shared/icons';
import { Badge } from '@/shared/ui/Badge';
import { Button } from '@/shared/ui/Button';
import { Textarea } from '@/shared/ui/Textarea';
import { useInjectedPosts } from '../context';
const { selectedPost } = useInjectedPosts()!;
const visible = computed({
get: () => !!selectedPost.value,
set: (value) => {
if (!value)
selectedPost.value = null;
},
});
useBodyScrollLock(visible);
const MAX_COMMENT_LENGTH = 250;
const comment = ref<string>('');
const commentLength = computed(() => comment.value.length);
const isCommentValid = computed(() => commentLength.value <= MAX_COMMENT_LENGTH);
function clearComment() {
comment.value = '';
}
</script>
<template>
<Teleport v-if="visible" to="#teleports">
<div class="fixed inset-0 bg-black/50 modal-backdrop" />
<div class="fixed inset-0 flex" @click.self="visible = false">
<div class="p-4 max-h-screen md:max-h-[98vh] w-full md:max-w-2xl md:m-auto md:rounded-lg overflow-auto flex flex-col gap-4 bg-surface text-surface-foreground relative">
<Button
variant="ghost"
class="absolute top-4 right-4"
@click="visible = false"
>
<XIcon class="size-5" />
<span class="sr-only">Закрыть</span>
</Button>
<div class="flex flex-col gap-2">
<PostTitle>
{{ selectedPost!.title }}
</PostTitle>
<PostMeta
:created-at="selectedPost!.created_at"
:content="selectedPost!.content_full"
:comments-length="selectedPost!.comments.length"
/>
</div>
<img
class="h-full aspect-video object-cover rounded-lg"
:src="selectedPost!.cover"
:alt="selectedPost!.title"
>
<div class="flex flex-col gap-2">
<p>
{{ selectedPost!.content_full }}
</p>
<div class="flex gap-2">
<Badge v-for="tag in selectedPost!.tags" :key="tag">
{{ tag }}
</Badge>
</div>
</div>
<div class="flex flex-col gap-2">
<div>
<h2 class="inline font-semibold text-gray-900">
Комментариев
</h2>
<span class="text-foreground-muted">{{ selectedPost!.comments.length }}</span>
</div>
<div>
<Textarea
v-model="comment"
name="comment"
placeholder="Введите комментарий"
:class="{ 'border-danger! outline-danger!': !isCommentValid }"
/>
<span v-if="commentLength > 0" class="text-xs text-foreground-muted">
<span :class="{ 'text-danger': !isCommentValid }">
{{ commentLength }}
</span>
из {{ MAX_COMMENT_LENGTH }} символов
</span>
<div v-if="commentLength > 0" class="flex justify-end gap-2">
<Button variant="secondary" @click="clearComment">
Отмена
</Button>
<Button :disabled="!isCommentValid">
Опубликовать
</Button>
</div>
</div>
<div class="mt-2 flex flex-col gap-4">
<Comment
v-for="postComment in selectedPost!.comments"
:id="postComment.id"
:key="postComment.id"
:name="postComment.name"
:avatar="postComment.avatar"
:created_at="postComment.created_at"
>
{{ postComment.text }}
</Comment>
</div>
</div>
</div>
</div>
</Teleport>
</template>