copy: rename site branding to Krishna
This commit is contained in:
67
components/posts/PostCard.tsx
Normal file
67
components/posts/PostCard.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { m, useReducedMotion } from 'motion/react';
|
||||
import type { PostMeta } from '@/lib/posts';
|
||||
|
||||
const tagToClientSlug = (tag: string): string =>
|
||||
tag
|
||||
.normalize('NFKD')
|
||||
.toLowerCase()
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\+/g, ' plus ')
|
||||
.replace(/#/g, ' sharp ')
|
||||
.replace(/[\\/]+/g, ' ')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
export function PostCard({ slug, title, date, excerpt, tags = [], author, readingTime, index = 0, coverImage }: PostMeta & { index?: number }) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<m.article
|
||||
initial={shouldReduceMotion ? false : { opacity: 0, y: 14 }}
|
||||
whileInView={shouldReduceMotion ? undefined : { opacity: 1, y: 0 }}
|
||||
whileHover={shouldReduceMotion ? undefined : { y: -2 }}
|
||||
transition={{ duration: 0.35, delay: shouldReduceMotion ? 0 : Math.min(index * 0.04, 0.18), ease: [0.22, 1, 0.36, 1] }}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
className="group relative scroll-mt-20 rounded-2xl border border-border/90 bg-canvas p-5 shadow-card transition-[background-color,border-color,box-shadow] duration-200 ease-out hover:border-ink/25 hover:bg-surface/40 hover:shadow-card-hover dark:hover:border-ink/35 sm:p-7"
|
||||
>
|
||||
<Link href={`/posts/${slug}/`} className="block">
|
||||
{coverImage && (
|
||||
<div className="mb-5 overflow-hidden rounded-xl border border-border/80 bg-surface">
|
||||
<img
|
||||
src={coverImage}
|
||||
alt={`Cover image for ${title}`}
|
||||
className="h-44 w-full object-cover transition-transform duration-300 ease-out group-hover:scale-[1.015] sm:h-52"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-soft">
|
||||
<time className="font-mono" dateTime={date}>{new Date(date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })}</time>
|
||||
{author && <span className="text-ink-soft/70">·</span>}
|
||||
{author && <span>{author}</span>}
|
||||
{readingTime && <span className="text-ink-soft/70">·</span>}
|
||||
{readingTime && <span className="font-mono">{readingTime} min read</span>}
|
||||
</div>
|
||||
<h3 className="heading-md mb-3 mt-0 text-ink transition-colors duration-200 group-hover:text-accent">
|
||||
{title}
|
||||
</h3>
|
||||
{excerpt && (
|
||||
<p className="mt-3 max-w-3xl text-sm leading-7 text-ink-soft">{excerpt}</p>
|
||||
)}
|
||||
</Link>
|
||||
{tags && tags.length > 0 && (
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
<Link key={tag} href={`/tags/${tagToClientSlug(tag)}/`} className="rounded-full border border-border bg-surface/70 px-3 py-1 text-xs text-ink-soft transition-colors duration-200 hover:border-accent/60 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent">
|
||||
{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</m.article>
|
||||
);
|
||||
}
|
||||
38
components/posts/PostList.tsx
Normal file
38
components/posts/PostList.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { m, useReducedMotion } from "motion/react";
|
||||
import { PostCard } from "./PostCard";
|
||||
import type { PostMeta } from "@/lib/posts";
|
||||
|
||||
const listVariants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
staggerChildren: 0.07,
|
||||
delayChildren: 0.15,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface PostListProps {
|
||||
posts: PostMeta[];
|
||||
}
|
||||
|
||||
export function PostList({ posts }: PostListProps) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<m.ul
|
||||
variants={shouldReduceMotion ? undefined : listVariants}
|
||||
initial={shouldReduceMotion ? false : "hidden"}
|
||||
animate="visible"
|
||||
className="space-y-6"
|
||||
>
|
||||
{posts.map((post, index) => (
|
||||
<li key={post.slug}>
|
||||
<PostCard {...post} index={index} />
|
||||
</li>
|
||||
))}
|
||||
</m.ul>
|
||||
);
|
||||
}
|
||||
237
components/posts/PostSearch.tsx
Normal file
237
components/posts/PostSearch.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { PostCard } from './PostCard'
|
||||
|
||||
type SearchPost = {
|
||||
slug: string
|
||||
title: string
|
||||
date: string
|
||||
excerpt: string
|
||||
tags: string[]
|
||||
author: string | null
|
||||
coverImage: string | null
|
||||
readingTime: number
|
||||
}
|
||||
|
||||
type TagFilter = {
|
||||
slug: string
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
type ScoredPost = {
|
||||
post: SearchPost
|
||||
score: number
|
||||
index: number
|
||||
}
|
||||
|
||||
interface PostSearchProps {
|
||||
posts: SearchPost[]
|
||||
}
|
||||
|
||||
const normalizeSearchText = (value: string): string =>
|
||||
value
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
|
||||
const tagToClientSlug = (tag: string): string =>
|
||||
normalizeSearchText(tag)
|
||||
.replace(/\+/g, ' plus ')
|
||||
.replace(/#/g, ' sharp ')
|
||||
.replace(/[\\/]+/g, ' ')
|
||||
.replace(/[^\p{L}\p{N}]+/gu, '-')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
const getOrderedCharacterScore = (field: string, query: string): number => {
|
||||
if (!query) return 0
|
||||
|
||||
let queryIndex = 0
|
||||
let gaps = 0
|
||||
let lastMatchIndex = -1
|
||||
|
||||
for (let fieldIndex = 0; fieldIndex < field.length && queryIndex < query.length; fieldIndex += 1) {
|
||||
if (field[fieldIndex] !== query[queryIndex]) continue
|
||||
|
||||
if (lastMatchIndex >= 0) gaps += fieldIndex - lastMatchIndex - 1
|
||||
lastMatchIndex = fieldIndex
|
||||
queryIndex += 1
|
||||
}
|
||||
|
||||
if (queryIndex !== query.length) return 0
|
||||
|
||||
return Math.max(1, 80 - gaps - Math.max(0, field.length - query.length) * 0.05)
|
||||
}
|
||||
|
||||
const scoreField = (field: string, query: string, weight: number): number => {
|
||||
if (!field || !query) return 0
|
||||
|
||||
if (field === query) return 1000 * weight
|
||||
if (field.startsWith(query)) return 700 * weight + query.length
|
||||
if (field.includes(query)) return 500 * weight + query.length
|
||||
|
||||
return getOrderedCharacterScore(field, query) * weight
|
||||
}
|
||||
|
||||
const scorePost = (post: SearchPost, query: string): number => {
|
||||
if (!query) return 0
|
||||
|
||||
const fields = [
|
||||
[post.title, 1.4],
|
||||
[post.slug, 1.1],
|
||||
[post.excerpt, 0.8],
|
||||
[post.author ?? '', 0.75],
|
||||
[post.tags.join(' '), 1],
|
||||
] as const
|
||||
|
||||
return fields.reduce(
|
||||
(score, [field, weight]) => score + scoreField(normalizeSearchText(field), query, weight),
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
const getTagFilters = (posts: SearchPost[]): TagFilter[] => {
|
||||
const tags = new Map<string, TagFilter>()
|
||||
|
||||
posts.forEach((post) => {
|
||||
const postTags = new Set<string>()
|
||||
|
||||
post.tags.forEach((tag) => {
|
||||
const slug = tagToClientSlug(tag)
|
||||
if (!slug || postTags.has(slug)) return
|
||||
|
||||
postTags.add(slug)
|
||||
const existing = tags.get(slug)
|
||||
|
||||
if (existing) {
|
||||
existing.count += 1
|
||||
if (tag.localeCompare(existing.label) < 0) existing.label = tag
|
||||
return
|
||||
}
|
||||
|
||||
tags.set(slug, { slug, label: tag, count: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(tags.values()).sort(
|
||||
(a, b) => a.label.localeCompare(b.label) || a.slug.localeCompare(b.slug)
|
||||
)
|
||||
}
|
||||
|
||||
export function PostSearch({ posts }: PostSearchProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null)
|
||||
|
||||
const tagFilters = useMemo(() => getTagFilters(posts), [posts])
|
||||
const normalizedQuery = normalizeSearchText(query)
|
||||
|
||||
const results = useMemo(() => {
|
||||
const scoredPosts = posts.reduce<ScoredPost[]>((matches, post, index) => {
|
||||
if (selectedTag && !post.tags.some((tag) => tagToClientSlug(tag) === selectedTag)) {
|
||||
return matches
|
||||
}
|
||||
|
||||
const score = scorePost(post, normalizedQuery)
|
||||
if (normalizedQuery && score <= 0) return matches
|
||||
|
||||
matches.push({ post, score, index })
|
||||
return matches
|
||||
}, [])
|
||||
|
||||
return scoredPosts
|
||||
.sort((a, b) => b.score - a.score || a.index - b.index)
|
||||
.map(({ post }) => post)
|
||||
}, [normalizedQuery, posts, selectedTag])
|
||||
|
||||
if (posts.length === 0) {
|
||||
return <p className="text-ink-soft">No posts yet.</p>
|
||||
}
|
||||
|
||||
const hasFilters = Boolean(normalizedQuery || selectedTag)
|
||||
const selectedTagLabel = tagFilters.find((tag) => tag.slug === selectedTag)?.label
|
||||
|
||||
return (
|
||||
<section aria-labelledby="posts-search-heading" className="space-y-8">
|
||||
<div className="space-y-5 rounded-xl border border-border bg-surface/30 p-4 sm:p-5">
|
||||
<div className="space-y-2">
|
||||
<label id="posts-search-heading" htmlFor="post-search" className="block text-sm font-medium text-ink">
|
||||
Search posts
|
||||
</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
id="post-search"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search title, excerpt, tag, author, or slug"
|
||||
className="min-w-0 flex-1 rounded-lg border border-border bg-canvas px-3 py-2 text-sm text-ink outline-none transition-colors placeholder:text-ink-soft/70 focus:border-accent focus:ring-2 focus:ring-accent/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery('')}
|
||||
disabled={!query}
|
||||
className="rounded-lg border border-border px-3 py-2 text-sm text-ink-soft transition-colors hover:border-accent/50 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tagFilters.length > 0 && (
|
||||
<div className="space-y-2" aria-label="Filter posts by tag">
|
||||
<p className="text-sm font-medium text-ink">Filter by tag</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tagFilters.map((tag) => {
|
||||
const isSelected = selectedTag === tag.slug
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tag.slug}
|
||||
type="button"
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => setSelectedTag(isSelected ? null : tag.slug)}
|
||||
className="rounded-full border border-border bg-canvas px-3 py-1 text-xs text-ink-soft transition-colors hover:border-accent/50 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent aria-pressed:border-accent aria-pressed:bg-accent/10 aria-pressed:text-accent"
|
||||
>
|
||||
{tag.label} <span className="font-mono text-ink-soft/70">{tag.count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2 text-sm text-ink-soft sm:flex-row sm:items-center sm:justify-between">
|
||||
<p aria-live="polite">
|
||||
{results.length} {results.length === 1 ? 'post' : 'posts'} found
|
||||
{selectedTagLabel ? ` in ${selectedTagLabel}` : ''}
|
||||
</p>
|
||||
{hasFilters && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setSelectedTag(null)
|
||||
}}
|
||||
className="self-start rounded-lg border border-border px-3 py-1.5 text-sm text-ink-soft transition-colors hover:border-accent/50 hover:text-accent focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent sm:self-auto"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{results.length > 0 ? (
|
||||
<div className="space-y-12">
|
||||
{results.map((post, index) => (
|
||||
<PostCard key={post.slug} {...post} index={index} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-ink-soft">No posts match your search.</p>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
81
components/posts/TableOfContents.tsx
Normal file
81
components/posts/TableOfContents.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
|
||||
interface TOCItem {
|
||||
id: string;
|
||||
text: string;
|
||||
level: number;
|
||||
}
|
||||
|
||||
function useHeadings(): TOCItem[] {
|
||||
const [headings, setHeadings] = useState<TOCItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const elements = Array.from(
|
||||
document.querySelectorAll("article h2, article h3")
|
||||
) as HTMLElement[];
|
||||
|
||||
const parsed = elements.map((el) => ({
|
||||
id: el.id,
|
||||
text: el.textContent ?? "",
|
||||
level: el.tagName === "H2" ? 2 : 3,
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setHeadings(parsed);
|
||||
}, []);
|
||||
|
||||
return headings;
|
||||
}
|
||||
|
||||
export function TableOfContents() {
|
||||
const [activeId, setActiveId] = useState("");
|
||||
const observerRef = useRef<IntersectionObserver | null>(null);
|
||||
const headings = useHeadings();
|
||||
|
||||
useEffect(() => {
|
||||
observerRef.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActiveId(entry.target.id);
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: "-20% 0px -70% 0px", threshold: 0 }
|
||||
);
|
||||
|
||||
const elements = Array.from(
|
||||
document.querySelectorAll("article h2, article h3")
|
||||
) as HTMLElement[];
|
||||
|
||||
elements.forEach((el) => observerRef.current?.observe(el));
|
||||
|
||||
return () => observerRef.current?.disconnect();
|
||||
}, []);
|
||||
|
||||
if (headings.length === 0) return null;
|
||||
|
||||
return (
|
||||
<nav aria-label="Table of contents" className="lg:sticky lg:top-[var(--header-height)]">
|
||||
<h4 className="font-sans text-xs font-semibold uppercase tracking-wider text-ink-soft mb-3">
|
||||
On this page
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{headings.map((heading) => (
|
||||
<li key={heading.id}>
|
||||
<a
|
||||
href={`#${heading.id}`}
|
||||
className={`block text-sm transition-colors ${
|
||||
heading.level === 3 ? "pl-3 text-ink-soft" : "text-ink"
|
||||
} ${activeId === heading.id ? "font-semibold text-accent" : ""}`}
|
||||
>
|
||||
{heading.text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
29
components/posts/button-client.tsx
Normal file
29
components/posts/button-client.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
export function ClientButton({
|
||||
children,
|
||||
onClick,
|
||||
...props
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
onClick?: ((event: React.MouseEvent<HTMLButtonElement>) => void) | string
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||
const btnRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!btnRef.current) return
|
||||
if (typeof onClick === 'string') {
|
||||
btnRef.current.onclick = onClick as unknown as (e: Event) => void
|
||||
} else if (typeof onClick === 'function') {
|
||||
btnRef.current.onclick = (e: Event) => onClick(e as unknown as React.MouseEvent<HTMLButtonElement>)
|
||||
}
|
||||
}, [onClick])
|
||||
|
||||
return (
|
||||
<button ref={btnRef} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user