fix sort and filter
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 246 KiB |
@@ -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(
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,22 +1,113 @@
|
||||
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={sortedPosts().length > 0}
|
||||
fallback={
|
||||
<Show
|
||||
when={props.posts.length > 0}
|
||||
fallback={
|
||||
@@ -25,7 +116,13 @@ export default function PostSorting(props: PostSortingProps) {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={props.posts}>
|
||||
<div class="pt-12 text-center text-2xl tracking-wide italic">
|
||||
All posts filtered out!
|
||||
</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<For each={sortedPosts()}>
|
||||
{(post) => (
|
||||
<div class="my-4">
|
||||
<Card post={post} privilegeLevel={props.privilegeLevel} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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!
|
||||
@@ -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:
|
||||
|
||||
@@ -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,7 +18,11 @@ import { useBars } from "~/context/bars";
|
||||
import { TerminalSplash } from "~/components/TerminalSplash";
|
||||
|
||||
// Server function to fetch post by title
|
||||
const getPostByTitle = query(async (title: string) => {
|
||||
const getPostByTitle = query(
|
||||
async (
|
||||
title: string,
|
||||
sortBy: "newest" | "oldest" | "highest_rated" | "hot" = "newest"
|
||||
) => {
|
||||
"use server";
|
||||
const { ConnectionFactory, getUserID, getPrivilegeLevel } =
|
||||
await import("~/server/utils");
|
||||
@@ -68,14 +78,64 @@ const getPostByTitle = query(async (title: string) => {
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch comments
|
||||
const commentQuery = "SELECT * FROM Comment WHERE post_id = ?";
|
||||
const comments = (await conn.execute({ sql: commentQuery, args: [post.id] }))
|
||||
.rows;
|
||||
// Fetch comments with sorting
|
||||
let commentQuery = "SELECT * FROM Comment WHERE post_id = ?";
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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;
|
||||
const likes = (await conn.execute({ sql: likeQuery, args: [post.id] }))
|
||||
.rows;
|
||||
|
||||
// Fetch tags
|
||||
const tagQuery = "SELECT * FROM Tag WHERE post_id = ?";
|
||||
@@ -105,7 +165,8 @@ const getPostByTitle = query(async (title: string) => {
|
||||
// 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 reactionQuery =
|
||||
"SELECT * FROM CommentReaction WHERE comment_id = ?";
|
||||
const res = await conn.execute({
|
||||
sql: reactionQuery,
|
||||
args: [(comment as any).id]
|
||||
@@ -113,24 +174,38 @@ const getPostByTitle = query(async (title: string) => {
|
||||
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: comments.filter((c: any) => c.parent_comment_id == null),
|
||||
topLevelComments,
|
||||
userCommentArray,
|
||||
reactionArray,
|
||||
privilegeLevel,
|
||||
userID
|
||||
userID,
|
||||
sortBy
|
||||
};
|
||||
}, "post-by-title");
|
||||
},
|
||||
"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>");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }) => {
|
||||
getPosts: publicProcedure.query(async ({ ctx }) => {
|
||||
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
|
||||
|
||||
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 };
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
21
src/server/api/schemas/comment.ts
Normal file
21
src/server/api/schemas/comment.ts
Normal 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>;
|
||||
Reference in New Issue
Block a user