added category infinite grid and fixed missing items at useInfiniteQuery

This commit is contained in:
2025-03-15 15:20:17 +01:00
parent f591e18847
commit 4ad0357133
15 changed files with 321 additions and 1100 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
"use client";
import React from "react";
import CategoriesGrid, {
CategoriesGridSkeleton,
} from "@/components/category/categories-grid";
import CategoryGrid, {
CategoryGridSkeleton,
} from "@/components/category/grid/category-grid";
import ArticleGrid, {
ArticleGridSkeleton,
} from "@/components/article/grid/article-grid";
+2 -4
View File
@@ -1,13 +1,11 @@
import CategoriesGrid from "@/components/category/categories-grid";
import InfiniteCategoryGrid from "@/components/category/grid/infinite-category-grid";
import { api } from "@/trpc/server";
import React from "react";
async function Page() {
const categories = await api.category.getAll();
return (
<>
<h1 className="text-2xl font-bold">Kategorien</h1>
<CategoriesGrid categories={categories} />
<InfiniteCategoryGrid />
</>
);
}
@@ -117,6 +117,4 @@ const sortItems: Array<{
{ Icon: CalendarArrowDown, value: "oldest", label: "Älteste" },
{ Icon: ArrowDownAZ, value: "abc", label: "Alphabetisch A-Z" },
{ Icon: ArrowUpAz, value: "cba", label: "Alphabetisch Z-A" },
// { Icon: Eye, value: "popular", label: "Beliebteste" },
// { Icon: MessageSquare, value: "commented", label: "Meistkommentiert" },
];
@@ -1,13 +1,14 @@
import CategoryCard from "@/components/category/category-card";
import { Category } from "@/server/db/schema";
import React from "react";
import { Skeleton } from "../ui/skeleton";
import { Skeleton } from "@/components/ui/skeleton";
const GRID_CLASS = "grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3";
export const CATEGORY_GRID_CLASS =
"grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3";
function CategoriesGrid({ categories }: { categories: Category[] }) {
function CategoryGrid({ categories }: { categories: Category[] }) {
return (
<menu className={GRID_CLASS}>
<menu className={CATEGORY_GRID_CLASS}>
{categories.map((category) => (
<li key={category.id}>
<CategoryCard {...category} />
@@ -17,12 +18,12 @@ function CategoriesGrid({ categories }: { categories: Category[] }) {
);
}
export default CategoriesGrid;
export default CategoryGrid;
export function CategoriesGridSkeleton() {
export function CategoryGridSkeleton() {
const range = Array.from(new Array(6).keys());
return (
<ul className={GRID_CLASS}>
<ul className={CATEGORY_GRID_CLASS}>
{range.map((i) => (
<li key={i}>
<Skeleton className="h-12 w-full" />
@@ -0,0 +1,67 @@
"use client";
import { api } from "@/trpc/react";
import React from "react";
import CategoryCard from "../category-card";
import { useInfiniteItemsObserver } from "@/lib/hooks/infinite-items-observer-hook";
import { Skeleton } from "@/components/ui/skeleton";
import { CATEGORY_GRID_CLASS } from "./category-grid";
// import ArticleFilterBar, { ArticleFilter } from "../article-filter-bar";
export default function InfiniteCategoryGrid() {
// const [filter, setFilter] = React.useState<ArticleFilter | undefined>(
// undefined,
// );
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
api.category.getByCursor.useInfiniteQuery(
{
// filter,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
},
);
// Calculate all visible items across all loaded pages
const allItems = React.useMemo(() => {
return data?.pages.flatMap((page) => page.items) || [];
}, [data]);
// Ref for bottom observation
const bottomObserverRef = React.useRef(null);
useInfiniteItemsObserver({
bottomObserverRef,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
});
return (
<div className="relative space-y-4">
{/* <ArticleFilterBar onFilterUpdate={setFilter} /> */}
<menu className={`${CATEGORY_GRID_CLASS} overflow-auto`}>
{data?.pages?.length
? allItems.map((category, idx) => (
<li key={`category-${idx}`}>
<CategoryCard {...category} />
</li>
))
: null}
{/* Loading indicator */}
{(isLoading || isFetchingNextPage) &&
Array.from(new Array(isLoading ? 16 : 4).keys()).map((idx) => (
<li key={idx}>
<Skeleton className="size-full min-h-20" />
</li>
))}
{/* Bottom observer element */}
{hasNextPage && (
<li ref={bottomObserverRef} className="col-span-full h-12" />
)}
</menu>
</div>
);
}
+5
View File
@@ -5,3 +5,8 @@ export const categorySchema = z.object({
description: z.string().optional(),
image: z.string().optional(),
});
export const categoryFilterSchema = z.object({
query: z.string().optional(),
sort: z.string().optional(),
});
+69 -17
View File
@@ -11,22 +11,50 @@ import {
articleSchema,
createArticleSchema,
} from "@/lib/validation/zod/article";
import { and, asc, count, desc, eq, gt, ilike, like, sql } from "drizzle-orm";
import {
and,
asc,
count,
desc,
eq,
gte,
ilike,
like,
lte,
sql,
} from "drizzle-orm";
import { hasPermission, Role } from "@/lib/validation/permissions";
import { generateSlug } from "@/lib/utils";
import SuperJSON from "superjson";
type ArticleCursor = Pick<Article, "slug" | "createdAt" | "title">;
const getArticleSorting = (sort: string, cursor?: ArticleCursor) => {
// Default to newest
const baseCase = {
orderBy: [desc(articles.createdAt), asc(articles.slug)],
cursor: cursor ? lte(articles.createdAt, cursor.createdAt) : undefined,
};
const getArticleSorting = (sort: string) => {
switch (sort) {
case "newest":
return desc(articles.createdAt);
return baseCase;
case "oldest":
return asc(articles.createdAt);
return {
orderBy: [asc(articles.createdAt), asc(articles.slug)],
cursor: cursor ? gte(articles.createdAt, cursor.createdAt) : undefined,
};
case "abc":
return asc(articles.title);
return {
orderBy: [asc(articles.title), asc(articles.slug)],
cursor: cursor ? gte(articles.title, cursor.title) : undefined,
};
case "cba":
return desc(articles.title);
return {
orderBy: [desc(articles.title), asc(articles.slug)],
cursor: cursor ? lte(articles.title, cursor.title) : undefined,
};
default:
return desc(articles.createdAt); // Default to newest
return baseCase;
}
};
@@ -59,15 +87,32 @@ export const articleRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
const { cursor } = input!;
const limit = input?.limit ?? 50;
const cursorArg = cursor ? gt(articles.slug, cursor) : undefined;
// Decode cursor if using it
let cursorObj: ArticleCursor | undefined;
if (cursor) {
try {
cursorObj = SuperJSON.parse(Buffer.from(cursor, "base64").toString());
} catch (e) {
// Handle invalid cursor
cursorObj = undefined;
}
}
const queryFilterArg = input?.filter?.query?.length
? ilike(articles.title, "%" + input.filter.query + "%")
: undefined;
const categoryArg = input?.filter?.category
? eq(articles.categoryId, input.filter.category)
: undefined;
const orderBy = getArticleSorting(input?.filter?.sort ?? "newest");
const sortConfig = input?.filter?.sort ?? "newest";
const { orderBy, cursor: cursorArg } = getArticleSorting(
sortConfig,
cursorObj,
);
const items = await ctx.db.query.articles.findMany({
where: and(
cursorArg,
@@ -83,15 +128,26 @@ export const articleRouter = createTRPCRouter({
createdAt: true,
},
});
let nextCursor: typeof cursor | undefined = undefined;
let nextCursor: string | undefined = undefined;
if (items.length > limit) {
const nextItem = items.pop();
nextCursor = nextItem!.slug;
console.log("Configure next cursor");
const cursorItem = items.pop();
// Create a cursor object with the relevant fields for sorting
const cursorData: ArticleCursor = {
slug: cursorItem!.slug,
createdAt: cursorItem!.createdAt,
title: cursorItem!.title,
};
// Encode the cursor as base64
nextCursor = Buffer.from(SuperJSON.stringify(cursorData)).toString(
"base64",
);
}
return {
items,
nextCursor,
previousCursor: cursor,
};
}),
getAll: publicProcedure
@@ -160,10 +216,6 @@ export const articleRouter = createTRPCRouter({
if (!isEditor) {
throw new Error("You are not allowed to update articles");
}
console.log(
"Content before save",
JSON.stringify(input.article.content),
);
return await ctx.db
.update(articles)
+115 -2
View File
@@ -6,10 +6,58 @@ import {
} from "@/server/api/trpc";
import { categories, Category } from "@/server/db/schema";
import { count, eq, like } from "drizzle-orm";
import {
and,
asc,
count,
desc,
eq,
gt,
gte,
ilike,
like,
lte,
} from "drizzle-orm";
import { hasPermission, Role } from "@/lib/validation/permissions";
import { categorySchema } from "@/lib/validation/zod/category";
import {
categoryFilterSchema,
categorySchema,
} from "@/lib/validation/zod/category";
import { generateSlug } from "@/lib/utils";
import SuperJSON from "superjson";
type CategoryCursor = Pick<Category, "slug" | "createdAt" | "name">;
const getCategorySorting = (sort: string, cursor?: CategoryCursor) => {
// Default to newest
const baseCase = {
orderBy: [desc(categories.createdAt), asc(categories.slug)],
cursor: cursor ? lte(categories.createdAt, cursor.createdAt) : undefined,
};
switch (sort) {
case "newest":
return baseCase;
case "oldest":
return {
orderBy: [asc(categories.createdAt), asc(categories.slug)],
cursor: cursor
? gte(categories.createdAt, cursor.createdAt)
: undefined,
};
case "abc":
return {
orderBy: [asc(categories.name), asc(categories.slug)],
cursor: cursor ? gte(categories.name, cursor.name) : undefined,
};
case "cba":
return {
orderBy: [desc(categories.name), asc(categories.slug)],
cursor: cursor ? lte(categories.name, cursor.name) : undefined,
};
default:
return baseCase;
}
};
export const categoryRouter = createTRPCRouter({
search: publicProcedure
@@ -42,6 +90,71 @@ export const categoryRouter = createTRPCRouter({
});
}),
getByCursor: publicProcedure
.input(
z.object({
limit: z.number().optional(),
cursor: z.string().optional(),
filter: categoryFilterSchema.optional(),
}),
)
.query(async ({ ctx, input }) => {
const { cursor } = input!;
const limit = input?.limit ?? 50;
// Decode cursor if using it
let cursorObj: CategoryCursor | undefined;
if (cursor) {
try {
cursorObj = SuperJSON.parse(Buffer.from(cursor, "base64").toString());
} catch (e) {
// Handle invalid cursor
cursorObj = undefined;
}
}
const queryFilterArg = input?.filter?.query?.length
? ilike(categories.name, "%" + input.filter.query + "%")
: undefined;
const sortConfig = input?.filter?.sort ?? "newest";
const { orderBy, cursor: cursorArg } = getCategorySorting(
sortConfig,
cursorObj,
);
const items = (await ctx.db.query.categories.findMany({
where: and(cursorArg, queryFilterArg),
limit: limit + 1,
orderBy,
columns: {
name: true,
slug: true,
createdAt: true,
},
})) as Category[];
let nextCursor: string | undefined = undefined;
if (items.length > limit) {
console.log("Configure next cursor");
const cursorItem = items.pop();
// Create a cursor object with the relevant fields for sorting
const cursorData: CategoryCursor = {
slug: cursorItem!.slug,
createdAt: cursorItem!.createdAt,
name: cursorItem!.name,
};
// Encode the cursor as base64
nextCursor = Buffer.from(SuperJSON.stringify(cursorData)).toString(
"base64",
);
}
return {
items,
nextCursor,
};
}),
getCount: publicProcedure.query(async ({ ctx }) => {
return (await ctx.db.select({ count: count() }).from(categories))[0]?.count;
}),
+2 -2
View File
@@ -29,7 +29,7 @@ export const articles = createTable(
.primaryKey()
.$defaultFn(() => createId())
.notNull(),
title: varchar("title", { length: 256 }),
title: varchar("title", { length: 256 }).notNull(),
slug: varchar("slug", { length: 256 }).unique().notNull(),
authorId: varchar("author_id", { length: 255 }),
content: jsonb("content").$type<JSONContent>(),
@@ -68,7 +68,7 @@ export const categories = createTable(
.primaryKey()
.$defaultFn(() => createId())
.notNull(),
name: varchar("name", { length: 256 }),
name: varchar("name", { length: 256 }).notNull(),
description: text("description"),
slug: varchar("slug", { length: 256 }).unique().notNull(),