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