Archived
Move search and the front-page decisions out of the browser into Java
Search downloaded every post to every visitor and filtered the list with String.includes, so it could only ever match what a summary carries — a phrase from the body of a post was unfindable, and the cost grew with each post published. It is now a query (/api/posts/search) that searches the body too. The home page picked 'the latest post' by taking element zero of that list, which was only correct for as long as the API happened to return posts in that order. /api/home now decides what leads, what counts as recent, and the tag counts, in one call. Tag counting moves out of the controller into TagService, shared by both. The LIKE escaping needed an explicit ESCAPE clause: there is no default escape character to rely on, and posts are markdown full of literal backslashes — escaping with a backslash turned a search for '%' into a search for backslashes and matched unrelated posts. Caught by the new tests. Also on platform 0.1.6 (contact-form header-injection fix).
This commit is contained in:
@@ -20,6 +20,19 @@ export function getPosts(params: { tag?: string; author?: string } = {}): Promis
|
||||
return api.get<PostSummary[]>(`/posts${q ? `?${q}` : ''}`);
|
||||
}
|
||||
export const getPost = (slug: string) => api.get<PostDetail>(`/posts/${encodeURIComponent(slug)}`);
|
||||
|
||||
/** Searching is done by the database, over post bodies too — not by filtering a downloaded list. */
|
||||
export const searchPosts = (q: string) =>
|
||||
api.get<PostSummary[]>(`/posts/search?q=${encodeURIComponent(q)}`);
|
||||
|
||||
export interface HomePage {
|
||||
featured: PostSummary | null;
|
||||
posts: PostSummary[];
|
||||
recent: PostSummary[];
|
||||
tags: TagCount[];
|
||||
}
|
||||
/** One call for the whole front page; which post leads is the backend's decision. */
|
||||
export const getHome = () => api.get<HomePage>('/home');
|
||||
export const getTags = () => api.get<TagCount[]>('/tags');
|
||||
export const getAuthors = () => api.get<AuthorSummary[]>('/authors');
|
||||
export const getAuthor = (name: string) => api.get<AuthorPage>(`/authors/${encodeURIComponent(name)}`);
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { getPosts, getTags } from '../api';
|
||||
import { getHome } from '../api';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
import PostCard from '../components/PostCard';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
|
||||
export default function HomePage() {
|
||||
const { data: posts, loading } = useAsync(() => getPosts(), []);
|
||||
const { data: tags } = useAsync(() => getTags(), []);
|
||||
const list = posts ?? [];
|
||||
const featured = list[0];
|
||||
// One call. Which post leads, what counts as recent, and the tag counts are all decided by the
|
||||
// backend — this page used to take element zero of the post list and call it "the latest".
|
||||
const { data, loading } = useAsync(() => getHome(), []);
|
||||
const featured = data?.featured ?? null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 md:flex-row">
|
||||
@@ -53,9 +53,9 @@ export default function HomePage() {
|
||||
<h2 className="mb-6 border-b border-primary-200 pb-2 text-2xl font-bold">Recent Posts</h2>
|
||||
{loading && <p className="text-primary-500">Loading…</p>}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{list.slice(1).map((post) => <PostCard key={post.slug} post={post} />)}
|
||||
{(data?.posts ?? []).map((post) => <PostCard key={post.slug} post={post} />)}
|
||||
</div>
|
||||
{!loading && list.length === 0 && <p className="text-primary-500">No posts yet.</p>}
|
||||
{!loading && !featured && <p className="text-primary-500">No posts yet.</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 text-center">
|
||||
@@ -64,7 +64,7 @@ export default function HomePage() {
|
||||
</main>
|
||||
|
||||
<div className="md:w-1/3">
|
||||
<Sidebar recentPosts={list.slice(0, 5)} tags={tags ?? []} />
|
||||
<Sidebar recentPosts={data?.recent ?? []} tags={data?.tags ?? []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { getPosts } from '../api';
|
||||
import { searchPosts } from '../api';
|
||||
import { useAsync } from '../lib/useAsync';
|
||||
import PostCard from '../components/PostCard';
|
||||
|
||||
@@ -8,15 +8,11 @@ export default function SearchPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const q = params.get('q') ?? '';
|
||||
const [term, setTerm] = useState(q);
|
||||
const { data: posts, loading } = useAsync(() => getPosts(), []);
|
||||
|
||||
const needle = q.trim().toLowerCase();
|
||||
const results = (posts ?? []).filter((p) =>
|
||||
!needle
|
||||
|| p.title.toLowerCase().includes(needle)
|
||||
|| p.excerpt.toLowerCase().includes(needle)
|
||||
|| p.author.toLowerCase().includes(needle)
|
||||
|| p.tags.some((t) => t.toLowerCase().includes(needle)));
|
||||
// The database does the matching — including post bodies, which the old browser-side filter
|
||||
// never had, and without shipping every post to every visitor who opens this page.
|
||||
const { data: results, loading } = useAsync(() => searchPosts(q), [q]);
|
||||
const found = results ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl">
|
||||
@@ -36,13 +32,13 @@ export default function SearchPage() {
|
||||
</form>
|
||||
|
||||
{loading && <p className="text-primary-500">Loading…</p>}
|
||||
{!loading && needle && (
|
||||
{!loading && q.trim() && (
|
||||
<p className="mb-6 text-primary-600">
|
||||
{results.length} {results.length === 1 ? 'result' : 'results'} for “{q}”
|
||||
{found.length} {found.length === 1 ? 'result' : 'results'} for “{q}”
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{results.map((post) => <PostCard key={post.slug} post={post} />)}
|
||||
{found.map((post) => <PostCard key={post.slug} post={post} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user