fixed build performance issues; optimized queries
This commit is contained in:
@@ -10,8 +10,8 @@ import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
|
||||
export default async function Home() {
|
||||
const categories = await api.category.getAll({ limit: 6 });
|
||||
const articles = await api.article.getAll({ limit: 6 });
|
||||
const categories = await api.category.getMany({ limit: 6 });
|
||||
const articles = await api.article.getMany({ limit: 6 });
|
||||
return (
|
||||
<>
|
||||
<Alert>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import * as cheerio from "cheerio";
|
||||
import { NextApiRequest } from "next";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export async function GET(req: NextApiRequest) {
|
||||
export async function GET(req: Request) {
|
||||
if (req.method !== "GET") {
|
||||
return NextResponse.json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ import CategorySelect from "@/components/category/category-select";
|
||||
import { CheckCircle, XCircle } from "lucide-react";
|
||||
import PublishArticleAlertDialog from "./publish-article-alert-dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import Editor from "../editor";
|
||||
import dynamic from "next/dynamic";
|
||||
const Editor = dynamic(() => import("../editor"), { ssr: false });
|
||||
|
||||
export default ({ server_article }: { server_article: Article }) => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
@@ -19,6 +19,9 @@ function InfiniteArticlesGrid() {
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
staleTime: 60 * 4 * 1000, // 4 minutes stale time
|
||||
refetchOnMount: false, // Prevents unnecessary refetching
|
||||
refetchOnWindowFocus: false, // Avoids refetch when switching tabs
|
||||
},
|
||||
);
|
||||
// Calculate all visible items across all loaded pages
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import { JSONContent } from "novel";
|
||||
|
||||
function RenderArticle({ content }: { content: string }) {
|
||||
function RenderArticle({ content }: { content: JSONContent }) {
|
||||
return "render article: in work"; //<Editor readOnly editorProviderProps={{ content: content }} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Combobox, ComboboxProps } from "../combobox";
|
||||
import { Icons } from "../icons";
|
||||
|
||||
function CategorySelect(props: Partial<ComboboxProps>) {
|
||||
const { data: categories } = api.category.getAll.useQuery();
|
||||
const { data: categories } = api.category.getMany.useQuery();
|
||||
return (
|
||||
<Combobox
|
||||
{...(props as ComboboxProps)}
|
||||
|
||||
@@ -9,8 +9,8 @@ export const CATEGORY_GRID_CLASS =
|
||||
function CategoryGrid({ categories }: { categories: Category[] }) {
|
||||
return (
|
||||
<menu className={CATEGORY_GRID_CLASS}>
|
||||
{categories.map((category) => (
|
||||
<li key={category.id}>
|
||||
{categories.map((category, idx) => (
|
||||
<li key={idx}>
|
||||
<CategoryCard {...category} />
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -18,6 +18,9 @@ export default function InfiniteCategoryGrid() {
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
staleTime: 60 * 4 * 1000, // 4 minutes stale time
|
||||
refetchOnMount: false, // Prevents unnecessary refetching
|
||||
refetchOnWindowFocus: false, // Avoids refetch when switching tabs
|
||||
},
|
||||
);
|
||||
// Calculate all visible items across all loaded pages
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import ColorPicker, { type ColorPickerProps } from ".";
|
||||
import { Button } from "../ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
|
||||
export default function ColorPickerPopover(props: ColorPickerProps) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button style={{ backgroundColor: props.initialColor }} className="">
|
||||
color
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<ColorPicker {...props} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
.color-picker .react-colorful {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
}
|
||||
.color-picker .react-colorful__saturation {
|
||||
border-radius: 4px 4px 0 0;
|
||||
}
|
||||
.color-picker .react-colorful__hue {
|
||||
height: 40px;
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
.color-picker .react-colorful__hue-pointer {
|
||||
width: 12px;
|
||||
height: inherit;
|
||||
border-radius: 0;
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
import "./color-picker.css";
|
||||
|
||||
import React from "react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { HexColorPicker } from "react-colorful";
|
||||
import { Input } from "../ui/input";
|
||||
import { debounce } from "@/lib/utils";
|
||||
|
||||
const STORAGE_KEY = "savedColors";
|
||||
const MAX_COLORS = 12;
|
||||
const SELECT_DEBOUNCE = 500;
|
||||
|
||||
export type ColorPickerProps = {
|
||||
onInput?: (color: string) => void;
|
||||
initialColor?: string;
|
||||
};
|
||||
|
||||
function ColorPicker({ onInput, initialColor }: ColorPickerProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [customColors, setCustomColors] = useState<string[]>([]);
|
||||
const [color, setColor] = useState(initialColor ?? "#ff0000");
|
||||
|
||||
// Load colors from localStorage on mount
|
||||
useEffect(() => {
|
||||
if (initialColor?.length) setColor(initialColor);
|
||||
const storageColors = localStorage.getItem(STORAGE_KEY);
|
||||
const storedColors = storageColors ? JSON.parse(storageColors) : [];
|
||||
if (storedColors.length) {
|
||||
setCustomColors(storedColors);
|
||||
if (!initialColor?.length) setColor(storedColors[0]);
|
||||
setMounted(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const persistColor = (newColor: string) => {
|
||||
if (newColor.length < 2) return;
|
||||
if (customColors[0] === newColor) return; // Prevent duplicate consecutive colors
|
||||
|
||||
const updatedColors = [
|
||||
newColor,
|
||||
...customColors.filter((c) => c !== newColor),
|
||||
].slice(0, MAX_COLORS);
|
||||
console.log(updatedColors);
|
||||
|
||||
setCustomColors(updatedColors);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedColors));
|
||||
};
|
||||
|
||||
const selectColor = (newColor: string) => {
|
||||
persistColor(newColor);
|
||||
onInput?.(newColor);
|
||||
};
|
||||
|
||||
const selectColorDebounced = debounce((newColor: string) => {
|
||||
selectColor(newColor);
|
||||
}, SELECT_DEBOUNCE);
|
||||
|
||||
const handleColorChange = (newColor: string, skipDebounce = false) => {
|
||||
setColor(newColor);
|
||||
if (skipDebounce)
|
||||
selectColor(newColor); // Delayed save
|
||||
else selectColorDebounced(newColor);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="color-picker flex flex-col items-center gap-4">
|
||||
{/* React Colorful Picker */}
|
||||
<HexColorPicker
|
||||
color={color}
|
||||
onChange={handleColorChange}
|
||||
className="w-full"
|
||||
/>
|
||||
|
||||
{/* Default Input (Native Color Picker) */}
|
||||
<div className="flex w-full gap-2">
|
||||
<div
|
||||
className="size-8 rounded-md border"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<Input
|
||||
className="h-8"
|
||||
value={color}
|
||||
onInput={(e) => handleColorChange(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Display Recent Colors */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{customColors.map((col) => (
|
||||
<button
|
||||
key={col}
|
||||
className="size-8 rounded border"
|
||||
style={{ backgroundColor: col }}
|
||||
onClick={() => handleColorChange(col, true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -36,7 +36,6 @@ export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
"use no memo";
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageSize: 25,
|
||||
pageIndex: 0,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import winston from "winston";
|
||||
const customLevels = {
|
||||
levels: {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
sql: 3,
|
||||
debug: 4,
|
||||
},
|
||||
colors: {
|
||||
error: "red",
|
||||
warn: "yellow",
|
||||
info: "green",
|
||||
sql: "magenta",
|
||||
debug: "blue",
|
||||
},
|
||||
};
|
||||
winston.addColors(customLevels.colors);
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
levels: customLevels.levels,
|
||||
format: winston.format.json(),
|
||||
transports: [
|
||||
new winston.transports.File({ filename: "db_queries.log", level: "sql" }),
|
||||
new winston.transports.File({ filename: "errors.log", level: "error" }),
|
||||
],
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
"use server";
|
||||
import fs from "node:fs/promises";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { auth } from "../auth";
|
||||
import { hasPermission, Role } from "@/lib/validation/permissions";
|
||||
|
||||
export async function uploadFile(formData: FormData) {
|
||||
const session = await auth();
|
||||
if (!session || hasPermission(session.user.role, Role.EDITOR)) return false;
|
||||
const file = formData.get("file") as File;
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = new Uint8Array(arrayBuffer);
|
||||
|
||||
@@ -1,29 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
import { desc, ilike, like } from "drizzle-orm";
|
||||
import {
|
||||
articles as articlesTable,
|
||||
categories as categoriesTable,
|
||||
lower,
|
||||
} from "@/server/db/schema";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
searchContent: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
query: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const articles = await ctx.db.query.articles.findMany({
|
||||
where: ilike(articlesTable.title, "%" + input.query + "%"),
|
||||
});
|
||||
const categories = await ctx.db.query.categories.findMany({
|
||||
where: like(categoriesTable.name, "%" + input.query + "%"),
|
||||
});
|
||||
return { articles, categories };
|
||||
}),
|
||||
|
||||
getSidebarMain: publicProcedure.query(async ({ ctx }) => {
|
||||
const categories = await ctx.db.query.categories.findMany({
|
||||
limit: 3,
|
||||
|
||||
@@ -71,10 +71,10 @@ export const articleRouter = createTRPCRouter({
|
||||
get: publicProcedure
|
||||
.input(z.object({ slug: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.articles.findFirst({
|
||||
return (await ctx.db.query.articles.findFirst({
|
||||
where: eq(articles.slug, input.slug),
|
||||
with: { category: true },
|
||||
});
|
||||
})) as Article;
|
||||
}),
|
||||
getByCursor: publicProcedure
|
||||
.input(
|
||||
@@ -131,7 +131,6 @@ export const articleRouter = createTRPCRouter({
|
||||
|
||||
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: ArticleCursor = {
|
||||
@@ -150,7 +149,7 @@ export const articleRouter = createTRPCRouter({
|
||||
nextCursor,
|
||||
};
|
||||
}),
|
||||
getAll: publicProcedure
|
||||
getMany: publicProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
@@ -160,11 +159,12 @@ export const articleRouter = createTRPCRouter({
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const limit = input?.limit ?? 50;
|
||||
return (await ctx.db.query.articles.findMany({
|
||||
where: input?.categoryId
|
||||
? eq(articles.categoryId, input.categoryId)
|
||||
: undefined,
|
||||
limit: input?.limit,
|
||||
limit: limit,
|
||||
columns: {
|
||||
title: true,
|
||||
slug: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
export const authorRouter = createTRPCRouter({
|
||||
getCount: publicProcedure.query(async ({ ctx }) => {
|
||||
|
||||
@@ -12,11 +12,11 @@ import {
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
gte,
|
||||
ilike,
|
||||
like,
|
||||
lte,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { hasPermission, Role } from "@/lib/validation/permissions";
|
||||
import {
|
||||
@@ -60,13 +60,6 @@ const getCategorySorting = (sort: string, cursor?: CategoryCursor) => {
|
||||
};
|
||||
|
||||
export const categoryRouter = createTRPCRouter({
|
||||
search: publicProcedure
|
||||
.input(z.object({ query: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.categories.findMany({
|
||||
where: like(categories.name, "%" + input.query + "%"),
|
||||
});
|
||||
}),
|
||||
get: publicProcedure
|
||||
.input(z.object({ slug: z.string(), with: z.any() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
@@ -76,7 +69,7 @@ export const categoryRouter = createTRPCRouter({
|
||||
})) as Category;
|
||||
}),
|
||||
|
||||
getAll: publicProcedure
|
||||
getMany: publicProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
@@ -85,9 +78,15 @@ export const categoryRouter = createTRPCRouter({
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.categories.findMany({
|
||||
limit: input?.limit,
|
||||
});
|
||||
const limit = input?.limit ?? 50;
|
||||
return (await ctx.db.query.categories.findMany({
|
||||
limit: limit,
|
||||
columns: {
|
||||
name: true,
|
||||
slug: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})) as Category[];
|
||||
}),
|
||||
|
||||
getByCursor: publicProcedure
|
||||
@@ -135,7 +134,6 @@ export const categoryRouter = createTRPCRouter({
|
||||
|
||||
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 = {
|
||||
@@ -172,6 +170,12 @@ export const categoryRouter = createTRPCRouter({
|
||||
.values({ ...input.category, slug })
|
||||
.returning({
|
||||
slug: categories.slug,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: categories.slug,
|
||||
set: {
|
||||
slug: sql`${slug} || '-' || (SELECT COUNT(*) FROM ${categories} WHERE slug LIKE ${slug + "-%"})`,
|
||||
},
|
||||
});
|
||||
}),
|
||||
update: protectedProcedure
|
||||
|
||||
@@ -13,7 +13,7 @@ export const usersRouter = createTRPCRouter({
|
||||
.update(users)
|
||||
.set(input.profile)
|
||||
.where(eq(users.id, ctx.session.user.id))
|
||||
.returning();
|
||||
.returning({ id: users.id });
|
||||
}),
|
||||
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
@@ -21,6 +21,12 @@ export const usersRouter = createTRPCRouter({
|
||||
if (!isAdmin) throw new Error("You are not allowed to get all users");
|
||||
return await ctx.db.query.users.findMany({
|
||||
orderBy: desc(users.role),
|
||||
columns: {
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ const globalForDb = globalThis as unknown as {
|
||||
const conn = globalForDb.conn ?? postgres(env.DATABASE_URL);
|
||||
if (env.NODE_ENV !== "production") globalForDb.conn = conn;
|
||||
|
||||
export const db = drizzle(conn, { schema });
|
||||
export const db = drizzle(conn, {
|
||||
schema,
|
||||
});
|
||||
|
||||
export type DBType = typeof db;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { relations, SQL, sql } from "drizzle-orm";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import {
|
||||
AnyPgColumn,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
@@ -16,10 +15,6 @@ import { User } from "next-auth";
|
||||
import { type AdapterAccount } from "next-auth/adapters";
|
||||
import { JSONContent } from "novel";
|
||||
|
||||
export function lower(value: AnyPgColumn): SQL {
|
||||
return sql`lower(${value})`;
|
||||
}
|
||||
|
||||
export const createTable = pgTableCreator((name) => `wiki-antifa_${name}`);
|
||||
|
||||
export const articles = createTable(
|
||||
@@ -43,7 +38,9 @@ export const articles = createTable(
|
||||
),
|
||||
},
|
||||
(example) => ({
|
||||
articleTitleIndex: index("article_title_idx").on(example.title),
|
||||
titleIndex: index("article_title_idx").on(example.title),
|
||||
slugIndex: index("article_slug_idx").on(example.slug),
|
||||
createdAtIndex: index("article_created_at_idx").on(example.createdAt),
|
||||
}),
|
||||
);
|
||||
export type Article = typeof articles.$inferSelect & {
|
||||
@@ -81,7 +78,9 @@ export const categories = createTable(
|
||||
),
|
||||
},
|
||||
(example) => ({
|
||||
categoryNameIndex: index("category_name_idx").on(example.name),
|
||||
nameIndex: index("category_name_idx").on(example.name),
|
||||
slugameIndex: index("category_slug_idx").on(example.slug),
|
||||
createdAtIndex: index("category_created_at_idx").on(example.createdAt),
|
||||
}),
|
||||
);
|
||||
export type Category = typeof categories.$inferSelect & {
|
||||
|
||||
Reference in New Issue
Block a user