From 921863c60216a28e7b6941b9682b58bb6afe1662 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Fri, 19 Dec 2025 23:54:11 -0500 Subject: [PATCH] server continue --- src/app.tsx | 8 +- src/components/blog/PostSorting.tsx | 64 ++------- src/components/blog/PostSortingSelect.tsx | 18 +-- src/routes/blog/index.tsx | 10 +- src/server/api/routers/blog.ts | 163 ++++++++++++++-------- src/server/api/schemas/blog.ts | 44 ++++++ 6 files changed, 181 insertions(+), 126 deletions(-) create mode 100644 src/server/api/schemas/blog.ts diff --git a/src/app.tsx b/src/app.tsx index 4be9ca8..d2f54d7 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -157,18 +157,15 @@ function AppLayout(props: { children: any }) { }); }); - const handleCenterTap = (e: MouseEvent) => { + const handleCenterTapRelease = (e: MouseEvent | TouchEvent) => { const isMobile = window.innerWidth < 768; - // Only hide left bar on mobile when it's visible if (isMobile && leftBarVisible()) { - // Check if the click is on an interactive element const target = e.target as HTMLElement; const isInteractive = target.closest( "a, button, input, select, textarea, [onclick]" ); - // Don't hide if clicking on interactive elements if (!isInteractive) { setLeftBarVisible(false); } @@ -185,7 +182,8 @@ function AppLayout(props: { children: any }) { width: `${centerWidth()}px`, "margin-left": `${leftBarSize()}px` }} - onClick={handleCenterTap} + onMouseUp={handleCenterTapRelease} + onTouchEnd={handleCenterTapRelease} > }> }>{props.children} diff --git a/src/components/blog/PostSorting.tsx b/src/components/blog/PostSorting.tsx index 79ba10f..c526e3e 100644 --- a/src/components/blog/PostSorting.tsx +++ b/src/components/blog/PostSorting.tsx @@ -4,68 +4,28 @@ import Card, { Post } from "./Card"; export interface PostSortingProps { posts: Post[]; privilegeLevel: "anonymous" | "admin" | "user"; - filters?: string; - sort?: string; } +/** + * PostSorting Component + * + * Note: This component has been simplified - filtering and sorting + * are now handled server-side via the blog.getPosts tRPC query. + * + * This component now only renders the posts that have already been + * filtered and sorted by the server. + */ export default function PostSorting(props: PostSortingProps) { - const postsToFilter = () => { - const filterSet = new Set(); - - if (!props.filters) return filterSet; - - const filterTags = props.filters.split("|"); - - props.posts.forEach((post) => { - if (post.tags) { - const postTags = post.tags.split(","); - const hasMatchingTag = postTags.some((tag) => - filterTags.includes(tag.slice(1)) - ); - if (hasMatchingTag) { - filterSet.add(post.id); - } - } - }); - - return filterSet; - }; - - const filteredPosts = () => { - return props.posts.filter((post) => { - return !postsToFilter().has(post.id); - }); - }; - - const sortedPosts = () => { - const posts = filteredPosts(); - - switch (props.sort) { - case "newest": - return [...posts]; - case "oldest": - return [...posts].reverse(); - case "most liked": - return [...posts].sort((a, b) => b.total_likes - a.total_likes); - case "most read": - return [...posts].sort((a, b) => b.reads - a.reads); - case "most comments": - return [...posts].sort((a, b) => b.total_comments - a.total_comments); - default: - return [...posts].reverse(); - } - }; - return ( 0 && filteredPosts().length === 0)} + when={props.posts.length > 0} fallback={
- All posts filtered out! + No posts found!
} > - + {(post) => (
diff --git a/src/components/blog/PostSortingSelect.tsx b/src/components/blog/PostSortingSelect.tsx index e236f4d..cce1b64 100644 --- a/src/components/blog/PostSortingSelect.tsx +++ b/src/components/blog/PostSortingSelect.tsx @@ -4,11 +4,11 @@ import Check from "~/components/icons/Check"; import UpDownArrows from "~/components/icons/UpDownArrows"; const sorting = [ - { val: "Newest" }, - { val: "Oldest" }, - { val: "Most Liked" }, - { val: "Most Read" }, - { val: "Most Comments" } + { val: "newest", label: "Newest" }, + { val: "oldest", label: "Oldest" }, + { val: "most_liked", label: "Most Liked" }, + { val: "most_read", label: "Most Read" }, + { val: "most_comments", label: "Most Comments" } ]; export interface PostSortingSelectProps {} @@ -23,14 +23,14 @@ export default function PostSortingSelect(props: PostSortingSelectProps) { const currentFilters = () => searchParams.filter || null; createEffect(() => { - let newRoute = location.pathname + "?sort=" + selected().val.toLowerCase(); + let newRoute = location.pathname + "?sort=" + selected().val; if (currentFilters()) { newRoute += "&filter=" + currentFilters(); } navigate(newRoute); }); - const handleSelect = (sort: { val: string }) => { + const handleSelect = (sort: { val: string; label: string }) => { setSelected(sort); setIsOpen(false); }; @@ -42,7 +42,7 @@ export default function PostSortingSelect(props: PostSortingSelectProps) { onClick={() => setIsOpen(!isOpen())} class="focus-visible:border-peach focus-visible:ring-offset-peach bg-surface0 focus-visible:ring-opacity-75 relative w-full cursor-default rounded-lg py-2 pr-10 pl-3 text-left shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 sm:text-sm" > - {selected().val} + {selected().label} - {sort.val} + {sort.label} diff --git a/src/routes/blog/index.tsx b/src/routes/blog/index.tsx index 862ee9c..9561dd1 100644 --- a/src/routes/blog/index.tsx +++ b/src/routes/blog/index.tsx @@ -14,7 +14,13 @@ export default function BlogIndex() { const sort = () => searchParams.sort || "newest"; const filters = () => searchParams.filter || ""; - const data = createAsync(() => api.blog.getPosts.query()); + // Pass filters and sortBy to server query + const data = createAsync(() => + api.blog.getPosts.query({ + filters: filters(), + sortBy: sort() as any // Will be validated by Zod schema + }) + ); return ( <> @@ -51,8 +57,6 @@ export default function BlogIndex() {
diff --git a/src/server/api/routers/blog.ts b/src/server/api/routers/blog.ts index 5e0ed26..97640a8 100644 --- a/src/server/api/routers/blog.ts +++ b/src/server/api/routers/blog.ts @@ -1,14 +1,7 @@ import { createTRPCRouter, publicProcedure } from "../utils"; import { ConnectionFactory } from "~/server/utils"; import { withCache } from "~/server/cache"; - -// Simple in-memory cache for blog posts to reduce DB load -let cachedPosts: { - posts: any[]; - tagMap: Record; - privilegeLevel: string; -} | null = null; -let cacheTimestamp: number = 0; +import { postQueryInputSchema } from "~/server/api/schemas/blog"; export const blogRouter = createTRPCRouter({ getRecentPosts: publicProcedure.query(async () => { @@ -44,61 +37,117 @@ export const blogRouter = createTRPCRouter({ }); }), - getPosts: publicProcedure.query(async ({ ctx }) => { - const privilegeLevel = ctx.privilegeLevel; + getPosts: publicProcedure + .input(postQueryInputSchema) + .query(async ({ ctx, input }) => { + const privilegeLevel = ctx.privilegeLevel; + const { filters, sortBy } = input; - // Check if we have fresh cached data (cache duration: 30 seconds) - const now = Date.now(); - if (cachedPosts && now - cacheTimestamp < 30000) { - return cachedPosts; - } + // Create cache key based on filters and sort + const cacheKey = `posts-${privilegeLevel}-${filters || "all"}-${sortBy}`; - // Single optimized query using JOINs instead of subqueries and separate queries - let query = ` - SELECT - p.id, - p.title, - p.subtitle, - p.body, - p.banner_photo, - p.date, - p.published, - p.category, - p.author_id, - p.reads, - p.attachments, - COUNT(DISTINCT pl.user_id) as total_likes, - COUNT(DISTINCT c.id) as total_comments, - GROUP_CONCAT(t.value) as tags - FROM Post p - LEFT JOIN PostLike pl ON p.id = pl.post_id - LEFT JOIN Comment c ON p.id = c.post_id - LEFT JOIN Tag t ON p.id = t.post_id`; + // Note: We're removing simple cache due to filtering/sorting variations + // Consider implementing a more sophisticated cache strategy if needed - if (privilegeLevel !== "admin") { - query += ` WHERE p.published = TRUE`; - } - query += ` GROUP BY p.id, p.title, p.subtitle, p.body, p.banner_photo, p.date, p.published, p.category, p.author_id, p.reads, p.attachments ORDER BY p.date DESC;`; + const conn = ConnectionFactory(); - const conn = ConnectionFactory(); - const results = await conn.execute(query); - const posts = results.rows; + // Parse filter tags (pipe-separated) + const filterTags = filters ? filters.split("|").filter(Boolean) : []; - // Process tags into a map for the UI - let tagMap: Record = {}; - posts.forEach((post: any) => { - if (post.tags) { - const postTags = post.tags.split(","); - postTags.forEach((tag: string) => { - tagMap[tag] = (tagMap[tag] || 0) + 1; - }); + // Build base query + let query = ` + SELECT + p.id, + p.title, + p.subtitle, + p.body, + p.banner_photo, + p.date, + p.published, + p.category, + p.author_id, + p.reads, + p.attachments, + COUNT(DISTINCT pl.user_id) as total_likes, + COUNT(DISTINCT c.id) as total_comments, + GROUP_CONCAT(t.value) as tags + FROM Post p + LEFT JOIN PostLike pl ON p.id = pl.post_id + LEFT JOIN Comment c ON p.id = c.post_id + LEFT JOIN Tag t ON p.id = t.post_id`; + + // Build WHERE clause + const whereClauses: string[] = []; + const queryArgs: any[] = []; + + // Published filter (if not admin) + if (privilegeLevel !== "admin") { + whereClauses.push("p.published = TRUE"); } - }); - // Cache the results - cachedPosts = { posts, tagMap, privilegeLevel }; - cacheTimestamp = now; + // Tag filter (if provided) + if (filterTags.length > 0) { + // Use EXISTS subquery for tag filtering + whereClauses.push(` + EXISTS ( + SELECT 1 FROM Tag t2 + WHERE t2.post_id = p.id + AND t2.value IN (${filterTags.map(() => "?").join(", ")}) + ) + `); + queryArgs.push(...filterTags); + } - return cachedPosts; - }) + // Add WHERE clause if any conditions exist + if (whereClauses.length > 0) { + query += ` WHERE ${whereClauses.join(" AND ")}`; + } + + // Add GROUP BY + query += ` GROUP BY p.id, p.title, p.subtitle, p.body, p.banner_photo, p.date, p.published, p.category, p.author_id, p.reads, p.attachments`; + + // Add ORDER BY based on sortBy parameter + switch (sortBy) { + case "newest": + query += ` ORDER BY p.date DESC`; + break; + case "oldest": + query += ` ORDER BY p.date ASC`; + break; + case "most_liked": + query += ` ORDER BY total_likes DESC`; + break; + case "most_read": + query += ` ORDER BY p.reads DESC`; + break; + case "most_comments": + query += ` ORDER BY total_comments DESC`; + break; + default: + query += ` ORDER BY p.date DESC`; + } + + query += ";"; + + // Execute query + const results = await conn.execute({ + sql: query, + args: queryArgs + }); + const posts = results.rows; + + // Process tags into a map for the UI + // Note: This includes ALL tags from filtered results + let tagMap: Record = {}; + posts.forEach((post: any) => { + if (post.tags) { + const postTags = post.tags.split(","); + postTags.forEach((tag: string) => { + tagMap[tag] = (tagMap[tag] || 0) + 1; + }); + } + }); + + return { posts, tagMap, privilegeLevel }; + }) }); diff --git a/src/server/api/schemas/blog.ts b/src/server/api/schemas/blog.ts new file mode 100644 index 0000000..793f542 --- /dev/null +++ b/src/server/api/schemas/blog.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; + +/** + * Blog Query Schemas + * + * Schemas for filtering and sorting blog posts server-side + */ + +/** + * Post sort mode enum + * Defines available sorting options for blog posts + */ +export const postSortModeSchema = z.enum([ + "newest", + "oldest", + "most_liked", + "most_read", + "most_comments" +]); + +/** + * Post query input schema + * Accepts optional filters (pipe-separated tags) and sort mode + */ +export const postQueryInputSchema = z.object({ + /** + * Pipe-separated list of tags to filter by + * e.g., "tech|design|javascript" + * Empty string or undefined means no filter + */ + filters: z.string().optional(), + + /** + * Sort mode for posts + * Defaults to "newest" if not specified + */ + sortBy: postSortModeSchema.default("newest") +}); + +/** + * Type exports for use in components + */ +export type PostSortMode = z.infer; +export type PostQueryInput = z.infer;