fix sort and filter

This commit is contained in:
Michael Freno
2025-12-20 01:59:00 -05:00
parent 921863c602
commit c51771dacd
13 changed files with 391 additions and 658 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 246 KiB

View File

@@ -160,6 +160,7 @@ function AppLayout(props: { children: any }) {
const handleCenterTapRelease = (e: MouseEvent | TouchEvent) => {
const isMobile = window.innerWidth < 768;
// Only hide left bar on mobile when it's visible
if (isMobile && leftBarVisible()) {
const target = e.target as HTMLElement;
const isInteractive = target.closest(

View File

@@ -1,4 +1,5 @@
import { createSignal, Show } from "solid-js";
import { useSearchParams } from "@solidjs/router";
import type {
Comment,
CommentReaction,
@@ -42,8 +43,10 @@ interface CommentSectionProps {
}
export default function CommentSection(props: CommentSectionProps) {
const [searchParams] = useSearchParams();
const [selectedSorting, setSelectedSorting] = createSignal<SortingMode>(
COMMENT_SORTING_OPTIONS[0].val
(searchParams.sortBy as SortingMode) || COMMENT_SORTING_OPTIONS[0].val
);
const hasComments = () =>

View File

@@ -1,6 +1,5 @@
import { createSignal, createEffect, For, Show, createMemo } from "solid-js";
import { createSignal, createEffect, For, Show } from "solid-js";
import type { CommentSortingProps } from "~/types/comment";
import { sortComments } from "~/lib/comment-utils";
import CommentBlock from "./CommentBlock";
export default function CommentSorting(props: CommentSortingProps) {
@@ -35,17 +34,9 @@ export default function CommentSorting(props: CommentSortingProps) {
}
};
// Memoized sorted comments
const sortedComments = createMemo(() => {
return sortComments(
props.topLevelComments,
props.selectedSorting.val,
props.reactionMap
);
});
// Comments are already sorted from server, no need for client-side sorting
return (
<For each={sortedComments()}>
<For each={props.topLevelComments}>
{(topLevelComment) => (
<div
onClick={() => checkForDoubleClick(topLevelComment.id)}

View File

@@ -1,4 +1,5 @@
import { For, Show, createSignal } from "solid-js";
import { useNavigate, useLocation } from "@solidjs/router";
import type { CommentSortingSelectProps, SortingMode } from "~/types/comment";
import Check from "~/components/icons/Check";
import UpDownArrows from "~/components/icons/UpDownArrows";
@@ -12,6 +13,8 @@ const SORTING_OPTIONS: { val: SortingMode; label: string }[] = [
export default function CommentSortingSelect(props: CommentSortingSelectProps) {
const [isOpen, setIsOpen] = createSignal(false);
const navigate = useNavigate();
const location = useLocation();
const selectedLabel = () => {
const option = SORTING_OPTIONS.find(
@@ -23,6 +26,14 @@ export default function CommentSortingSelect(props: CommentSortingSelectProps) {
const handleSelect = (mode: SortingMode) => {
props.setSorting(mode);
setIsOpen(false);
// Update URL with sortBy parameter
const url = new URL(window.location.href);
url.searchParams.set("sortBy", mode);
navigate(`${location.pathname}?${url.searchParams.toString()}#comments`, {
scroll: false,
replace: true
});
};
return (

View File

@@ -1,31 +1,128 @@
import { For, Show } from "solid-js";
import { For, Show, createMemo } from "solid-js";
import Card, { Post } from "./Card";
export interface Tag {
value: string;
post_id: number;
}
export interface PostSortingProps {
posts: Post[];
tags: Tag[];
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) {
// Build set of tags that are ALLOWED (not filtered out)
const allowedTags = createMemo(() => {
const filterList = props.filters?.split("|").filter(Boolean) || [];
// If no filters set, all tags are allowed
if (filterList.length === 0) {
return new Set(props.tags.map((t) => t.value.slice(1)));
}
// Build set of tags that are checked (allowed to show)
const allTags = new Set(props.tags.map((t) => t.value.slice(1)));
const filteredOutTags = new Set(filterList);
const allowed = new Set<string>();
allTags.forEach((tag) => {
if (!filteredOutTags.has(tag)) {
allowed.add(tag);
}
});
return allowed;
});
// Get posts that have at least one allowed tag
const filteredPosts = createMemo(() => {
const allowed = allowedTags();
// If all tags are allowed, show all posts
if (
allowed.size ===
props.tags
.map((t) => t.value.slice(1))
.filter((v, i, a) => a.indexOf(v) === i).length
) {
return props.posts;
}
// Build map of post_id -> tags for that post
const postTags = new Map<number, Set<string>>();
props.tags.forEach((tag) => {
if (!postTags.has(tag.post_id)) {
postTags.set(tag.post_id, new Set());
}
postTags.get(tag.post_id)!.add(tag.value.slice(1));
});
// Keep posts that have at least one allowed tag
return props.posts.filter((post) => {
const tags = postTags.get(post.id);
if (!tags) return false; // Post has no tags
// Check if post has at least one allowed tag
for (const tag of tags) {
if (allowed.has(tag)) {
return true;
}
}
return false;
});
});
const sortedPosts = createMemo(() => {
let sorted = [...filteredPosts()];
switch (props.sort) {
case "newest":
sorted.reverse(); // Posts come oldest first from DB
break;
case "oldest":
// Already in oldest order from DB
break;
case "most_liked":
sorted.sort((a, b) => (b.total_likes || 0) - (a.total_likes || 0));
break;
case "most_read":
sorted.sort((a, b) => (b.reads || 0) - (a.reads || 0));
break;
case "most_comments":
sorted.sort(
(a, b) => (b.total_comments || 0) - (a.total_comments || 0)
);
break;
default:
sorted.reverse(); // Default to newest
}
return sorted;
});
return (
<Show
when={props.posts.length > 0}
when={sortedPosts().length > 0}
fallback={
<div class="pt-12 text-center text-2xl tracking-wide italic">
No posts found!
</div>
<Show
when={props.posts.length > 0}
fallback={
<div class="pt-12 text-center text-2xl tracking-wide italic">
No posts found!
</div>
}
>
<div class="pt-12 text-center text-2xl tracking-wide italic">
All posts filtered out!
</div>
</Show>
}
>
<For each={props.posts}>
<For each={sortedPosts()}>
{(post) => (
<div class="my-4">
<Card post={post} privilegeLevel={props.privilegeLevel} />

View File

@@ -65,8 +65,17 @@ export default function TagSelector(props: TagSelectorProps) {
}
};
const handleUncheckAll = () => {
// Build filter string with all tags
const allTags =
Object.keys(props.tagMap)
.map((key) => key.slice(1))
.join("|") + "|";
navigate(`${location.pathname}?sort=${currentSort()}&filter=${allTags}`);
};
return (
<>
<div class="relative">
<button
ref={buttonRef}
type="button"
@@ -78,8 +87,17 @@ export default function TagSelector(props: TagSelectorProps) {
<Show when={showingMenu()}>
<div
ref={menuRef}
class="bg-surface0 absolute z-50 mt-12 rounded-lg py-2 pr-4 pl-2 shadow-lg"
class="bg-surface0 absolute top-full left-0 z-50 mt-2 rounded-lg py-2 pr-4 pl-2 shadow-lg"
>
<div class="border-overlay0 mb-2 flex justify-center border-b pb-2">
<button
type="button"
onClick={handleUncheckAll}
class="text-text hover:text-red text-xs font-medium underline"
>
Uncheck All
</button>
</div>
<For each={Object.entries(props.tagMap)}>
{([key, value]) => (
<div class="mx-auto my-2 flex">
@@ -98,6 +116,6 @@ export default function TagSelector(props: TagSelectorProps) {
</For>
</div>
</Show>
</>
</div>
);
}

View File

@@ -1,441 +0,0 @@
# React to SolidJS Conversion Patterns
This guide documents common patterns for converting React code to SolidJS for this migration.
## Table of Contents
- [State Management](#state-management)
- [Effects](#effects)
- [Refs](#refs)
- [Routing](#routing)
- [Conditional Rendering](#conditional-rendering)
- [Lists](#lists)
- [Forms](#forms)
- [Event Handlers](#event-handlers)
## State Management
### React (useState)
```tsx
import { useState } from "react";
const [count, setCount] = useState(0);
const [user, setUser] = useState<User | null>(null);
// Update
setCount(count + 1);
setCount(prev => prev + 1);
setUser({ ...user, name: "John" });
```
### Solid (createSignal)
```tsx
import { createSignal } from "solid-js";
const [count, setCount] = createSignal(0);
const [user, setUser] = createSignal<User | null>(null);
// Update - note the function call to read value
setCount(count() + 1);
setCount(prev => prev + 1);
setUser({ ...user(), name: "John" });
// ⚠️ Important: Always call the signal to read its value
console.log(count()); // ✅ Correct
console.log(count); // ❌ Wrong - this is the function itself
```
## Effects
### React (useEffect)
```tsx
import { useEffect } from "react";
// Run once on mount
useEffect(() => {
console.log("Mounted");
return () => {
console.log("Cleanup");
};
}, []);
// Run when dependency changes
useEffect(() => {
console.log(count);
}, [count]);
```
### Solid (createEffect / onMount / onCleanup)
```tsx
import { createEffect, onMount, onCleanup } from "solid-js";
// Run once on mount
onMount(() => {
console.log("Mounted");
});
// Cleanup
onCleanup(() => {
console.log("Cleanup");
});
// Run when dependency changes (automatic tracking)
createEffect(() => {
console.log(count()); // Automatically tracks count signal
});
// ⚠️ Important: Effects automatically track any signal reads
// No dependency array needed!
```
## Refs
### React (useRef)
```tsx
import { useRef } from "react";
const inputRef = useRef<HTMLInputElement>(null);
// Access
inputRef.current?.focus();
// In JSX
<input ref={inputRef} />
```
### Solid (let binding or signal)
```tsx
// Method 1: Direct binding (preferred for simple cases)
let inputRef: HTMLInputElement | undefined;
// Access
inputRef?.focus();
// In JSX
<input ref={inputRef} />
// Method 2: Using a signal (for reactive refs)
import { createSignal } from "solid-js";
const [inputRef, setInputRef] = createSignal<HTMLInputElement>();
// Access
inputRef()?.focus();
// In JSX
<input ref={setInputRef} />
```
## Routing
### React (Next.js)
```tsx
import Link from "next/link";
import { useRouter } from "next/navigation";
// Link component
<Link href="/about">About</Link>
// Programmatic navigation
const router = useRouter();
router.push("/dashboard");
router.back();
router.refresh();
```
### Solid (SolidStart)
```tsx
import { A, useNavigate } from "@solidjs/router";
// Link component
<A href="/about">About</A>
// Programmatic navigation
const navigate = useNavigate();
navigate("/dashboard");
navigate(-1); // Go back
// Note: No refresh() - Solid is reactive by default
```
## Conditional Rendering
### React
```tsx
// Using && operator
{isLoggedIn && <Dashboard />}
// Using ternary
{isLoggedIn ? <Dashboard /> : <Login />}
// Using if statement
if (loading) return <Spinner />;
return <Content />;
```
### Solid
```tsx
import { Show } from "solid-js";
// Using Show component (recommended)
<Show when={isLoggedIn()}>
<Dashboard />
</Show>
// With fallback
<Show when={isLoggedIn()} fallback={<Login />}>
<Dashboard />
</Show>
// ⚠️ Important: Can still use && and ternary, but Show is more efficient
// because it doesn't recreate the DOM on every change
// Early return still works
if (loading()) return <Spinner />;
return <Content />;
```
## Lists
### React
```tsx
// Using map
{users.map(user => (
<div key={user.id}>{user.name}</div>
))}
// With index
{users.map((user, index) => (
<div key={index}>{user.name}</div>
))}
```
### Solid
```tsx
import { For, Index } from "solid-js";
// Using For (when items have stable keys)
<For each={users()}>
{(user) => <div>{user.name}</div>}
</For>
// With index
<For each={users()}>
{(user, index) => <div>{index()} - {user.name}</div>}
</For>
// Using Index (when items have no stable identity, keyed by index)
<Index each={users()}>
{(user, index) => <div>{index} - {user().name}</div>}
</Index>
// ⚠️ Key differences:
// - For: Better when items have stable identity (keyed by reference)
// - Index: Better when items change frequently (keyed by index)
// - Note the () on user in Index component
```
## Forms
### React
```tsx
import { useState } from "react";
const [email, setEmail] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log(email);
};
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</form>
```
### Solid
```tsx
import { createSignal } from "solid-js";
const [email, setEmail] = createSignal("");
const handleSubmit = (e: Event) => {
e.preventDefault();
console.log(email());
};
<form onSubmit={handleSubmit}>
<input
type="email"
value={email()}
onInput={(e) => setEmail(e.currentTarget.value)}
/>
</form>
// ⚠️ Important differences:
// - Use onInput instead of onChange for real-time updates
// - onChange fires on blur in Solid
// - Use e.currentTarget instead of e.target for type safety
```
## Event Handlers
### React
```tsx
// Inline
<button onClick={() => setCount(count + 1)}>
// Function reference
<button onClick={handleClick}>
// With parameters
<button onClick={(e) => handleClick(e, id)}>
```
### Solid
```tsx
// Inline - same as React
<button onClick={() => setCount(count() + 1)}>
// Function reference - same as React
<button onClick={handleClick}>
// With parameters - same as React
<button onClick={(e) => handleClick(e, id)}>
// Alternative syntax with array (for optimization)
<button onClick={[handleClick, id]}>
// ⚠️ Important: Remember to call signals with ()
<button onClick={() => setCount(count() + 1)}> // ✅
<button onClick={() => setCount(count + 1)}> // ❌
```
## Server Actions to tRPC
### React (Next.js Server Actions)
```tsx
"use server";
export async function updateProfile(displayName: string) {
const userId = cookies().get("userIDToken");
await db.execute("UPDATE User SET display_name = ? WHERE id = ?",
[displayName, userId]);
return { success: true };
}
// Client usage
import { updateProfile } from "./actions";
const result = await updateProfile("John");
```
### Solid (tRPC)
```tsx
// Server (in src/server/api/routers/user.ts)
updateDisplayName: publicProcedure
.input(z.object({ displayName: z.string() }))
.mutation(async ({ input, ctx }) => {
const userId = await getUserID(ctx.event);
const conn = ConnectionFactory();
await conn.execute({
sql: "UPDATE User SET display_name = ? WHERE id = ?",
args: [input.displayName, userId]
});
return { success: true };
})
// Client usage
import { api } from "~/lib/api";
const result = await api.user.updateDisplayName.mutate({
displayName: "John"
});
```
## Common Gotchas
### 1. Reading Signal Values
```tsx
// ❌ WRONG
const value = mySignal; // This is the function, not the value!
// ✅ CORRECT
const value = mySignal(); // Call it to get the value
```
### 2. Updating Objects in Signals
```tsx
// ❌ WRONG - Mutating directly
const [user, setUser] = createSignal({ name: "John" });
user().name = "Jane"; // This won't trigger reactivity!
// ✅ CORRECT - Create new object
setUser({ ...user(), name: "Jane" });
// ✅ ALSO CORRECT - Using produce (from solid-js/store)
import { produce } from "solid-js/store";
setUser(produce(u => { u.name = "Jane"; }));
```
### 3. Conditional Effects
```tsx
// ❌ WRONG - Effect won't re-run when condition changes
if (someCondition()) {
createEffect(() => {
// This only creates the effect if condition is true initially
});
}
// ✅ CORRECT - Effect tracks condition reactively
createEffect(() => {
if (someCondition()) {
// This runs whenever condition or dependencies change
}
});
```
### 4. Cleanup in Effects
```tsx
// ❌ WRONG
createEffect(() => {
const timer = setInterval(() => {}, 1000);
// No cleanup!
});
// ✅ CORRECT
createEffect(() => {
const timer = setInterval(() => {}, 1000);
onCleanup(() => {
clearInterval(timer);
});
});
```
## Quick Reference Card
| React | Solid | Notes |
|-------|-------|-------|
| `useState` | `createSignal` | Call signal to read: `count()` |
| `useEffect` | `createEffect` | Auto-tracks dependencies |
| `useRef` | `let` binding | Or use signal for reactive refs |
| `useRouter()` | `useNavigate()` | Different API |
| `Link` | `A` | Different import |
| `{cond && <A />}` | `<Show when={cond()}><A /></Show>` | Show is more efficient |
| `{arr.map()}` | `<For each={arr()}></For>` | For is more efficient |
| `onChange` | `onInput` | onChange fires on blur |
| `e.target` | `e.currentTarget` | Better types |
| "use server" | tRPC router | Different architecture |
## Tips for Success
1. **Always call signals to read their values**: `count()` not `count`
2. **Use Show and For components**: More efficient than && and map
3. **Effects auto-track**: No dependency arrays needed
4. **Immutable updates**: Always create new objects when updating signals
5. **Use onCleanup**: Clean up timers, subscriptions, etc.
6. **Type safety**: SolidJS has excellent TypeScript support - use it!

View File

@@ -71,6 +71,10 @@ export function getCommentLevel(
// ============================================================================
/**
* @deprecated Server-side sorting is now implemented in the blog post route.
* Comments are sorted by SQL queries for better performance.
* This function remains for backward compatibility only.
*
* Calculates "hot" score for a comment based on votes and time
* Uses logarithmic decay for older comments
*/
@@ -90,6 +94,10 @@ function calculateHotScore(
}
/**
* @deprecated Server-side sorting is now implemented in the blog post route.
* Use SQL-based sorting instead for better performance.
* This function remains for backward compatibility only.
*
* Counts upvotes for a comment from reaction map
*/
function getUpvoteCount(
@@ -103,6 +111,10 @@ function getUpvoteCount(
}
/**
* @deprecated Server-side sorting is now implemented in the blog post route.
* Use SQL-based sorting instead for better performance.
* This function remains for backward compatibility only.
*
* Counts downvotes for a comment from reaction map
*/
function getDownvoteCount(
@@ -116,6 +128,11 @@ function getDownvoteCount(
}
/**
* @deprecated Server-side sorting is now implemented in the blog post route.
* Comments are now sorted by SQL queries in src/routes/blog/[title]/index.tsx
* for better performance and reduced client-side processing.
* This function remains for backward compatibility only.
*
* Sorts comments based on the selected sorting mode
*
* Modes:

View File

@@ -1,5 +1,11 @@
import { Show, Suspense, For } from "solid-js";
import { useParams, A, Navigate, query } from "@solidjs/router";
import {
useParams,
A,
Navigate,
query,
useSearchParams
} from "@solidjs/router";
import { Title, Meta } from "@solidjs/meta";
import { createAsync } from "@solidjs/router";
import { getRequestEvent } from "solid-js/web";
@@ -12,39 +18,56 @@ import { useBars } from "~/context/bars";
import { TerminalSplash } from "~/components/TerminalSplash";
// Server function to fetch post by title
const getPostByTitle = query(async (title: string) => {
"use server";
const { ConnectionFactory, getUserID, getPrivilegeLevel } =
await import("~/server/utils");
const event = getRequestEvent()!;
const privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
const userID = await getUserID(event.nativeEvent);
const conn = ConnectionFactory();
const getPostByTitle = query(
async (
title: string,
sortBy: "newest" | "oldest" | "highest_rated" | "hot" = "newest"
) => {
"use server";
const { ConnectionFactory, getUserID, getPrivilegeLevel } =
await import("~/server/utils");
const event = getRequestEvent()!;
const privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
const userID = await getUserID(event.nativeEvent);
const conn = ConnectionFactory();
let query = "SELECT * FROM Post WHERE title = ?";
if (privilegeLevel !== "admin") {
query += ` AND published = TRUE`;
}
let query = "SELECT * FROM Post WHERE title = ?";
if (privilegeLevel !== "admin") {
query += ` AND published = TRUE`;
}
const postResults = await conn.execute({
sql: query,
args: [decodeURIComponent(title)]
});
const post = postResults.rows[0] as any;
if (!post) {
// Check if post exists but is unpublished
const existQuery = "SELECT id FROM Post WHERE title = ?";
const existRes = await conn.execute({
sql: existQuery,
const postResults = await conn.execute({
sql: query,
args: [decodeURIComponent(title)]
});
if (existRes.rows[0]) {
const post = postResults.rows[0] as any;
if (!post) {
// Check if post exists but is unpublished
const existQuery = "SELECT id FROM Post WHERE title = ?";
const existRes = await conn.execute({
sql: existQuery,
args: [decodeURIComponent(title)]
});
if (existRes.rows[0]) {
return {
post: null,
exists: true,
comments: [],
likes: [],
tags: [],
userCommentArray: [],
reactionArray: [],
privilegeLevel: "anonymous" as const,
userID: null
};
}
return {
post: null,
exists: true,
exists: false,
comments: [],
likes: [],
tags: [],
@@ -55,82 +78,134 @@ const getPostByTitle = query(async (title: string) => {
};
}
return {
post: null,
exists: false,
comments: [],
likes: [],
tags: [],
userCommentArray: [],
reactionArray: [],
privilegeLevel: "anonymous" as const,
userID: null
};
}
// Fetch comments with sorting
let commentQuery = "SELECT * FROM Comment WHERE post_id = ?";
// Fetch comments
const commentQuery = "SELECT * FROM Comment WHERE post_id = ?";
const comments = (await conn.execute({ sql: commentQuery, args: [post.id] }))
.rows;
// Fetch likes
const likeQuery = "SELECT * FROM PostLike WHERE post_id = ?";
const likes = (await conn.execute({ sql: likeQuery, args: [post.id] })).rows;
// Fetch tags
const tagQuery = "SELECT * FROM Tag WHERE post_id = ?";
const tags = (await conn.execute({ sql: tagQuery, args: [post.id] })).rows;
// Build commenter map
const commenterToCommentIDMap = new Map<string, number[]>();
comments.forEach((comment: any) => {
const prev = commenterToCommentIDMap.get(comment.commenter_id) || [];
commenterToCommentIDMap.set(comment.commenter_id, [...prev, comment.id]);
});
const commenterQuery =
"SELECT email, display_name, image FROM User WHERE id = ?";
// Convert to serializable array format
const userCommentArray: Array<[UserPublicData, number[]]> = [];
for (const [key, value] of commenterToCommentIDMap.entries()) {
const res = await conn.execute({ sql: commenterQuery, args: [key] });
const user = res.rows[0];
if (user) {
userCommentArray.push([user as UserPublicData, value]);
// Build ORDER BY clause based on sortBy parameter
switch (sortBy) {
case "newest":
commentQuery += " ORDER BY date DESC";
break;
case "oldest":
commentQuery += " ORDER BY date ASC";
break;
case "highest_rated":
// Calculate net score (upvotes - downvotes) for each comment
commentQuery = `
SELECT c.*,
COALESCE((
SELECT COUNT(*) FROM CommentReaction
WHERE comment_id = c.id
AND type IN ('tears', 'heartEye', 'moneyEye')
), 0) - COALESCE((
SELECT COUNT(*) FROM CommentReaction
WHERE comment_id = c.id
AND type IN ('angry', 'sick', 'worried')
), 0) as net_score
FROM Comment c
WHERE c.post_id = ?
ORDER BY net_score DESC, c.date DESC
`;
break;
case "hot":
// Calculate hot score: (upvotes - downvotes) / log10(age_in_hours + 2)
commentQuery = `
SELECT c.*,
(COALESCE((
SELECT COUNT(*) FROM CommentReaction
WHERE comment_id = c.id
AND type IN ('tears', 'heartEye', 'moneyEye')
), 0) - COALESCE((
SELECT COUNT(*) FROM CommentReaction
WHERE comment_id = c.id
AND type IN ('angry', 'sick', 'worried')
), 0)) /
LOG10(((JULIANDAY('now') - JULIANDAY(c.date)) * 24) + 2) as hot_score
FROM Comment c
WHERE c.post_id = ?
ORDER BY hot_score DESC, c.date DESC
`;
break;
}
}
// Get reaction map as serializable array
const reactionArray: Array<[number, CommentReaction[]]> = [];
for (const comment of comments) {
const reactionQuery = "SELECT * FROM CommentReaction WHERE comment_id = ?";
const res = await conn.execute({
sql: reactionQuery,
args: [(comment as any).id]
const comments = (
await conn.execute({ sql: commentQuery, args: [post.id] })
).rows;
// Fetch likes
const likeQuery = "SELECT * FROM PostLike WHERE post_id = ?";
const likes = (await conn.execute({ sql: likeQuery, args: [post.id] }))
.rows;
// Fetch tags
const tagQuery = "SELECT * FROM Tag WHERE post_id = ?";
const tags = (await conn.execute({ sql: tagQuery, args: [post.id] })).rows;
// Build commenter map
const commenterToCommentIDMap = new Map<string, number[]>();
comments.forEach((comment: any) => {
const prev = commenterToCommentIDMap.get(comment.commenter_id) || [];
commenterToCommentIDMap.set(comment.commenter_id, [...prev, comment.id]);
});
reactionArray.push([(comment as any).id, res.rows as CommentReaction[]]);
}
return {
post,
exists: true,
comments,
likes,
tags,
topLevelComments: comments.filter((c: any) => c.parent_comment_id == null),
userCommentArray,
reactionArray,
privilegeLevel,
userID
};
}, "post-by-title");
const commenterQuery =
"SELECT email, display_name, image FROM User WHERE id = ?";
// Convert to serializable array format
const userCommentArray: Array<[UserPublicData, number[]]> = [];
for (const [key, value] of commenterToCommentIDMap.entries()) {
const res = await conn.execute({ sql: commenterQuery, args: [key] });
const user = res.rows[0];
if (user) {
userCommentArray.push([user as UserPublicData, value]);
}
}
// Get reaction map as serializable array
const reactionArray: Array<[number, CommentReaction[]]> = [];
for (const comment of comments) {
const reactionQuery =
"SELECT * FROM CommentReaction WHERE comment_id = ?";
const res = await conn.execute({
sql: reactionQuery,
args: [(comment as any).id]
});
reactionArray.push([(comment as any).id, res.rows as CommentReaction[]]);
}
// Filter top-level comments (preserve sort order from SQL)
const topLevelComments = comments.filter(
(c: any) => c.parent_comment_id == null
);
return {
post,
exists: true,
comments,
likes,
tags,
topLevelComments,
userCommentArray,
reactionArray,
privilegeLevel,
userID,
sortBy
};
},
"post-by-title"
);
export default function PostPage() {
const params = useParams();
const [searchParams] = useSearchParams();
const data = createAsync(() => getPostByTitle(params.title));
const data = createAsync(() => {
const sortBy =
(searchParams.sortBy as "newest" | "oldest" | "highest_rated" | "hot") ||
"newest";
return getPostByTitle(params.title, sortBy);
});
const hasCodeBlock = (str: string): boolean => {
return str.includes("<code") && str.includes("</code>");

View File

@@ -14,13 +14,7 @@ export default function BlogIndex() {
const sort = () => searchParams.sort || "newest";
const filters = () => searchParams.filter || "";
// 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
})
);
const data = createAsync(() => api.blog.getPosts.query());
return (
<>
@@ -56,7 +50,10 @@ export default function BlogIndex() {
<div class="mx-auto flex w-11/12 flex-col pt-8">
<PostSorting
posts={data()!.posts}
tags={data()!.tags}
privilegeLevel={data()!.privilegeLevel}
filters={filters()}
sort={sort()}
/>
</div>
</Show>

View File

@@ -1,7 +1,6 @@
import { createTRPCRouter, publicProcedure } from "../utils";
import { ConnectionFactory } from "~/server/utils";
import { withCache } from "~/server/cache";
import { postQueryInputSchema } from "~/server/api/schemas/blog";
export const blogRouter = createTRPCRouter({
getRecentPosts: publicProcedure.query(async () => {
@@ -37,25 +36,14 @@ export const blogRouter = createTRPCRouter({
});
}),
getPosts: publicProcedure
.input(postQueryInputSchema)
.query(async ({ ctx, input }) => {
const privilegeLevel = ctx.privilegeLevel;
const { filters, sortBy } = input;
// Create cache key based on filters and sort
const cacheKey = `posts-${privilegeLevel}-${filters || "all"}-${sortBy}`;
// Note: We're removing simple cache due to filtering/sorting variations
// Consider implementing a more sophisticated cache strategy if needed
getPosts: publicProcedure.query(async ({ ctx }) => {
const privilegeLevel = ctx.privilegeLevel;
return withCache(`posts-${privilegeLevel}`, 5 * 60 * 1000, async () => {
const conn = ConnectionFactory();
// Parse filter tags (pipe-separated)
const filterTags = filters ? filters.split("|").filter(Boolean) : [];
// Build base query
let query = `
// Fetch all posts with aggregated data
let postsQuery = `
SELECT
p.id,
p.title,
@@ -69,85 +57,40 @@ export const blogRouter = createTRPCRouter({
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
COUNT(DISTINCT c.id) as total_comments
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");
postsQuery += ` WHERE p.published = TRUE`;
}
// 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);
}
postsQuery += ` 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`;
postsQuery += ` ORDER BY p.date ASC;`;
// Add WHERE clause if any conditions exist
if (whereClauses.length > 0) {
query += ` WHERE ${whereClauses.join(" AND ")}`;
}
const postsResult = await conn.execute(postsQuery);
const posts = postsResult.rows;
// 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`;
const tagsQuery = `
SELECT t.value, t.post_id
FROM Tag t
JOIN Post p ON t.post_id = p.id
${privilegeLevel !== "admin" ? "WHERE p.published = TRUE" : ""}
ORDER BY t.value ASC
`;
// 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`;
}
const tagsResult = await conn.execute(tagsQuery);
const tags = tagsResult.rows;
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;
});
}
const tagMap: Record<string, number> = {};
tags.forEach((tag: any) => {
const key = `${tag.value}`;
tagMap[key] = (tagMap[key] || 0) + 1;
});
return { posts, tagMap, privilegeLevel };
})
return { posts, tags, tagMap, privilegeLevel };
});
})
});

View File

@@ -0,0 +1,21 @@
/**
* Comment API Validation Schemas
*
* Zod schemas for comment-related tRPC procedures:
* - Comment sorting validation
*/
import { z } from "zod";
// ============================================================================
// Comment Sorting
// ============================================================================
/**
* Valid comment sorting modes
*/
export const commentSortSchema = z
.enum(["newest", "oldest", "highest_rated", "hot"])
.default("newest");
export type CommentSortMode = z.infer<typeof commentSortSchema>;