Skip to content
Merged
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
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"dependencies": {
"dompurify": "^3.2.6",
"highlight.js": "^11.11.1",
"lodash": "^4.17.21",
"marked": "^16.0.0",
"pinia": "^3.0.2",
"vue": "^3.5.13",
Expand All @@ -23,6 +24,7 @@
"@eslint/js": "^9.25.1",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/vite": "^4.1.4",
"@types/lodash": "^4.17.20",
"@types/node": "^22.14.1",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
Expand Down
2 changes: 1 addition & 1 deletion src/pages/PostPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
<!-- Post content -->
<div class="text-slate-500 dark:text-slate-400 space-y-8">
<p>{{ post.excerpt }}</p>
<img class="w-full" :src="post.cover_image_url" width="692" height="390" :alt="post.title" />
<img class="w-full" :src="post.cover_image_url" width="692" height="390" :alt="post.title" fetchpriority="high" aria-hidden="true" />
<div ref="postContainer" class="space-y-4" v-html="htmlContent"></div>
</div>
</article>
Expand Down
11 changes: 10 additions & 1 deletion src/partials/ArticleItemPartial.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
<template>
<article v-if="item" class="py-5 border-b border-slate-100 dark:border-slate-800">
<div class="flex items-start">
<img class="rounded-sm w-16 h-16 sm:w-[88px] sm:h-[88px] object-cover mr-6" :src="item.cover_image_url" width="88" height="88" :alt="item.title" />
<img
class="rounded-sm w-16 h-16 sm:w-[88px] sm:h-[88px] object-cover mr-6"
:src="item.cover_image_url"
width="88"
height="88"
:alt="item.title"
loading="lazy"
decoding="async"
fetchpriority="low"
/>
<div>
<div class="text-xs text-slate-700 uppercase mb-1 dark:text-slate-500">
{{ date().format(new Date(item.published_at)) }}
Expand Down
80 changes: 64 additions & 16 deletions src/partials/ArticlesListPartial.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@

<!-- Filters -->
<ul class="flex flex-wrap text-sm border-b border-slate-100 dark:border-slate-800">
<li class="px-3 -mb-px">
<a class="block py-3 font-medium text-slate-500 border-b-2 border-fuchsia-500 dark:text-slate-300 dark:border-teal-500" href="#">Coding</a>
</li>
<li class="px-3 -mb-px">
<a class="block py-3 text-slate-500 hover:text-slate-600 dark:text-slate-400 dark:hover:text-slate-300" href="#">Startups</a>
</li>
<li class="px-3 -mb-px">
<a class="block py-3 text-slate-500 hover:text-slate-600 dark:text-slate-400 dark:hover:text-slate-300" href="#">Tutorials</a>
</li>
<li class="px-3 -mb-px">
<a class="block py-3 text-slate-500 hover:text-slate-600 dark:text-slate-400 dark:hover:text-slate-300" href="#">Indie Hacking</a>
<li v-for="category in categories" :key="category.uuid" class="px-3 -mb-px">
<a
href="#"
:class="
filters.category === category.slug
? 'text-slate-800 border-fuchsia-500 dark:text-slate-200 dark:border-teal-500'
: 'text-slate-500 border-transparent hover:border-slate-300 dark:text-slate-300 dark:hover:border-slate-700'
"
class="block py-3 font-medium border-b-2"
@click.prevent="selectCategory(category.slug)"
>{{ category.name }}</a
>
</li>
</ul>

Expand All @@ -26,20 +27,67 @@
</template>

<script setup lang="ts">
import { onMounted, ref } from 'vue';
import debounce from 'lodash/debounce';
import { useApiStore } from '@api/store.ts';
import { debugError } from '@api/http-error.ts';
import { onMounted, reactive, ref, watch } from 'vue';
import ArticleItemPartial from '@partials/ArticleItemPartial.vue';
import type { PostResponse, PostsCollectionResponse } from '@api/response/posts-response.ts';
import type { PostResponse, PostsCollectionResponse, PostsFilters } from '@api/response/posts-response.ts';
import { CategoriesCollectionResponse, CategoryResponse } from '@api/response/categories-response.ts';

const apiStore = useApiStore();
const collection = ref<PostsCollectionResponse>();
const items = ref<PostResponse[]>([]);

const categoriesCollection = ref<CategoriesCollectionResponse>();
const categories = ref<CategoryResponse[]>([]);

const selectCategory = (categorySlug: string) => {
filters.category = categorySlug;
};

const filters = reactive<PostsFilters>({
category: '',
text: '',
});

const fetchPosts = async () => {
try {
const collection: PostsCollectionResponse = await apiStore.getPosts(filters);

items.value = collection.data as PostResponse[];
} catch (error) {
debugError(error);
}
};

// --- Categories' Filter:
watch(
() => filters.category,
debounce(() => {
fetchPosts();
}, 500),
);

// --- Search: filter post by the given search criteria.
watch(
() => apiStore.searchTerm,
(newSearchTerm: string): void => {
filters.text = newSearchTerm.trim();
fetchPosts();
},
);

// --- Mount the Vue component
onMounted(async () => {
try {
collection.value = await apiStore.getPosts();
items.value = collection.value.data as PostResponse[];
categoriesCollection.value = await apiStore.getCategories();
categories.value = categoriesCollection.value.data as CategoryResponse[];

if (categories.value.length > 0) {
filters.category = categories.value[0].slug;

await fetchPosts();
}
} catch (error) {
debugError(error);
}
Expand Down
2 changes: 1 addition & 1 deletion src/partials/AvatarPartial.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<img :class="className" :src="props.avatar" :alt="props.alt" />
<img :class="className" :src="props.avatar" :alt="props.alt" fetchpriority="high" aria-hidden="true" />
</template>

<script setup lang="ts">
Expand Down
45 changes: 42 additions & 3 deletions src/partials/HeaderPartial.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@
<div class="flex items-center justify-between h-16 before:block">
<div class="grow flex justify-end space-x-4">
<!-- Search form -->
<form class="w-full max-w-[276px]">
<form class="w-full max-w-[276px]" @submit.prevent="performSearch">
<div class="flex flex-wrap">
<div class="w-full">
<label class="block text-sm sr-only" for="search">Search</label>
<div class="relative flex items-center">
<input id="search" type="search" class="form-input py-1 w-full pl-10" />
<input id="search" v-model="searchQuery" type="search" class="form-input py-1 w-full pl-10" :class="{ 'border-red-500': validationError }" @keyup="onSearchInput" />
<div class="absolute inset-0 right-auto flex items-center justify-center">
<svg class="w-4 h-4 shrink-0 mx-3" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<svg v-if="validationError" class="w-4 h-4 shrink-0 mx-3" viewBox="0 0 16 16" title="Clear search" @click="clearSearchAndError">
<path class="stroke-current text-red-500 cursor-pointer" stroke-width="2" stroke-linecap="round" d="M4 4l8 8m0-8l-8 8" />
</svg>
<svg v-else class="w-4 h-4 shrink-0 mx-3" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
<path
class="blog-header-search-icon"
d="M7 14c-3.86 0-7-3.14-7-7s3.14-7 7-7 7 3.14 7 7-3.14 7-7 7zM7 2C4.243 2 2 4.243 2 7s2.243 5 5 5 5-2.243 5-5-2.243-5-5-5zm8.707 12.293a.999.999 0 11-1.414 1.414L11.9 13.314a8.019 8.019 0 001.414-1.414l2.393 2.393z"
Expand Down Expand Up @@ -52,7 +55,43 @@
</template>

<script setup lang="ts">
import { ref } from 'vue';
import debounce from 'lodash/debounce';
import { useDarkMode } from '@/dark-mode.ts';
import { useApiStore } from '@api/store.ts';

const { toggleDarkMode } = useDarkMode();
const apiStore = useApiStore();

const searchQuery = ref('');
const validationError = ref<string>('');

const clearSearchAndError = () => {
onSearchInput.cancel();
searchQuery.value = '';
performSearch();
};

const onSearchInput = debounce(() => {
performSearch();
}, 500);

const performSearch = () => {
validationError.value = '';
const query = searchQuery.value.trim();

if (query === '') {
apiStore.setSearchTerm(query);

return;
}

if (query.length < 5) {
validationError.value = 'Search term must be at least 5 characters.';

return;
}

apiStore.setSearchTerm(query);
};
</script>
17 changes: 17 additions & 0 deletions src/stores/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,23 @@ export class ApiClient {
return headers;
}

public async post<T>(url: string, data: object): Promise<T> {
const headers = this.createHeaders();
const fullUrl = new URL(url, this.basedURL);

const response = await fetch(fullUrl.href, {
method: 'POST',
headers: headers,
body: JSON.stringify(data),
});

if (!response.ok) {
throw new HttpError(response, await response.text());
}

return await response.json();
}

public async get<T>(url: string): Promise<T> {
const headers = this.createHeaders();
const fullUrl = new URL(url, this.basedURL);
Expand Down
14 changes: 14 additions & 0 deletions src/stores/api/response/categories-response.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export interface CategoriesCollectionResponse {
page: number;
total: number;
page_size: number;
total_pages: number;
data: CategoryResponse[];
}

export interface CategoryResponse {
uuid: string;
name: string;
slug: string;
description: string;
}
8 changes: 8 additions & 0 deletions src/stores/api/response/posts-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,11 @@ export interface PostsTagResponse {
name: string;
description: string;
}

export interface PostsFilters {
text?: string;
title?: string;
author?: string;
category?: string;
tag?: string;
}
23 changes: 19 additions & 4 deletions src/stores/api/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,27 @@ import { defineStore } from 'pinia';
import { parseError } from '@api/http-error.ts';
import { ProfileResponse } from '@api/response/profile-response.ts';
import { ApiClient, ApiResponse, defaultCreds } from '@api/client.ts';
import type { PostResponse, PostsCollectionResponse } from '@api/response/posts-response.ts';
import type { PostResponse, PostsCollectionResponse, PostsFilters } from '@api/response/posts-response.ts';
import { CategoriesCollectionResponse } from '@api/response/categories-response.ts';

const STORE_KEY = 'api-client-store';

export interface ApiStoreState {
client: ApiClient;
searchTerm: string;
}

const client = new ApiClient(defaultCreds);

export const useApiStore = defineStore(STORE_KEY, {
state: (): ApiStoreState => ({
client: client,
searchTerm: '',
}),
actions: {
setSearchTerm(term: string): void {
this.searchTerm = term;
},
boot(): void {
if (this.client.isDev()) {
console.log('API client booted ...');
Expand All @@ -31,11 +37,20 @@ export const useApiStore = defineStore(STORE_KEY, {
return parseError(error);
}
},
async getPosts(): Promise<PostsCollectionResponse> {
const url = 'posts';
async getCategories(): Promise<CategoriesCollectionResponse> {
const url = 'categories?limit=5';

try {
return await this.client.get<CategoriesCollectionResponse>(url);
} catch (error) {
return parseError(error);
}
},
async getPosts(filters: PostsFilters): Promise<PostsCollectionResponse> {
const url = 'posts?limit=5';

try {
return await this.client.get<PostsCollectionResponse>(url);
return await this.client.post<PostsCollectionResponse>(url, filters);
} catch (error) {
return parseError(error);
}
Expand Down