diff --git a/lefthook.toml b/lefthook.toml index ea320bb..1b2cb70 100644 --- a/lefthook.toml +++ b/lefthook.toml @@ -9,6 +9,10 @@ run = "pnpm biome check --apply --no-errors-on-unmatched --files-ignore-unknown= # glob = "*.svelte" # run = "pnpm svelte-check -- {staged_files} && git update-index --again" +# [pre-commit.commands.update-mdx] +# glob = "*.mdx" +# run = "pnpm scripts/update-mdx {staged_files} && git update-index --again" + [pre-commit.commands.check-toml] glob = "*.toml" run = "pnpm taplo format {staged_files} && git update-index --again" diff --git a/package.json b/package.json index 9eaabcd..13a9cf0 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ }, "type": "module", "dependencies": { + "@vercel/analytics": "^1.3.1", "autoprefixer": "^10.4.19", "highlight.js": "^11.9.0", "postcss": "^8.4.38", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2c459e..d2ff467 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + '@vercel/analytics': + specifier: ^1.3.1 + version: 1.3.1(react@18.2.0) autoprefixer: specifier: ^10.4.19 version: 10.4.19(postcss@8.4.38) @@ -904,6 +907,21 @@ packages: resolution: {integrity: sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA==} dev: true + /@vercel/analytics@1.3.1(react@18.2.0): + resolution: {integrity: sha512-xhSlYgAuJ6Q4WQGkzYTLmXwhYl39sWjoMA3nHxfkvG+WdBT25c563a7QhwwKivEOZtPJXifYHR1m2ihoisbWyA==} + peerDependencies: + next: '>= 13' + react: ^18 || ^19 + peerDependenciesMeta: + next: + optional: true + react: + optional: true + dependencies: + react: 18.2.0 + server-only: 0.0.1 + dev: false + /JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -2405,6 +2423,10 @@ packages: engines: {node: '>=10'} hasBin: true + /server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + dev: false + /set-cookie-parser@2.6.0: resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} dev: true diff --git a/src/app.css b/src/app.css index 8ba3f11..2e1f020 100644 --- a/src/app.css +++ b/src/app.css @@ -43,7 +43,7 @@ kbd { --a4-height: 297mm; --a4-aspect-ratio: calc(var(--a4-height) / var(--a4-width)); - --page-min-height: calc(100dvh - var(--header-weight) - var(--footer-height)); + --page-min-height: calc(100dvh - var(--header-height) - var(--footer-height)); --primary-100: #e8e9e9; --primary-200: #cdcecf; diff --git a/src/components/blog/blog-link.svelte b/src/components/blog/blog-link.svelte new file mode 100644 index 0000000..57d6b32 --- /dev/null +++ b/src/components/blog/blog-link.svelte @@ -0,0 +1,32 @@ + + + + {article.metadata.title} + + {Intl.DateTimeFormat("en", { + year: "2-digit", + month: "short", + day: "numeric", + }).format(new Date(article.metadata.date))} + + diff --git a/src/components/blog/blog-modal.svelte b/src/components/blog/blog-modal.svelte new file mode 100644 index 0000000..64cc629 --- /dev/null +++ b/src/components/blog/blog-modal.svelte @@ -0,0 +1,103 @@ + + + + + {#if showModal} + + + e.target instanceof HTMLInputElement && (value = e.target.value)} + error={null} + placeholder={"Search for an article"} + /> + + + Close + (Esc) + + + Search + (Enter) + + + + {/if} + diff --git a/src/components/blog/blog-section.svelte b/src/components/blog/blog-section.svelte new file mode 100644 index 0000000..0aacecc --- /dev/null +++ b/src/components/blog/blog-section.svelte @@ -0,0 +1,36 @@ + + +{#if isAside} + +{:else} + + {label} + + {@render props.children?.()} + +{/if} + + diff --git a/src/components/blog/index.ts b/src/components/blog/index.ts new file mode 100644 index 0000000..b3b2c82 --- /dev/null +++ b/src/components/blog/index.ts @@ -0,0 +1,3 @@ +export { default as BlogLink } from './blog-link.svelte' +export { default as BlogModal } from './blog-modal.svelte' +export { default as BlogSection } from './blog-section.svelte' diff --git a/src/components/resume/resume.svelte b/src/components/resume/resume.svelte index 4474b8c..9ba9d61 100644 --- a/src/components/resume/resume.svelte +++ b/src/components/resume/resume.svelte @@ -1,5 +1,6 @@ + + + {@render children?.()} + diff --git a/src/components/ui/input.svelte b/src/components/ui/input.svelte index 0313b6c..ea7258a 100644 --- a/src/components/ui/input.svelte +++ b/src/components/ui/input.svelte @@ -5,14 +5,24 @@ import type { HTMLInputAttributes } from 'svelte/elements' type InputProps = HTMLInputAttributes & { label: string error: string | null + showLabel?: boolean } -const { label, type, name, required = false, error = null, ...props }: InputProps = $props() +const { + label, + type, + name, + required = false, + error = null, + showLabel = true, + ...props +}: InputProps = $props() - {label} + {label} This is a placeholder post + +Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + diff --git a/src/icons/index.ts b/src/icons/index.ts new file mode 100644 index 0000000..9441ec3 --- /dev/null +++ b/src/icons/index.ts @@ -0,0 +1,10 @@ +export { default as DownloadIcon } from './download-icon.svelte' +export { default as GlobeIcon } from './globe-icon.svelte' +export { default as LeftIcon } from './left-icon.svelte' +export { default as LogoIcon } from './logo-icon.svelte' +export { default as PhoneIcon } from './phone-icon.svelte' +export { default as ShareIcon } from './share-icon.svelte' +export { default as SocialEmailIcon } from './social-email-icon.svelte' +export { default as SocialGithubIcon } from './social-github-icon.svelte' +export { default as SocialLinkedinIcon } from './social-linkedin-icon.svelte' +export { default as SocialXIcon } from './social-x-icon.svelte' diff --git a/src/icons/phone-icon.svelte b/src/icons/phone-icon.svelte new file mode 100644 index 0000000..8eaa058 --- /dev/null +++ b/src/icons/phone-icon.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/src/icons/share-icon.svelte b/src/icons/share-icon.svelte new file mode 100644 index 0000000..5fcbac0 --- /dev/null +++ b/src/icons/share-icon.svelte @@ -0,0 +1,11 @@ + + + + Share Icon + + diff --git a/src/lib/blog/motions.ts b/src/lib/blog/motions.ts new file mode 100644 index 0000000..f20def9 --- /dev/null +++ b/src/lib/blog/motions.ts @@ -0,0 +1,110 @@ +import type { BlogPost } from './post' + +export type MetadataKey = 'title' | 'description' | 'date' | 'slug' + +type ProcessMotionsOptions = { + key: string + articles: BlogPost[] + slug: string + multiplier: number | null +} + +type NullValue = { kind: 'null' } +type Multiplier = { kind: 'multiplier'; multiplier: number } +type SelectedArticle = { kind: 'select'; selected: BlogPost; lastMotion: string; slug: string } +type ShowModal = { kind: 'modal' } +type PageUpDown = { kind: 'page'; direction: 'up' | 'down' } +type RepeatMotion = { kind: 'repeat' } + +type ProcessMotionsReturnValue = + | NullValue + | Multiplier + | SelectedArticle + | ShowModal + | PageUpDown + | RepeatMotion + +export const processMotions = (options: ProcessMotionsOptions): ProcessMotionsReturnValue => { + const motions: { [key: string]: () => ProcessMotionsReturnValue } = { + k: () => prevArticle(), + j: () => nextArticle(), + K: () => pageDown(), + J: () => pageUp(), + x: () => repeatMotion(), + '\\': () => search(), + } + + if (/^\d$/.test(options.key)) + return { kind: 'multiplier', multiplier: Number.parseInt(options.key) } + + const getIndex = (articles: BlogPost[], slug: string) => + articles.findIndex((article) => article.slug === slug) + + const prevArticle = (): ProcessMotionsReturnValue => { + const { articles, slug } = options + const currentIndex = getIndex(articles, slug) + const prevIndex = currentIndex - (options.multiplier || 1) + if (prevIndex < 0) + return { + kind: 'select' as const, + selected: articles[0], + lastMotion: 'j', + slug: articles[0].slug, + } + return { + kind: 'select' as const, + selected: articles[prevIndex], + lastMotion: `${options.multiplier || ''} ${options.key}`, + slug: articles[prevIndex].slug, + } + } + + const nextArticle = () => { + const { articles, slug } = options + const currentIndex = getIndex(articles, slug) + if (currentIndex === -1) + return { + kind: 'select' as const, + selected: articles[0], + lastMotion: 'k', + slug: articles[0].slug, + } + + const nextIndex = currentIndex + (options.multiplier || 1) + if (nextIndex >= articles.length) { + return { + kind: 'select' as const, + selected: articles[articles.length - 1], + lastMotion: 'k', + slug: articles[articles.length - 1].slug, + } + } + + return { + kind: 'select' as const, + selected: articles[nextIndex], + lastMotion: `${options.multiplier || ''} ${options.key}`, + slug: articles[nextIndex].slug, + } + } + + const pageUp = () => { + return { kind: 'page' as const, direction: 'up' as const } + } + + const pageDown = () => { + return { kind: 'page' as const, direction: 'down' as const } + } + + const repeatMotion = () => { + return { kind: 'repeat' as const } + } + + const search = () => { + return { kind: 'modal' as const } + } + + if (options.key in motions) return motions[options.key]() + + return { kind: 'null' as const } +} diff --git a/src/lib/blog/post.ts b/src/lib/blog/post.ts new file mode 100644 index 0000000..57ff919 --- /dev/null +++ b/src/lib/blog/post.ts @@ -0,0 +1,13 @@ +import type { MetadataKey } from './motions' + +export interface BlogPost { + content: string + metadata: Record + slug: string +} + +export const filterPosts = (posts: BlogPost[], search: string) => { + return posts.filter((post) => { + return post.metadata.title.toLowerCase().includes(search.toLowerCase()) + }) +} diff --git a/src/lib/blog/server.ts b/src/lib/blog/server.ts new file mode 100644 index 0000000..087929f --- /dev/null +++ b/src/lib/blog/server.ts @@ -0,0 +1,40 @@ +import fs from 'node:fs' +import path from 'node:path' +import { compile } from 'mdsvex' +import type { MetadataKey } from './motions' +import type { BlogPost } from './post' + +export const getBlogPosts = async (): Promise => { + const blogDirectory = path.resolve('src/contents') + const posts = await fs.promises.readdir(blogDirectory).catch(() => []) + if (!posts.length) return [] + + const postsList: BlogPost[] = [] + + for (const post of posts) { + const postPath = path.resolve(blogDirectory, post, 'blog.mdx') + const postContent = await fs.promises.readFile(postPath, 'utf-8') + const postCompiled = await compile(postContent) + if (!postCompiled) continue + + if (!postCompiled.data) continue + + const { + title = post.replaceAll(/-/gi, ' '), + description, + date = new Date().toISOString(), + slug = post, + } = postCompiled.data as Record + const content = postCompiled.code + + postsList.push({ + content, + metadata: { ...postCompiled.data, title, description, date, slug }, + slug, + }) + } + + return postsList.sort( + (a, b) => new Date(b.metadata.date).getTime() - new Date(a.metadata.date).getTime(), + ) +} diff --git a/src/lib/blog/shortcuts.ts b/src/lib/blog/shortcuts.ts new file mode 100644 index 0000000..f4c0d4a --- /dev/null +++ b/src/lib/blog/shortcuts.ts @@ -0,0 +1,9 @@ +export const shortcuts = [ + { key: '0-9', description: 'multiplier' }, + { key: 'j', description: 'list down' }, + { key: 'k', description: 'list up' }, + { key: 'J', description: 'page down' }, + { key: 'K', description: 'page up' }, + // { key: 'x', description: 'repeat last' }, + { key: '\\', description: 'filter' }, +] diff --git a/src/routes/(vimlike)/+layout.svelte b/src/routes/(vimlike)/+layout.svelte index b563cc7..287a39e 100644 --- a/src/routes/(vimlike)/+layout.svelte +++ b/src/routes/(vimlike)/+layout.svelte @@ -21,8 +21,6 @@ $effect(() => { mode = Mode.NORMAL } else if (mode === Mode.NORMAL && e instanceof KeyboardEvent) { if (e.key === 'Backspace') { - history.back() - // Fallback if there is no history goto('/') } } @@ -59,7 +57,7 @@ const { children } = $props()
Lorem ipsum dolor sit amet, consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.