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 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()) {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
const isInteractive = target.closest(
|
const isInteractive = target.closest(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createSignal, Show } from "solid-js";
|
import { createSignal, Show } from "solid-js";
|
||||||
|
import { useSearchParams } from "@solidjs/router";
|
||||||
import type {
|
import type {
|
||||||
Comment,
|
Comment,
|
||||||
CommentReaction,
|
CommentReaction,
|
||||||
@@ -42,8 +43,10 @@ interface CommentSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function CommentSection(props: CommentSectionProps) {
|
export default function CommentSection(props: CommentSectionProps) {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
const [selectedSorting, setSelectedSorting] = createSignal<SortingMode>(
|
const [selectedSorting, setSelectedSorting] = createSignal<SortingMode>(
|
||||||
COMMENT_SORTING_OPTIONS[0].val
|
(searchParams.sortBy as SortingMode) || COMMENT_SORTING_OPTIONS[0].val
|
||||||
);
|
);
|
||||||
|
|
||||||
const hasComments = () =>
|
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 type { CommentSortingProps } from "~/types/comment";
|
||||||
import { sortComments } from "~/lib/comment-utils";
|
|
||||||
import CommentBlock from "./CommentBlock";
|
import CommentBlock from "./CommentBlock";
|
||||||
|
|
||||||
export default function CommentSorting(props: CommentSortingProps) {
|
export default function CommentSorting(props: CommentSortingProps) {
|
||||||
@@ -35,17 +34,9 @@ export default function CommentSorting(props: CommentSortingProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Memoized sorted comments
|
// Comments are already sorted from server, no need for client-side sorting
|
||||||
const sortedComments = createMemo(() => {
|
|
||||||
return sortComments(
|
|
||||||
props.topLevelComments,
|
|
||||||
props.selectedSorting.val,
|
|
||||||
props.reactionMap
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<For each={sortedComments()}>
|
<For each={props.topLevelComments}>
|
||||||
{(topLevelComment) => (
|
{(topLevelComment) => (
|
||||||
<div
|
<div
|
||||||
onClick={() => checkForDoubleClick(topLevelComment.id)}
|
onClick={() => checkForDoubleClick(topLevelComment.id)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { For, Show, createSignal } from "solid-js";
|
import { For, Show, createSignal } from "solid-js";
|
||||||
|
import { useNavigate, useLocation } from "@solidjs/router";
|
||||||
import type { CommentSortingSelectProps, SortingMode } from "~/types/comment";
|
import type { CommentSortingSelectProps, SortingMode } from "~/types/comment";
|
||||||
import Check from "~/components/icons/Check";
|
import Check from "~/components/icons/Check";
|
||||||
import UpDownArrows from "~/components/icons/UpDownArrows";
|
import UpDownArrows from "~/components/icons/UpDownArrows";
|
||||||
@@ -12,6 +13,8 @@ const SORTING_OPTIONS: { val: SortingMode; label: string }[] = [
|
|||||||
|
|
||||||
export default function CommentSortingSelect(props: CommentSortingSelectProps) {
|
export default function CommentSortingSelect(props: CommentSortingSelectProps) {
|
||||||
const [isOpen, setIsOpen] = createSignal(false);
|
const [isOpen, setIsOpen] = createSignal(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const selectedLabel = () => {
|
const selectedLabel = () => {
|
||||||
const option = SORTING_OPTIONS.find(
|
const option = SORTING_OPTIONS.find(
|
||||||
@@ -23,6 +26,14 @@ export default function CommentSortingSelect(props: CommentSortingSelectProps) {
|
|||||||
const handleSelect = (mode: SortingMode) => {
|
const handleSelect = (mode: SortingMode) => {
|
||||||
props.setSorting(mode);
|
props.setSorting(mode);
|
||||||
setIsOpen(false);
|
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 (
|
return (
|
||||||
|
|||||||
@@ -1,31 +1,128 @@
|
|||||||
import { For, Show } from "solid-js";
|
import { For, Show, createMemo } from "solid-js";
|
||||||
import Card, { Post } from "./Card";
|
import Card, { Post } from "./Card";
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
value: string;
|
||||||
|
post_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PostSortingProps {
|
export interface PostSortingProps {
|
||||||
posts: Post[];
|
posts: Post[];
|
||||||
|
tags: Tag[];
|
||||||
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) {
|
||||||
|
// 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 (
|
return (
|
||||||
<Show
|
<Show
|
||||||
when={props.posts.length > 0}
|
when={sortedPosts().length > 0}
|
||||||
fallback={
|
fallback={
|
||||||
<div class="pt-12 text-center text-2xl tracking-wide italic">
|
<Show
|
||||||
No posts found!
|
when={props.posts.length > 0}
|
||||||
</div>
|
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) => (
|
{(post) => (
|
||||||
<div class="my-4">
|
<div class="my-4">
|
||||||
<Card post={post} privilegeLevel={props.privilegeLevel} />
|
<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 (
|
return (
|
||||||
<>
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -78,8 +87,17 @@ export default function TagSelector(props: TagSelectorProps) {
|
|||||||
<Show when={showingMenu()}>
|
<Show when={showingMenu()}>
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
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)}>
|
<For each={Object.entries(props.tagMap)}>
|
||||||
{([key, value]) => (
|
{([key, value]) => (
|
||||||
<div class="mx-auto my-2 flex">
|
<div class="mx-auto my-2 flex">
|
||||||
@@ -98,6 +116,6 @@ export default function TagSelector(props: TagSelectorProps) {
|
|||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</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
|
* Calculates "hot" score for a comment based on votes and time
|
||||||
* Uses logarithmic decay for older comments
|
* 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
|
* Counts upvotes for a comment from reaction map
|
||||||
*/
|
*/
|
||||||
function getUpvoteCount(
|
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
|
* Counts downvotes for a comment from reaction map
|
||||||
*/
|
*/
|
||||||
function getDownvoteCount(
|
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
|
* Sorts comments based on the selected sorting mode
|
||||||
*
|
*
|
||||||
* Modes:
|
* Modes:
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Show, Suspense, For } from "solid-js";
|
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 { Title, Meta } from "@solidjs/meta";
|
||||||
import { createAsync } from "@solidjs/router";
|
import { createAsync } from "@solidjs/router";
|
||||||
import { getRequestEvent } from "solid-js/web";
|
import { getRequestEvent } from "solid-js/web";
|
||||||
@@ -12,39 +18,56 @@ import { useBars } from "~/context/bars";
|
|||||||
import { TerminalSplash } from "~/components/TerminalSplash";
|
import { TerminalSplash } from "~/components/TerminalSplash";
|
||||||
|
|
||||||
// Server function to fetch post by title
|
// Server function to fetch post by title
|
||||||
const getPostByTitle = query(async (title: string) => {
|
const getPostByTitle = query(
|
||||||
"use server";
|
async (
|
||||||
const { ConnectionFactory, getUserID, getPrivilegeLevel } =
|
title: string,
|
||||||
await import("~/server/utils");
|
sortBy: "newest" | "oldest" | "highest_rated" | "hot" = "newest"
|
||||||
const event = getRequestEvent()!;
|
) => {
|
||||||
const privilegeLevel = await getPrivilegeLevel(event.nativeEvent);
|
"use server";
|
||||||
const userID = await getUserID(event.nativeEvent);
|
const { ConnectionFactory, getUserID, getPrivilegeLevel } =
|
||||||
const conn = ConnectionFactory();
|
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 = ?";
|
let query = "SELECT * FROM Post WHERE title = ?";
|
||||||
if (privilegeLevel !== "admin") {
|
if (privilegeLevel !== "admin") {
|
||||||
query += ` AND published = TRUE`;
|
query += ` AND published = TRUE`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const postResults = await conn.execute({
|
const postResults = await conn.execute({
|
||||||
sql: query,
|
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,
|
|
||||||
args: [decodeURIComponent(title)]
|
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 {
|
return {
|
||||||
post: null,
|
post: null,
|
||||||
exists: true,
|
exists: false,
|
||||||
comments: [],
|
comments: [],
|
||||||
likes: [],
|
likes: [],
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -55,82 +78,134 @@ const getPostByTitle = query(async (title: string) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// Fetch comments with sorting
|
||||||
post: null,
|
let commentQuery = "SELECT * FROM Comment WHERE post_id = ?";
|
||||||
exists: false,
|
|
||||||
comments: [],
|
|
||||||
likes: [],
|
|
||||||
tags: [],
|
|
||||||
userCommentArray: [],
|
|
||||||
reactionArray: [],
|
|
||||||
privilegeLevel: "anonymous" as const,
|
|
||||||
userID: null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch comments
|
// Build ORDER BY clause based on sortBy parameter
|
||||||
const commentQuery = "SELECT * FROM Comment WHERE post_id = ?";
|
switch (sortBy) {
|
||||||
const comments = (await conn.execute({ sql: commentQuery, args: [post.id] }))
|
case "newest":
|
||||||
.rows;
|
commentQuery += " ORDER BY date DESC";
|
||||||
|
break;
|
||||||
// Fetch likes
|
case "oldest":
|
||||||
const likeQuery = "SELECT * FROM PostLike WHERE post_id = ?";
|
commentQuery += " ORDER BY date ASC";
|
||||||
const likes = (await conn.execute({ sql: likeQuery, args: [post.id] })).rows;
|
break;
|
||||||
|
case "highest_rated":
|
||||||
// Fetch tags
|
// Calculate net score (upvotes - downvotes) for each comment
|
||||||
const tagQuery = "SELECT * FROM Tag WHERE post_id = ?";
|
commentQuery = `
|
||||||
const tags = (await conn.execute({ sql: tagQuery, args: [post.id] })).rows;
|
SELECT c.*,
|
||||||
|
COALESCE((
|
||||||
// Build commenter map
|
SELECT COUNT(*) FROM CommentReaction
|
||||||
const commenterToCommentIDMap = new Map<string, number[]>();
|
WHERE comment_id = c.id
|
||||||
comments.forEach((comment: any) => {
|
AND type IN ('tears', 'heartEye', 'moneyEye')
|
||||||
const prev = commenterToCommentIDMap.get(comment.commenter_id) || [];
|
), 0) - COALESCE((
|
||||||
commenterToCommentIDMap.set(comment.commenter_id, [...prev, comment.id]);
|
SELECT COUNT(*) FROM CommentReaction
|
||||||
});
|
WHERE comment_id = c.id
|
||||||
|
AND type IN ('angry', 'sick', 'worried')
|
||||||
const commenterQuery =
|
), 0) as net_score
|
||||||
"SELECT email, display_name, image FROM User WHERE id = ?";
|
FROM Comment c
|
||||||
|
WHERE c.post_id = ?
|
||||||
// Convert to serializable array format
|
ORDER BY net_score DESC, c.date DESC
|
||||||
const userCommentArray: Array<[UserPublicData, number[]]> = [];
|
`;
|
||||||
|
break;
|
||||||
for (const [key, value] of commenterToCommentIDMap.entries()) {
|
case "hot":
|
||||||
const res = await conn.execute({ sql: commenterQuery, args: [key] });
|
// Calculate hot score: (upvotes - downvotes) / log10(age_in_hours + 2)
|
||||||
const user = res.rows[0];
|
commentQuery = `
|
||||||
if (user) {
|
SELECT c.*,
|
||||||
userCommentArray.push([user as UserPublicData, value]);
|
(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 comments = (
|
||||||
const reactionArray: Array<[number, CommentReaction[]]> = [];
|
await conn.execute({ sql: commentQuery, args: [post.id] })
|
||||||
for (const comment of comments) {
|
).rows;
|
||||||
const reactionQuery = "SELECT * FROM CommentReaction WHERE comment_id = ?";
|
|
||||||
const res = await conn.execute({
|
// Fetch likes
|
||||||
sql: reactionQuery,
|
const likeQuery = "SELECT * FROM PostLike WHERE post_id = ?";
|
||||||
args: [(comment as any).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 {
|
const commenterQuery =
|
||||||
post,
|
"SELECT email, display_name, image FROM User WHERE id = ?";
|
||||||
exists: true,
|
|
||||||
comments,
|
// Convert to serializable array format
|
||||||
likes,
|
const userCommentArray: Array<[UserPublicData, number[]]> = [];
|
||||||
tags,
|
|
||||||
topLevelComments: comments.filter((c: any) => c.parent_comment_id == null),
|
for (const [key, value] of commenterToCommentIDMap.entries()) {
|
||||||
userCommentArray,
|
const res = await conn.execute({ sql: commenterQuery, args: [key] });
|
||||||
reactionArray,
|
const user = res.rows[0];
|
||||||
privilegeLevel,
|
if (user) {
|
||||||
userID
|
userCommentArray.push([user as UserPublicData, value]);
|
||||||
};
|
}
|
||||||
}, "post-by-title");
|
}
|
||||||
|
|
||||||
|
// 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() {
|
export default function PostPage() {
|
||||||
const params = useParams();
|
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 => {
|
const hasCodeBlock = (str: string): boolean => {
|
||||||
return str.includes("<code") && str.includes("</code>");
|
return str.includes("<code") && str.includes("</code>");
|
||||||
|
|||||||
@@ -14,13 +14,7 @@ export default function BlogIndex() {
|
|||||||
const sort = () => searchParams.sort || "newest";
|
const sort = () => searchParams.sort || "newest";
|
||||||
const filters = () => searchParams.filter || "";
|
const filters = () => searchParams.filter || "";
|
||||||
|
|
||||||
// Pass filters and sortBy to server query
|
const data = createAsync(() => api.blog.getPosts.query());
|
||||||
const data = createAsync(() =>
|
|
||||||
api.blog.getPosts.query({
|
|
||||||
filters: filters(),
|
|
||||||
sortBy: sort() as any // Will be validated by Zod schema
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -56,7 +50,10 @@ export default function BlogIndex() {
|
|||||||
<div class="mx-auto flex w-11/12 flex-col pt-8">
|
<div class="mx-auto flex w-11/12 flex-col pt-8">
|
||||||
<PostSorting
|
<PostSorting
|
||||||
posts={data()!.posts}
|
posts={data()!.posts}
|
||||||
|
tags={data()!.tags}
|
||||||
privilegeLevel={data()!.privilegeLevel}
|
privilegeLevel={data()!.privilegeLevel}
|
||||||
|
filters={filters()}
|
||||||
|
sort={sort()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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";
|
|
||||||
|
|
||||||
export const blogRouter = createTRPCRouter({
|
export const blogRouter = createTRPCRouter({
|
||||||
getRecentPosts: publicProcedure.query(async () => {
|
getRecentPosts: publicProcedure.query(async () => {
|
||||||
@@ -37,25 +36,14 @@ export const blogRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getPosts: publicProcedure
|
getPosts: publicProcedure.query(async ({ ctx }) => {
|
||||||
.input(postQueryInputSchema)
|
const privilegeLevel = ctx.privilegeLevel;
|
||||||
.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
|
|
||||||
|
|
||||||
|
return withCache(`posts-${privilegeLevel}`, 5 * 60 * 1000, async () => {
|
||||||
const conn = ConnectionFactory();
|
const conn = ConnectionFactory();
|
||||||
|
|
||||||
// Parse filter tags (pipe-separated)
|
// Fetch all posts with aggregated data
|
||||||
const filterTags = filters ? filters.split("|").filter(Boolean) : [];
|
let postsQuery = `
|
||||||
|
|
||||||
// Build base query
|
|
||||||
let query = `
|
|
||||||
SELECT
|
SELECT
|
||||||
p.id,
|
p.id,
|
||||||
p.title,
|
p.title,
|
||||||
@@ -69,85 +57,40 @@ export const blogRouter = createTRPCRouter({
|
|||||||
p.reads,
|
p.reads,
|
||||||
p.attachments,
|
p.attachments,
|
||||||
COUNT(DISTINCT pl.user_id) as total_likes,
|
COUNT(DISTINCT pl.user_id) as total_likes,
|
||||||
COUNT(DISTINCT c.id) as total_comments,
|
COUNT(DISTINCT c.id) as total_comments
|
||||||
GROUP_CONCAT(t.value) as tags
|
|
||||||
FROM Post p
|
FROM Post p
|
||||||
LEFT JOIN PostLike pl ON p.id = pl.post_id
|
LEFT JOIN PostLike pl ON p.id = pl.post_id
|
||||||
LEFT JOIN Comment c ON p.id = c.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") {
|
if (privilegeLevel !== "admin") {
|
||||||
whereClauses.push("p.published = TRUE");
|
postsQuery += ` WHERE p.published = TRUE`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag filter (if provided)
|
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`;
|
||||||
if (filterTags.length > 0) {
|
postsQuery += ` ORDER BY p.date ASC;`;
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add WHERE clause if any conditions exist
|
const postsResult = await conn.execute(postsQuery);
|
||||||
if (whereClauses.length > 0) {
|
const posts = postsResult.rows;
|
||||||
query += ` WHERE ${whereClauses.join(" AND ")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add GROUP BY
|
const tagsQuery = `
|
||||||
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`;
|
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
|
const tagsResult = await conn.execute(tagsQuery);
|
||||||
switch (sortBy) {
|
const tags = tagsResult.rows;
|
||||||
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 += ";";
|
const tagMap: Record<string, number> = {};
|
||||||
|
tags.forEach((tag: any) => {
|
||||||
// Execute query
|
const key = `${tag.value}`;
|
||||||
const results = await conn.execute({
|
tagMap[key] = (tagMap[key] || 0) + 1;
|
||||||
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 };
|
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