This commit is contained in:
2025-03-11 15:51:31 +01:00
parent bccd089c7f
commit 7135e34699
34 changed files with 633 additions and 130 deletions
@@ -1,3 +1,4 @@
import { appRoutes } from "@/config";
import { Category } from "@/server/db/schema";
import Link from "next/link";
import React from "react";
@@ -8,7 +9,7 @@ function CategoryCard({
createdAt,
}: Pick<Category, "name" | "slug" | "createdAt">) {
return (
<Link href={`/kategorie/${slug}`}>
<Link href={appRoutes.category(slug)}>
<div className="rounded-md border p-4">{name}</div>
</Link>
);
@@ -3,7 +3,7 @@ import { auth } from "@/server/auth";
import { api } from "@/trpc/server";
import { notFound } from "next/navigation";
import React from "react";
import Editor from "@/components/article/editor/article-form";
import ArticleEditor from "@/components/article/article-form";
async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
@@ -13,7 +13,7 @@ async function Page({ params }: { params: Promise<{ slug: string }> }) {
: false;
const article = await api.article.get({ slug: slug });
if (!article || !isEditor) return notFound();
return <Editor server_article={article} />;
return <ArticleEditor server_article={article} />;
}
export default Page;
+27 -4
View File
@@ -1,4 +1,7 @@
import RenderArticle from "@/components/article/render-article";
import BreadNavigator from "@/components/bread-navigator";
import { Button } from "@/components/ui/button";
import { appRoutes } from "@/config";
import { hasPermission, Role } from "@/lib/validation/permissions";
import { auth } from "@/server/auth";
import { api } from "@/trpc/server";
@@ -16,13 +19,28 @@ async function Page({ params }: { params: Promise<{ slug: string }> }) {
? hasPermission(session.user.role, Role.EDITOR)
: false;
return (
<div>
<div className="space-y-2">
<div className="flex w-full items-center justify-between">
<h1 className="text-4xl font-bold">{article.title}</h1>
<div className="w-full">
<BreadNavigator
links={[
...(article?.category
? [
{
label: article.category.name!,
href: appRoutes.category(article.category.slug),
},
]
: []),
{ label: "Artikel", href: appRoutes.allArticles },
{ label: article.title!, href: appRoutes.article(article.slug) },
]}
/>
</div>
{isEditor && (
<div className="space-x-2">
<div className="flex w-full justify-end space-x-2">
<Button asChild variant={"outline"}>
<Link href={`/artikel/${article.slug}/edit`}>
<Link href={appRoutes.editArticle(article.slug)}>
<Edit className="size-4" />
<span>Bearbeiten</span>
</Link>
@@ -33,6 +51,11 @@ async function Page({ params }: { params: Promise<{ slug: string }> }) {
</div>
)}
</div>
<h1 className="text-4xl font-bold">{article.title}</h1>
{article?.content?.length ? (
<RenderArticle content={article.content} />
) : null}
</div>
);
}
+4 -3
View File
@@ -6,7 +6,8 @@ import { api } from "@/trpc/server";
import { Edit } from "lucide-react";
import { notFound } from "next/navigation";
import React from "react";
import ArticleCard from "../../_components/article/article-card";
import ArticleCard from "../../../../components/article/article-card";
import { appRoutes } from "@/config";
async function Page({ params }: { params: Promise<{ name: string }> }) {
const { name } = await params;
@@ -22,10 +23,10 @@ async function Page({ params }: { params: Promise<{ name: string }> }) {
<div className="flex items-center justify-between">
<BreadNavigator
links={[
{ label: "Kategorie", href: "/kategorie" },
{ label: "Kategorie", href: appRoutes.allCategories },
{
label: name,
href: `/kategorie/${name}`,
href: appRoutes.category(name),
},
]}
/>
+1 -1
View File
@@ -2,7 +2,7 @@ import { auth } from "@/server/auth";
import { api } from "@/trpc/server";
import Link from "next/link";
import GlobalStats from "./_components/global-stats";
import ArticleCard from "./_components/article/article-card";
import ArticleCard from "../../components/article/article-card";
import CategoryCard from "./_components/category/category-card";
export default async function Home() {
+1 -1
View File
@@ -14,7 +14,7 @@ import Link from "next/link";
import SidebarLink from "@/components/layout/sidebar-link";
import { Button } from "@/components/ui/button";
import { LogOutIcon } from "lucide-react";
import { appConfig } from "@/app.config";
import { appConfig } from "@/config/app.config";
const data = {
navMain: [
@@ -1,3 +1,4 @@
import { appRoutes } from "@/config";
import { Article } from "@/server/db/schema";
import Link from "next/link";
import React from "react";
@@ -8,7 +9,7 @@ function ArticleCard({
createdAt,
}: Pick<Article, "title" | "slug" | "createdAt">) {
return (
<Link href={`/artikel/${slug}`}>
<Link href={appRoutes.article(slug)}>
<div className="rounded-md border p-4">{title}</div>
</Link>
);
@@ -19,41 +19,32 @@ import {
import { articleSchema } from "@/lib/validation/zod/article";
import { cn, debounce } from "@/lib/utils";
import { updateArticle } from "@/server/actions/article";
import Editor from "./editor";
import Editor from "../text-editor";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import CategorySelect from "@/components/category/category-select";
import {
Check,
CheckCircle,
MessageCircleWarning,
XCircle,
} from "lucide-react";
import { Switch } from "@/components/ui/switch";
import PublishArticleAlertDialog from "../publish-article-alert-dialog";
import { CheckCircle, XCircle } from "lucide-react";
import PublishArticleAlertDialog from "./publish-article-alert-dialog";
import { Label } from "@/components/ui/label";
export default ({ server_article }: { server_article: Article }) => {
const [selectedCategory, setSelectedCategory] =
React.useState<Category | null>(null);
const [loading, setLoading] = React.useState(false);
const form = useForm<z.infer<typeof articleSchema>>({
resolver: zodResolver(articleSchema),
defaultValues: {
title: server_article?.title ?? "",
slug: server_article?.slug,
published: server_article?.published ?? false,
categoryId: server_article?.categoryId ?? "",
authorId: server_article?.authorId ?? "",
content:
server_article?.content ??
`<h2>
Hey bearbeite mich!
</h2>`,
content: server_article?.content,
},
});
// 2. Define a submit handler.
async function onSubmit(values: z.infer<typeof articleSchema>) {
console.log("Content before save", values.content);
setLoading(true);
await updateArticle(values, server_article.id);
setLoading(false);
@@ -100,11 +91,17 @@ export default ({ server_article }: { server_article: Article }) => {
<FormItem className="w-full">
<FormControl>
<Editor
// content={field.value}
editorProviderProps={{
editorProps: { attributes: { class: "min-h-64" } },
content: field.value,
editorProps: { attributes: { class: "min-h-64" } },
onUpdate: (value) => {
field.onChange(value.editor.getHTML());
const newContent = value.editor.getHTML();
console.log(
"Content :: form",
JSON.stringify(newContent),
);
field.onChange(newContent);
debouncedSubmit();
},
}}
@@ -23,24 +23,20 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { articleSchema } from "@/lib/validation/zod/article";
import { createArticleSchema } from "@/lib/validation/zod/article";
import { createArticle } from "@/server/actions/article";
const formSchema = articleSchema.pick({
title: true,
});
function CreateArticleDialog() {
const [open, setOpen] = React.useState<boolean>(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
const form = useForm<z.infer<typeof createArticleSchema>>({
resolver: zodResolver(createArticleSchema),
defaultValues: {
title: "",
},
});
// 2. Define a submit handler.
async function onSubmit(values: z.infer<typeof formSchema>) {
async function onSubmit(values: z.infer<typeof createArticleSchema>) {
setOpen(false);
await createArticle(values);
}
@@ -0,0 +1,8 @@
import React from "react";
import Editor from "../text-editor";
function RenderArticle({ content }: { content: string }) {
return <Editor readOnly editorProviderProps={{ content: content }} />;
}
export default RenderArticle;
+5 -3
View File
@@ -13,6 +13,8 @@ function BreadNavigator({
}: {
links: { label: string; href: string }[];
}) {
const labelClass =
"w-full max-w-52 overflow-hidden text-ellipsis whitespace-nowrap capitalize block";
return (
<Breadcrumb>
<BreadcrumbList>
@@ -20,8 +22,8 @@ function BreadNavigator({
if (idx < links.length - 1)
return (
<React.Fragment key={href}>
<BreadcrumbItem>
<BreadcrumbLink className="capitalize" href={href}>
<BreadcrumbItem className="">
<BreadcrumbLink className={labelClass} href={href}>
{label}
</BreadcrumbLink>
</BreadcrumbItem>
@@ -29,7 +31,7 @@ function BreadNavigator({
</React.Fragment>
);
return (
<BreadcrumbItem className="capitalize" key={href}>
<BreadcrumbItem className={labelClass} key={href}>
<BreadcrumbPage>{label}</BreadcrumbPage>
</BreadcrumbItem>
);
+48 -40
View File
@@ -5,23 +5,31 @@ import {
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
import Link from "next/link";
import { Separator } from "../ui/separator";
import SidebarLink from "./sidebar-link";
import { Button } from "../ui/button";
import { HomeIcon } from "lucide-react";
import { Minus, Plus } from "lucide-react";
import { api } from "@/trpc/server";
import { appConfig } from "@/app.config";
import { appConfig } from "@/config/app.config";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "../ui/collapsible";
import { appRoutes } from "@/config";
import SidebarLink from "./sidebar-link";
export async function AppSidebar({
...props
}: React.ComponentProps<typeof Sidebar>) {
const articles = await api.article.getAllPreviews();
const sidebarContent = await api.app.getSidebarContent();
return (
<Sidebar {...props}>
<SidebarHeader className="flex h-14 items-center justify-center border-b">
@@ -31,39 +39,39 @@ export async function AppSidebar({
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<Button asChild>
<Link href={"/"}>
<HomeIcon className="size-4" />
<span>Start</span>
</Link>
</Button>
</SidebarGroup>
{/* We create a SidebarGroup for each parent. */}
{/* {data.navMain.map((item) => (
<SidebarGroup key={item.title}>
<SidebarGroupLabel>{item.title}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{item.items.map((item) => (
<SidebarLink key={item.title} {...item} />
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))} */}
<SidebarGroup>
<SidebarGroupLabel>Artikel</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{articles?.map((article) => (
<SidebarLink
key={article.slug}
title={article.title!}
url={`/artikel/${article.slug}`}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
<SidebarMenu>
{sidebarContent?.map((category, index) => (
<Collapsible
key={category.id}
defaultOpen={index === 1}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton>
{category.name}{" "}
<Plus className="ml-auto group-data-[state=open]/collapsible:hidden" />
<Minus className="ml-auto group-data-[state=closed]/collapsible:hidden" />
</SidebarMenuButton>
</CollapsibleTrigger>
{category?.articles?.length ? (
<CollapsibleContent>
<SidebarMenuSub>
{category.articles.map((article) => (
<SidebarMenuSubItem key={article.slug}>
<SidebarLink
title={article.title!}
url={appRoutes.article(article.slug)}
/>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
) : null}
</SidebarMenuItem>
</Collapsible>
))}
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
<SidebarFooter>
+3 -2
View File
@@ -10,8 +10,9 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import CreateArticleDialog from "@/app/(PAGES)/_components/article/create-article-dialog";
import CreateArticleDialog from "@/components/article/create-article-dialog";
import CreateCategoryDialog from "@/app/(PAGES)/_components/category/create-category-dialog";
import { appRoutes } from "@/config";
async function Navbar() {
const session = await auth();
@@ -44,7 +45,7 @@ async function Navbar() {
)}
{isAdmin && (
<Button asChild>
<Link href={"/admin"}>Admin Dashboard</Link>
<Link href={appRoutes.admin.base}>Admin Dashboard</Link>
</Button>
)}
{session ? (
+4 -6
View File
@@ -2,17 +2,15 @@
import React from "react";
import { usePathname } from "next/navigation";
import { SidebarMenuButton, SidebarMenuItem } from "@/components/ui/sidebar";
import { SidebarMenuSubButton } from "@/components/ui/sidebar";
import Link from "next/link";
function SidebarLink({ url, title }: { url: string; title: string }) {
const isActive = usePathname() === url;
return (
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={isActive}>
<Link href={url}>{title}</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuSubButton asChild isActive={isActive}>
<Link href={url}>{title}</Link>
</SidebarMenuSubButton>
);
}
@@ -0,0 +1,92 @@
import React from "react";
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import { useDrag, useDrop } from "react-dnd";
const DRAG_TYPE = "TIPTAP_BLOCK";
export const DraggableBlockView = (props) => {
const { node, getPos, editor } = props;
const [showDragHandle, setShowDragHandle] = useState(false);
// Get node position and ID
const nodePos = getPos();
const nodeId = `${node.type.name}-${nodePos}`;
// Set up drag
const [{ isDragging }, drag, dragPreview] = useDrag({
type: DRAG_TYPE,
item: { id: nodeId, pos: nodePos, type: node.type.name },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
begin: () => {
// Select the block on drag start
editor.commands.setNodeSelection(nodePos);
return { id: nodeId, pos: nodePos, type: node.type.name };
},
});
// Set up drop
const [{ isOver, canDrop }, drop] = useDrop({
accept: DRAG_TYPE,
drop: (item) => {
if (item.pos !== nodePos) {
// Move the dragged node to this position
const tr = editor.state.tr;
const sourcePos = item.pos;
const targetPos = nodePos;
// Logic to move nodes in the document
// This is the complex part that requires careful handling of ProseMirror positions
const sourceNode = tr.doc.nodeAt(sourcePos);
if (sourceNode) {
tr.delete(sourcePos, sourcePos + sourceNode.nodeSize);
const newTargetPos =
targetPos > sourcePos ? targetPos - sourceNode.nodeSize : targetPos;
tr.insert(newTargetPos, sourceNode);
editor.view.dispatch(tr);
}
}
},
collect: (monitor) => ({
isOver: monitor.isOver(),
canDrop: monitor.canDrop(),
}),
});
// Reference for the drag preview (the whole block)
const dragRef = useRef(null);
return (
<NodeViewWrapper
ref={drop}
data-drag-handle={nodeId}
style={{
position: "relative",
opacity: isDragging ? 0.5 : 1,
backgroundColor: isOver ? "#f0f9ff" : "transparent",
}}
onMouseEnter={() => setShowDragHandle(true)}
onMouseLeave={() => setShowDragHandle(false)}
>
{showDragHandle && (
<div
ref={drag}
className="drag-handle"
style={{
position: "absolute",
left: "-24px",
top: "8px",
cursor: "grab",
color: "#aaa",
}}
>
⋮⋮
</div>
)}
<div ref={dragPreview}>
<NodeViewContent />
</div>
</NodeViewWrapper>
);
};
@@ -3,6 +3,23 @@ import ListItem from "@tiptap/extension-list-item";
import TextStyle from "@tiptap/extension-text-style";
import StarterKit from "@tiptap/starter-kit";
import Image from "@tiptap/extension-image";
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
import { Extension } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { DraggableBlockView } from "./block-drag";
const DraggableBlocks = Extension.create({
name: "draggableBlocks",
addProseMirrorPlugins() {
return [];
},
// Apply this nodeview to all block nodes
addNodeView() {
return ReactNodeViewRenderer(DraggableBlockView);
},
});
export const extensions = [
Color.configure({ types: [TextStyle.name, ListItem.name] }),
@@ -11,7 +28,9 @@ export const extensions = [
StarterKit.configure({
code: false,
codeBlock: false,
horizontalRule: {},
heading: {
levels: [1, 2, 3, 4, 5, 6],
},
bulletList: {
keepMarks: true,
keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help
@@ -21,5 +40,16 @@ export const extensions = [
keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help
},
}),
GlobalDragHandle,
Image,
DraggableBlocks.configure({
types: [
"paragraph",
"heading",
"blockquote",
"codeBlock",
"bulletList",
"orderedList",
],
}),
];
@@ -5,6 +5,7 @@ import React from "react";
import { EditorProvider, EditorProviderProps } from "@tiptap/react";
import { MenuBar } from "./menu-bar";
import { extensions } from "./extentions";
import { cn } from "@/lib/utils";
function Editor({
editorProviderProps,
@@ -14,15 +15,22 @@ function Editor({
readOnly?: boolean;
}) {
return (
<div className="rounded-md bg-gradient-to-b from-muted to-background p-2">
<div
className={cn(
"rounded-md p-2",
!readOnly && "bg-gradient-to-b from-muted to-background",
)}
>
<EditorProvider
immediatelyRender={false}
extensions={extensions}
slotBefore={!readOnly && <MenuBar />}
{...editorProviderProps}
editable={!readOnly}
/>
{JSON.stringify(editorProviderProps.content)}
</div>
);
}
export default Editor;
@@ -0,0 +1,34 @@
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import { useDraggable } from "@dnd-kit/core";
const DraggableBlock = ({ node }: any) => {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: node.attrs.id,
});
return (
<NodeViewWrapper
ref={setNodeRef}
style={{
transform: transform
? `translate(${transform.x}px, ${transform.y}px)`
: "none",
}}
className="relative cursor-grab border bg-white p-2 shadow-md"
>
{/* Drag Handle */}
<div
{...listeners}
{...attributes}
className="absolute left-0 top-0 cursor-grab bg-gray-300 p-1"
>
⠿
</div>
{/* Content */}
<NodeViewContent as="div" />
</NodeViewWrapper>
);
};
export default DraggableBlock;
@@ -112,3 +112,24 @@ ol {
.menu-bar .is-active {
@apply border-primary;
}
.drag-handle {
@apply bg-red-500;
}
.block-drag-handle {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #aaa;
background: white;
border-radius: 4px;
z-index: 10;
}
.block-drag-handle:hover {
color: #333;
background: #f1f1f1;
}
+11
View File
@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+43
View File
@@ -0,0 +1,43 @@
export type RouteWithParam = (param: string) => string;
export type Route = string | RouteWithParam;
export type AppRoutes = {
// Home and admin
home: string;
admin: {
base: string;
};
// Article routes
allArticles: string;
article: RouteWithParam;
editArticle: RouteWithParam;
// Category routes
allCategories: string;
category: RouteWithParam;
editCategory: RouteWithParam;
// Auth routes
signin: string;
signout: string;
};
export const appRoutes: AppRoutes = {
home: "/",
admin: { base: "/admin" },
// article
allArticles: "/artikel",
article: (slug) => `/artikel/${slug}`,
editArticle: (slug) => `/artikel/${slug}/bearbeiten`,
// category
allCategories: "/kategorie",
category: (slug) => `/kategorie/${slug}`,
editCategory: (slug) => `/kategorie/${slug}/bearbeiten`,
// auth
signin: "/api/auth/signin",
signout: "/api/auth/signout",
};
+2
View File
@@ -0,0 +1,2 @@
export { appConfig } from "./app.config";
export { appRoutes } from "./app.routes";
+7 -16
View File
@@ -6,7 +6,13 @@ export function cn(...inputs: ClassValue[]) {
}
export function generateSlug(title: string) {
return title.toLowerCase().replace(/\s+/g, "-");
return title
.toLowerCase() // Convert to lowercase
.trim() // Remove leading/trailing whitespace
.replace(/[^\w\s-]/g, "") // Remove special characters
.replace(/\s+/g, "-") // Replace spaces with hyphens
.replace(/-+/g, "-") // Remove consecutive hyphens
.replace(/^-+|-+$/g, ""); // Remove leading/trailing hyphens
}
export function debounce<T extends (...args: any[]) => void>(
@@ -20,18 +26,3 @@ export function debounce<T extends (...args: any[]) => void>(
timeoutId = setTimeout(() => func(...args), delay);
};
}
export function RGBAToHexA(rgba: string, forceRemoveAlpha = false) {
return (
"#" +
rgba
.replace(/^rgba?\(|\s+|\)$/g, "") // Get's rgba / rgb string values
.split(",") // splits them at ","
.filter((string, index) => !forceRemoveAlpha || index !== 3)
.map((string) => parseFloat(string)) // Converts them to numbers
.map((number, index) => (index === 3 ? Math.round(number * 255) : number)) // Converts alpha to 255 number
.map((number) => number.toString(16)) // Converts numbers to hex
.map((string) => (string.length === 1 ? "0" + string : string)) // Adds 0 when length of one number is 1
.join("")
); // Puts the array to togehter to a string
}
+6 -1
View File
@@ -2,8 +2,13 @@ import { z } from "zod";
export const articleSchema = z.object({
title: z.string().min(1),
content: z.string().optional(),
slug: z.string().min(1),
content: z.any().optional(),
authorId: z.string().optional(),
categoryId: z.string().optional(),
published: z.boolean(),
});
export const createArticleSchema = articleSchema.pick({
title: true,
});
+11 -4
View File
@@ -1,22 +1,29 @@
"use server";
import { articleSchema } from "@/lib/validation/zod/article";
import { appRoutes } from "@/config";
import {
articleSchema,
createArticleSchema,
} from "@/lib/validation/zod/article";
import { api } from "@/trpc/server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
export async function createArticle(article: z.infer<typeof articleSchema>) {
export async function createArticle(
article: z.infer<typeof createArticleSchema>,
) {
const result = await api.article.create({
article,
});
if (!result[0]?.slug?.length) return false;
return redirect(`/artikel/${result[0].slug}/edit`);
return redirect(appRoutes.editArticle(result[0].slug));
}
export async function updateArticle(
article: z.infer<typeof articleSchema>,
articleId: string,
) {
console.log("Content :: action", JSON.stringify(article.content));
const result = await api.article.update({
article,
articleId,
+2 -1
View File
@@ -3,7 +3,7 @@ import { categoryRouter } from "@/server/api/routers/category";
import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
import { usersRouter } from "./routers/users";
import { authorRouter } from "./routers/author";
import { appRouter as globalRouter } from "./routers/app";
/**
* This is the primary router for your server.
*
@@ -14,6 +14,7 @@ export const appRouter = createTRPCRouter({
category: categoryRouter,
users: usersRouter,
author: authorRouter,
app: globalRouter,
});
// export type definition of API
+16
View File
@@ -0,0 +1,16 @@
import { createTRPCRouter, publicProcedure } from "../trpc";
export const appRouter = createTRPCRouter({
getSidebarContent: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.query.categories.findMany({
with: {
articles: {
columns: {
title: true,
slug: true,
},
},
},
});
}),
});
+11 -2
View File
@@ -6,7 +6,10 @@ import {
publicProcedure,
} from "@/server/api/trpc";
import { articles } from "@/server/db/schema";
import { articleSchema } from "@/lib/validation/zod/article";
import {
articleSchema,
createArticleSchema,
} from "@/lib/validation/zod/article";
import { count, eq } from "drizzle-orm";
import { hasPermission, Role } from "@/lib/validation/permissions";
import { generateSlug } from "@/lib/utils";
@@ -18,6 +21,7 @@ export const articleRouter = createTRPCRouter({
.query(async ({ ctx, input }) => {
return await ctx.db.query.articles.findFirst({
where: eq(articles.slug, input.slug),
with: { category: true },
});
}),
@@ -60,7 +64,7 @@ export const articleRouter = createTRPCRouter({
}),
// mutations
create: protectedProcedure
.input(z.object({ article: articleSchema }))
.input(z.object({ article: createArticleSchema }))
.mutation(async ({ ctx, input }) => {
const isEditor = hasPermission(ctx.session.user.role, Role.EDITOR);
if (!isEditor) {
@@ -81,6 +85,11 @@ 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)
.set(input.article)
+7 -5
View File
@@ -4,6 +4,7 @@ import {
boolean,
index,
integer,
jsonb,
pgTableCreator,
primaryKey,
text,
@@ -11,6 +12,8 @@ import {
varchar,
} from "drizzle-orm/pg-core";
import { type AdapterAccount } from "next-auth/adapters";
import { type JSONContent } from "@tiptap/react";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
@@ -27,9 +30,9 @@ export const articles = createTable(
.$defaultFn(() => createId())
.notNull(),
title: varchar("title", { length: 256 }),
slug: varchar("slug", { length: 256 }).unique(),
slug: varchar("slug", { length: 256 }).unique().notNull(),
authorId: varchar("author_id", { length: 255 }),
content: text("content"),
content: text("content"),
categoryId: varchar("category_id", { length: 255 }),
published: boolean("published").default(false),
createdAt: timestamp("created_at", { withTimezone: true })
@@ -44,9 +47,8 @@ export const articles = createTable(
}),
);
export type Article = typeof articles.$inferSelect;
export const articleRelations = relations(articles, ({ one }) => ({
categories: one(categories, {
category: one(categories, {
fields: [articles.categoryId],
references: [categories.id],
}),
@@ -60,7 +62,7 @@ export const categories = createTable(
.$defaultFn(() => createId())
.notNull(),
name: varchar("name", { length: 256 }),
slug: varchar("slug", { length: 256 }).unique(),
slug: varchar("slug", { length: 256 }).unique().notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)