server continue

This commit is contained in:
Michael Freno
2025-12-19 23:54:11 -05:00
parent 2e80fbd11e
commit 921863c602
6 changed files with 181 additions and 126 deletions

View File

@@ -157,18 +157,15 @@ function AppLayout(props: { children: any }) {
}); });
}); });
const handleCenterTap = (e: MouseEvent) => { const handleCenterTapRelease = (e: MouseEvent | TouchEvent) => {
const isMobile = window.innerWidth < 768; const isMobile = window.innerWidth < 768;
// Only hide left bar on mobile when it's visible
if (isMobile && leftBarVisible()) { if (isMobile && leftBarVisible()) {
// Check if the click is on an interactive element
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
const isInteractive = target.closest( const isInteractive = target.closest(
"a, button, input, select, textarea, [onclick]" "a, button, input, select, textarea, [onclick]"
); );
// Don't hide if clicking on interactive elements
if (!isInteractive) { if (!isInteractive) {
setLeftBarVisible(false); setLeftBarVisible(false);
} }
@@ -185,7 +182,8 @@ function AppLayout(props: { children: any }) {
width: `${centerWidth()}px`, width: `${centerWidth()}px`,
"margin-left": `${leftBarSize()}px` "margin-left": `${leftBarSize()}px`
}} }}
onClick={handleCenterTap} onMouseUp={handleCenterTapRelease}
onTouchEnd={handleCenterTapRelease}
> >
<Show when={barsInitialized()} fallback={<TerminalSplash />}> <Show when={barsInitialized()} fallback={<TerminalSplash />}>
<Suspense fallback={<TerminalSplash />}>{props.children}</Suspense> <Suspense fallback={<TerminalSplash />}>{props.children}</Suspense>

View File

@@ -4,68 +4,28 @@ import Card, { Post } from "./Card";
export interface PostSortingProps { export interface PostSortingProps {
posts: Post[]; posts: Post[];
privilegeLevel: "anonymous" | "admin" | "user"; 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) { export default function PostSorting(props: PostSortingProps) {
const postsToFilter = () => {
const filterSet = new Set<number>();
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 ( return (
<Show <Show
when={!(props.posts.length > 0 && filteredPosts().length === 0)} when={props.posts.length > 0}
fallback={ fallback={
<div class="pt-12 text-center text-2xl tracking-wide italic"> <div class="pt-12 text-center text-2xl tracking-wide italic">
All posts filtered out! No posts found!
</div> </div>
} }
> >
<For each={sortedPosts()}> <For each={props.posts}>
{(post) => ( {(post) => (
<div class="my-4"> <div class="my-4">
<Card post={post} privilegeLevel={props.privilegeLevel} /> <Card post={post} privilegeLevel={props.privilegeLevel} />

View File

@@ -4,11 +4,11 @@ import Check from "~/components/icons/Check";
import UpDownArrows from "~/components/icons/UpDownArrows"; import UpDownArrows from "~/components/icons/UpDownArrows";
const sorting = [ const sorting = [
{ val: "Newest" }, { val: "newest", label: "Newest" },
{ val: "Oldest" }, { val: "oldest", label: "Oldest" },
{ val: "Most Liked" }, { val: "most_liked", label: "Most Liked" },
{ val: "Most Read" }, { val: "most_read", label: "Most Read" },
{ val: "Most Comments" } { val: "most_comments", label: "Most Comments" }
]; ];
export interface PostSortingSelectProps {} export interface PostSortingSelectProps {}
@@ -23,14 +23,14 @@ export default function PostSortingSelect(props: PostSortingSelectProps) {
const currentFilters = () => searchParams.filter || null; const currentFilters = () => searchParams.filter || null;
createEffect(() => { createEffect(() => {
let newRoute = location.pathname + "?sort=" + selected().val.toLowerCase(); let newRoute = location.pathname + "?sort=" + selected().val;
if (currentFilters()) { if (currentFilters()) {
newRoute += "&filter=" + currentFilters(); newRoute += "&filter=" + currentFilters();
} }
navigate(newRoute); navigate(newRoute);
}); });
const handleSelect = (sort: { val: string }) => { const handleSelect = (sort: { val: string; label: string }) => {
setSelected(sort); setSelected(sort);
setIsOpen(false); setIsOpen(false);
}; };
@@ -42,7 +42,7 @@ export default function PostSortingSelect(props: PostSortingSelectProps) {
onClick={() => setIsOpen(!isOpen())} 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" 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"
> >
<span class="block truncate">{selected().val}</span> <span class="block truncate">{selected().label}</span>
<span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<UpDownArrows <UpDownArrows
strokeWidth={1.5} strokeWidth={1.5}
@@ -71,7 +71,7 @@ export default function PostSortingSelect(props: PostSortingSelectProps) {
selected().val === sort.val ? "font-medium" : "font-normal" selected().val === sort.val ? "font-medium" : "font-normal"
}`} }`}
> >
{sort.val} {sort.label}
</span> </span>
<Show when={selected().val === sort.val}> <Show when={selected().val === sort.val}>
<span class="text-peach absolute inset-y-0 left-0 flex items-center pl-3"> <span class="text-peach absolute inset-y-0 left-0 flex items-center pl-3">

View File

@@ -14,7 +14,13 @@ export default function BlogIndex() {
const sort = () => searchParams.sort || "newest"; const sort = () => searchParams.sort || "newest";
const filters = () => searchParams.filter || ""; 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 ( return (
<> <>
@@ -51,8 +57,6 @@ export default function BlogIndex() {
<PostSorting <PostSorting
posts={data()!.posts} posts={data()!.posts}
privilegeLevel={data()!.privilegeLevel} privilegeLevel={data()!.privilegeLevel}
filters={filters()}
sort={sort()}
/> />
</div> </div>
</Show> </Show>

View File

@@ -1,14 +1,7 @@
import { createTRPCRouter, publicProcedure } from "../utils"; import { createTRPCRouter, publicProcedure } from "../utils";
import { ConnectionFactory } from "~/server/utils"; import { ConnectionFactory } from "~/server/utils";
import { withCache } from "~/server/cache"; import { withCache } from "~/server/cache";
import { postQueryInputSchema } from "~/server/api/schemas/blog";
// Simple in-memory cache for blog posts to reduce DB load
let cachedPosts: {
posts: any[];
tagMap: Record<string, number>;
privilegeLevel: string;
} | null = null;
let cacheTimestamp: number = 0;
export const blogRouter = createTRPCRouter({ export const blogRouter = createTRPCRouter({
getRecentPosts: publicProcedure.query(async () => { getRecentPosts: publicProcedure.query(async () => {
@@ -44,61 +37,117 @@ export const blogRouter = createTRPCRouter({
}); });
}), }),
getPosts: publicProcedure.query(async ({ ctx }) => { getPosts: publicProcedure
const privilegeLevel = ctx.privilegeLevel; .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) // Create cache key based on filters and sort
const now = Date.now(); const cacheKey = `posts-${privilegeLevel}-${filters || "all"}-${sortBy}`;
if (cachedPosts && now - cacheTimestamp < 30000) {
return cachedPosts;
}
// Single optimized query using JOINs instead of subqueries and separate queries // Note: We're removing simple cache due to filtering/sorting variations
let query = ` // Consider implementing a more sophisticated cache strategy if needed
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`;
if (privilegeLevel !== "admin") { const conn = ConnectionFactory();
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(); // Parse filter tags (pipe-separated)
const results = await conn.execute(query); const filterTags = filters ? filters.split("|").filter(Boolean) : [];
const posts = results.rows;
// Process tags into a map for the UI // Build base query
let tagMap: Record<string, number> = {}; let query = `
posts.forEach((post: any) => { SELECT
if (post.tags) { p.id,
const postTags = post.tags.split(","); p.title,
postTags.forEach((tag: string) => { p.subtitle,
tagMap[tag] = (tagMap[tag] || 0) + 1; 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 // Tag filter (if provided)
cachedPosts = { posts, tagMap, privilegeLevel }; if (filterTags.length > 0) {
cacheTimestamp = now; // 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<string, number> = {};
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 };
})
}); });

View File

@@ -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<typeof postSortModeSchema>;
export type PostQueryInput = z.infer<typeof postQueryInputSchema>;