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:
@@ -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>
|
||||
|
||||
35
src/shared/composables/useBodyScrollLock.ts
Normal file
35
src/shared/composables/useBodyScrollLock.ts
Normal 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
100
src/widgets/Blog/context.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
3
src/widgets/Blog/index.ts
Normal file
3
src/widgets/Blog/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './context';
|
||||
export { default as BlogHeader } from './ui/BlogHeader.vue';
|
||||
export { default as BlogList } from './ui/BlogList.vue';
|
||||
92
src/widgets/Blog/ui/BlogHeader.vue
Normal file
92
src/widgets/Blog/ui/BlogHeader.vue
Normal 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>
|
||||
62
src/widgets/Blog/ui/BlogList.vue
Normal file
62
src/widgets/Blog/ui/BlogList.vue
Normal 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>
|
||||
118
src/widgets/Blog/ui/BlogModal.vue
Normal file
118
src/widgets/Blog/ui/BlogModal.vue
Normal 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>
|
||||
Reference in New Issue
Block a user