diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..33e28944 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# --- The environment where the api is running under. +# example: production, local, staging, etc. +VITE_API_ENV= + +# --- The oullin.io/api URL. +# https://github.com/oullin/api +VITE_API_URL= + +# --- The given application registered account name +VITE_ACCOUNT_NAME= + +# --- The given application public key +VITE_PUBLIC_KEY= + +# --- The given application API's utilises public cryptography to validate the origin of the requesters. +VITE_PUBLIC_SIGNATURE= diff --git a/eslint.config.js b/eslint.config.js index 48086769..7c49a9e4 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -72,7 +72,7 @@ export default [ }, { - files: ['src/partials/EducationPartial.vue', 'src/partials/RecommendationPartial.vue'], + files: ['src/partials/EducationPartial.vue', 'src/partials/RecommendationPartial.vue', 'src/pages/PostPage.vue'], rules: { 'vue/no-v-html': 'off', }, diff --git a/index.html b/index.html index 0027af26..5965519a 100644 --- a/index.html +++ b/index.html @@ -7,10 +7,8 @@ Gustavo Ocanto diff --git a/package-lock.json b/package-lock.json index de93582e..9cab21e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "dompurify": "^3.2.6", + "highlight.js": "^11.11.1", "marked": "^16.0.0", "pinia": "^3.0.2", "vue": "^3.5.13", @@ -2872,6 +2873,14 @@ "node": ">=8" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hookable": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", diff --git a/package.json b/package.json index 1d564718..bfd4f6dc 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "dompurify": "^3.2.6", + "highlight.js": "^11.11.1", "marked": "^16.0.0", "pinia": "^3.0.2", "vue": "^3.5.13", diff --git a/src/App.vue b/src/App.vue index 4437b0a6..1c6c8adc 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,3 +1,14 @@ + + diff --git a/src/dark-mode.ts b/src/dark-mode.ts new file mode 100644 index 00000000..da8241b9 --- /dev/null +++ b/src/dark-mode.ts @@ -0,0 +1,20 @@ +import { ref, watchEffect } from 'vue'; + +const isDark = ref(localStorage.getItem('dark-mode') === 'true'); + +export function useDarkMode() { + watchEffect(() => { + localStorage.setItem('dark-mode', String(isDark.value)); + + document.documentElement.classList.toggle('dark', isDark.value); + }); + + function toggleDarkMode(): void { + isDark.value = !isDark.value; + } + + return { + isDark, + toggleDarkMode, + }; +} diff --git a/src/pages/HomePage.vue b/src/pages/HomePage.vue index b3997690..9d777f00 100644 --- a/src/pages/HomePage.vue +++ b/src/pages/HomePage.vue @@ -44,19 +44,28 @@ import TalksPartial from '@partials/TalksPartial.vue'; import FooterPartial from '@partials/FooterPartial.vue'; import HeaderPartial from '@partials/HeaderPartial.vue'; import SideNavPartial from '@partials/SideNavPartial.vue'; +import WidgetSkillsPartial from '@partials/WidgetSkillsPartial.vue'; import ArticlesListPartial from '@partials/ArticlesListPartial.vue'; import WidgetSponsorPartial from '@partials/WidgetSponsorPartial.vue'; import FeaturedProjectsPartial from '@partials/FeaturedProjectsPartial.vue'; -import WidgetSkillsPartial from '@partials/WidgetSkillsPartial.vue'; -import { useUserStore } from '@stores/users/user.ts'; import { onMounted } from 'vue'; +import { useApiStore } from '@api/store.ts'; +import { debugError } from '@api/http-error.ts'; + +const apiStore = useApiStore(); + +onMounted(async () => { + console.log('Attempting to fetch user profile...'); -const userStore = useUserStore(); + try { + const userProfileResponse = await apiStore.getProfile(); -onMounted(() => { - userStore.onBoot(() => { - console.log('[home]: app booted...'); - }); + if (userProfileResponse.data) { + console.log(`Welcome, ${userProfileResponse.data.name}!`); + } + } catch (error) { + debugError(error); + } }); diff --git a/src/pages/PostPage.vue b/src/pages/PostPage.vue index 86dd651d..c13cad05 100644 --- a/src/pages/PostPage.vue +++ b/src/pages/PostPage.vue @@ -1,5 +1,5 @@ - diff --git a/src/partials/ArticleItemPartial.vue b/src/partials/ArticleItemPartial.vue index a83198d0..190037d3 100644 --- a/src/partials/ArticleItemPartial.vue +++ b/src/partials/ArticleItemPartial.vue @@ -1,13 +1,15 @@ diff --git a/src/partials/ArticlesListPartial.vue b/src/partials/ArticlesListPartial.vue index f758f3ef..e85364d8 100644 --- a/src/partials/ArticlesListPartial.vue +++ b/src/partials/ArticlesListPartial.vue @@ -19,102 +19,29 @@ -
- +
+
- diff --git a/src/partials/HeaderPartial.vue b/src/partials/HeaderPartial.vue index fb4a8c40..69715d1c 100644 --- a/src/partials/HeaderPartial.vue +++ b/src/partials/HeaderPartial.vue @@ -24,7 +24,7 @@
- +
- - diff --git a/src/public.ts b/src/public.ts index 2aa52f86..67ddb5ae 100644 --- a/src/public.ts +++ b/src/public.ts @@ -15,3 +15,30 @@ export function date(language?: string, options?: Intl.DateTimeFormatOptions): I return new Intl.DateTimeFormat(lang, ops); } + +export function getReadingTime(text: string, wpm: number = 225): string { + if (!text || !text.trim() || wpm <= 0) { + return '1 min read'; + } + + const wordCount: number = text.trim().split(/\s+/).length; + const totalMinutes: number = Math.ceil(wordCount / wpm); + + // Ensure a minimum of 1 minute for any content + const minutes: number = Math.max(1, totalMinutes); + + // --- Formatting Steps --- + if (minutes < 60) { + return `${minutes} min read`; + } + + const hours: number = Math.floor(minutes / 60); + const remainingMinutes: number = minutes % 60; + const hourText: string = hours > 1 ? 'hours' : 'hour'; + + if (remainingMinutes === 0) { + return `${hours} ${hourText} read`; + } + + return `${hours} ${hourText} ${remainingMinutes} min read`; +} diff --git a/src/router.ts b/src/router.ts index 837803ce..e053c91b 100644 --- a/src/router.ts +++ b/src/router.ts @@ -31,7 +31,8 @@ const router: Router = createRouter({ component: HomePage, }, { - path: '/post', + path: '/post/:slug', + name: 'PostDetail', component: PostPage, }, { diff --git a/src/stores/api/client.ts b/src/stores/api/client.ts new file mode 100644 index 00000000..93f7794b --- /dev/null +++ b/src/stores/api/client.ts @@ -0,0 +1,117 @@ +import { HttpError } from '@api/http-error.ts'; + +const ENV_PROD = 'production'; + +export const defaultCreds: ApiClientOptions = { + env: import.meta.env.VITE_API_ENV as string, + apiKey: import.meta.env.VITE_PUBLIC_KEY as string, + apiUsername: import.meta.env.VITE_ACCOUNT_NAME as string, + apiSignature: import.meta.env.VITE_PUBLIC_SIGNATURE as string, +}; + +export interface ApiClientOptions { + env: string; + apiKey: string; + apiUsername: string; + apiSignature: string; +} + +export interface CacheEntry { + etag: string; + data: T; +} + +export interface ApiResponse { + version: string; + data: T; +} + +export class ApiClient { + private readonly env: string; + private readonly apiKey: string; + private readonly basedURL: string; + private readonly apiUsername: string; + private readonly apiSignature: string; + + constructor(options: ApiClientOptions) { + this.env = options.env; + this.apiKey = options.apiKey; + this.apiUsername = options.apiUsername; + this.apiSignature = options.apiSignature; + this.basedURL = `${import.meta.env.VITE_API_URL}`; + } + + private getCacheKey(url: string): string { + return `api-cache-${url}`; + } + + private getFromCache(url: string): CacheEntry | null { + const key = this.getCacheKey(url); + const item = localStorage.getItem(key); + + return item ? JSON.parse(item) : null; + } + + private setToCache(url: string, etag: string, data: T): void { + const key = this.getCacheKey(url); + const item: CacheEntry = { etag, data }; + + localStorage.setItem(key, JSON.stringify(item)); + } + + public isProd(): boolean { + return this.env.trim().toLowerCase() === ENV_PROD; + } + + public isDev(): boolean { + return !this.isProd(); + } + + private createHeaders(): Headers { + const headers = new Headers(); + + headers.append('X-API-Key', this.apiKey); + headers.append('User-Agent', 'oullin/web-app'); + headers.append('X-API-Username', this.apiUsername); + headers.append('X-API-Signature', this.apiSignature); + + headers.append('Content-Type', 'application/json'); + + return headers; + } + + public async get(url: string): Promise { + const headers = this.createHeaders(); + const fullUrl = new URL(url, this.basedURL); + const cached = this.getFromCache(url); + + if (cached) { + headers.append('If-None-Match', cached.etag); + } + + const response = await fetch(fullUrl.href, { + method: 'GET', + headers: headers, + }); + + if (response.status === 304) { + console.log(`%c[CACHE] 304 Not Modified for "${url}". Serving from cache.`, 'color: #007acc;'); + + // We can safely assert cached is not null here. + return cached!.data; + } + + if (!response.ok) { + throw new HttpError(response, await response.text()); + } + + const eTag = response.headers.get('ETag'); + const payload = await response.json(); + + if (eTag) { + this.setToCache(url, eTag, payload); + } + + return payload; + } +} diff --git a/src/stores/api/http-error.ts b/src/stores/api/http-error.ts new file mode 100644 index 00000000..dc6f6ca5 --- /dev/null +++ b/src/stores/api/http-error.ts @@ -0,0 +1,36 @@ +export class HttpError extends Error { + public readonly body: any; + public readonly status: number; + + constructor(response: Response, body: any) { + super(`API request failed with status ${response.status}: ${response.statusText}`); + + this.body = body; + this.name = 'HttpError'; + this.status = response.status; + + // This line is for compatibility with older environments + Object.setPrototypeOf(this, HttpError.prototype); + } +} + +export function parseError(error: any): Promise { + let errorMessage = `An unexpected error occurred: ${error}`; + + if (error instanceof HttpError) { + errorMessage = `An error occurred in the API. status [${error.message}] / message [${error.body}]`; + } + + return new Promise((resolve, reject) => { + reject(new Error(errorMessage)); + }); +} + +export function debugError(error: any): void { + if (error instanceof HttpError) { + console.error(`API Error: Status ${error.status}`); + console.error('Server Response:', error.body); + } else { + console.error('An unexpected error occurred:', error); + } +} diff --git a/src/stores/api/response/posts-response.ts b/src/stores/api/response/posts-response.ts new file mode 100644 index 00000000..efc07499 --- /dev/null +++ b/src/stores/api/response/posts-response.ts @@ -0,0 +1,46 @@ +export interface PostsCollectionResponse { + page: number; + total: number; + page_size: number; + total_pages: number; + data: PostResponse[]; +} + +export interface PostResponse { + uuid: string; + author: PostsAuthorResponse; + categories: PostsCategoryResponse[]; + tags: PostsTagResponse[]; + slug: string; + title: string; + excerpt: string; + content: string; + cover_image_url: string; + published_at: string; + created_at: string; + updated_at: string; +} + +export interface PostsAuthorResponse { + uuid: string; + first_name: string; + last_name: string; + username: string; + display_name: string; + bio: string; + picture_file_name: string; + profile_picture_url: string; +} + +export interface PostsCategoryResponse { + uuid: string; + name: string; + slug: string; + description: string; +} + +export interface PostsTagResponse { + uuid: string; + name: string; + description: string; +} diff --git a/src/stores/api/response/profile-response.ts b/src/stores/api/response/profile-response.ts new file mode 100644 index 00000000..e14a35c9 --- /dev/null +++ b/src/stores/api/response/profile-response.ts @@ -0,0 +1,7 @@ +export interface ProfileResponse { + nickname: string; + handle: string; + name: string; + email: string; + profession: string; +} diff --git a/src/stores/api/store.ts b/src/stores/api/store.ts new file mode 100644 index 00000000..3cb5ab7f --- /dev/null +++ b/src/stores/api/store.ts @@ -0,0 +1,53 @@ +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'; + +const STORE_KEY = 'api-client-store'; + +export interface ApiStoreState { + client: ApiClient; +} + +const client = new ApiClient(defaultCreds); + +export const useApiStore = defineStore(STORE_KEY, { + state: (): ApiStoreState => ({ + client: client, + }), + actions: { + boot(): void { + if (this.client.isDev()) { + console.log('API client booted ...'); + } + }, + async getProfile(): Promise> { + const url = 'profile'; + + try { + return await this.client.get>(url); + } catch (error) { + return parseError(error); + } + }, + async getPosts(): Promise { + const url = 'posts'; + + try { + return await this.client.get(url); + } catch (error) { + return parseError(error); + } + }, + async getPost(slug: string): Promise { + const url = `posts/${slug}`; + + try { + return await this.client.get(url); + } catch (error) { + return parseError(error); + } + }, + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 0bd1d1ac..d99126a6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,7 +24,8 @@ "@images/*": ["./src/images/*"], "@public/*": ["./src/public/*"], "@fonts/*": ["./src/fonts/*"], - "@stores/*": ["./src/stores/*"] + "@stores/*": ["./src/stores/*"], + "@api/*": ["./src/stores/api/*"] }, "lib": ["esnext", "dom", "dom.iterable", "scripthost"] }, diff --git a/vite.config.ts b/vite.config.ts index 0c175115..7c618420 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -27,6 +27,7 @@ export default defineConfig({ { find: '@public', replacement: path.resolve(__dirname, './src/public') }, { find: '@partials', replacement: path.resolve(__dirname, './src/partials') }, { find: '@stores', replacement: path.resolve(__dirname, './src/stores') }, + { find: '@api', replacement: path.resolve(__dirname, './src/stores/api') }, ], }, });