added seed data; image uploads via server actions; infinite article grid

This commit is contained in:
mr-shortman 2025-03-14 20:48:17 +01:00
parent 7135e34699
commit 37233db0ec
73 changed files with 4578 additions and 1137 deletions

View File

@ -7,7 +7,9 @@ import "./src/env.js";
/** @type {import("next").NextConfig} */
const config = {
experimental: {
reactCompiler: true,
serverActions: {
bodySizeLimit: "2mb",
},
},
};

View File

@ -21,13 +21,10 @@
},
"dependencies": {
"@auth/drizzle-adapter": "^1.7.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^4.1.3",
"@paralleldrive/cuid2": "^2.2.2",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-aspect-ratio": "^1.1.2",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-collapsible": "^1.1.3",
@ -43,17 +40,13 @@
"@tanstack/react-query": "^5.50.0",
"@tanstack/react-table": "^8.21.2",
"@tiptap/core": "^2.11.5",
"@tiptap/extension-color": "^2.11.5",
"@tiptap/extension-image": "^2.11.5",
"@tiptap/extension-list-item": "^2.11.5",
"@tiptap/extension-text-style": "^2.11.5",
"@tiptap/pm": "^2.11.5",
"@tiptap/extension-heading": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@trpc/client": "^11.0.0-rc.446",
"@trpc/react-query": "^11.0.0-rc.446",
"@trpc/server": "^11.0.0-rc.446",
"babel-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
"cheerio": "^1.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "1.0.0",
@ -63,11 +56,9 @@
"next": "^15.0.1",
"next-auth": "5.0.0-beta.25",
"next-themes": "^0.4.4",
"novel": "^1.0.2",
"postgres": "^3.4.4",
"prosemirror-state": "^1.4.3",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-dnd": "^16.0.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.2",
"react-textarea-autosize": "^8.5.7",
@ -76,7 +67,7 @@
"superjson": "^2.2.1",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"tiptap-extension-global-drag-handle": "^0.1.18",
"use-debounce": "^10.0.4",
"zod": "^3.24.2"
},
"devDependencies": {

1484
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -0,0 +1,302 @@
[
{
"title": "Account Executive"
},
{
"title": "Engineer II"
},
{
"title": "Data Coordinator"
},
{
"title": "Senior Editor"
},
{
"title": "Senior Quality Engineer"
},
{
"title": "Statistician III"
},
{
"title": "Programmer I"
},
{
"title": "Office Assistant II"
},
{
"title": "VP Marketing"
},
{
"title": "Senior Quality Engineer"
},
{
"title": "Business Systems Development Analyst"
},
{
"title": "Chemical Engineer"
},
{
"title": "Director of Sales"
},
{
"title": "Chief Design Engineer"
},
{
"title": "Editor"
},
{
"title": "Speech Pathologist"
},
{
"title": "Pharmacist"
},
{
"title": "Operator"
},
{
"title": "Human Resources Assistant III"
},
{
"title": "Computer Systems Analyst II"
},
{
"title": "Sales Associate"
},
{
"title": "Desktop Support Technician"
},
{
"title": "Executive Secretary"
},
{
"title": "Quality Control Specialist"
},
{
"title": "Research Associate"
},
{
"title": "Software Consultant"
},
{
"title": "Staff Scientist"
},
{
"title": "Senior Sales Associate"
},
{
"title": "Business Systems Development Analyst"
},
{
"title": "VP Sales"
},
{
"title": "Mechanical Systems Engineer"
},
{
"title": "Information Systems Manager"
},
{
"title": "Internal Auditor"
},
{
"title": "Product Engineer"
},
{
"title": "Legal Assistant"
},
{
"title": "GIS Technical Architect"
},
{
"title": "Software Consultant"
},
{
"title": "Paralegal"
},
{
"title": "Nurse"
},
{
"title": "Biostatistician II"
},
{
"title": "Web Designer I"
},
{
"title": "Financial Analyst"
},
{
"title": "Administrative Officer"
},
{
"title": "VP Accounting"
},
{
"title": "Biostatistician IV"
},
{
"title": "Data Coordinator"
},
{
"title": "Occupational Therapist"
},
{
"title": "Web Developer IV"
},
{
"title": "Quality Control Specialist"
},
{
"title": "General Manager"
},
{
"title": "Assistant Manager"
},
{
"title": "Sales Associate"
},
{
"title": "VP Marketing"
},
{
"title": "Graphic Designer"
},
{
"title": "Operator"
},
{
"title": "Senior Financial Analyst"
},
{
"title": "Information Systems Manager"
},
{
"title": "Tax Accountant"
},
{
"title": "Research Assistant II"
},
{
"title": "Quality Engineer"
},
{
"title": "Staff Scientist"
},
{
"title": "Account Representative I"
},
{
"title": "Clinical Specialist"
},
{
"title": "Web Developer II"
},
{
"title": "Desktop Support Technician"
},
{
"title": "Electrical Engineer"
},
{
"title": "Registered Nurse"
},
{
"title": "Paralegal"
},
{
"title": "Financial Advisor"
},
{
"title": "Senior Cost Accountant"
},
{
"title": "Senior Financial Analyst"
},
{
"title": "Safety Technician III"
},
{
"title": "Recruiting Manager"
},
{
"title": "Engineer III"
},
{
"title": "Social Worker"
},
{
"title": "Assistant Manager"
},
{
"title": "Financial Analyst"
},
{
"title": "Health Coach II"
},
{
"title": "Database Administrator III"
},
{
"title": "Senior Editor"
},
{
"title": "Research Nurse"
},
{
"title": "Graphic Designer"
},
{
"title": "Quality Engineer"
},
{
"title": "Media Manager II"
},
{
"title": "Payment Adjustment Coordinator"
},
{
"title": "Desktop Support Technician"
},
{
"title": "Legal Assistant"
},
{
"title": "Research Associate"
},
{
"title": "Operator"
},
{
"title": "Speech Pathologist"
},
{
"title": "Senior Editor"
},
{
"title": "Financial Analyst"
},
{
"title": "Professor"
},
{
"title": "Registered Nurse"
},
{
"title": "Electrical Engineer"
},
{
"title": "Actuary"
},
{
"title": "Nuclear Power Engineer"
},
{
"title": "Social Worker"
},
{
"title": "Safety Technician IV"
},
{
"title": "Web Developer I"
}
]

View File

@ -0,0 +1,302 @@
[
{
"name": "Green Sotol"
},
{
"name": "Mexican Prairie Clover"
},
{
"name": "Stipulate Leaf-flower"
},
{
"name": "Kawelu"
},
{
"name": "Spiked Crested Coralroot"
},
{
"name": "Texas Windmill Grass"
},
{
"name": "Mountain Alder"
},
{
"name": "Macdougal Verbena"
},
{
"name": "European Aspen"
},
{
"name": "Torrey's Willowherb"
},
{
"name": "Ailanthus"
},
{
"name": "Shortstalk Stinkweed"
},
{
"name": "Black Damar"
},
{
"name": "Canelillo"
},
{
"name": "Veatch's Island Broom"
},
{
"name": "Lewton's Milkwort"
},
{
"name": "Hungarian Milkvetch"
},
{
"name": "Palmer Evening Primrose"
},
{
"name": "Smooth Chastetree"
},
{
"name": "Jaeger's Joshua Tree"
},
{
"name": "Roughhairy Maiden Fern"
},
{
"name": "Florida Orchid"
},
{
"name": "Belonia Lichen"
},
{
"name": "Bigfruit Evening Primrose"
},
{
"name": "Fitch's Tarweed"
},
{
"name": "Coastal Plain Dawnflower"
},
{
"name": "Clokey's Gilia"
},
{
"name": "Indian Jointvetch"
},
{
"name": "Wreath Lichen"
},
{
"name": "Cumberland Xanthoparmelia Lichen"
},
{
"name": "Spectacular Flatsedge"
},
{
"name": "Pride Of California"
},
{
"name": "Feverfew"
},
{
"name": "Comb Wash Buckwheat"
},
{
"name": "Sweet Woodreed"
},
{
"name": "Delicate Violet Orchid"
},
{
"name": "Canadian Blacksnakeroot"
},
{
"name": "Wax Currant"
},
{
"name": "Western Mountain Ash"
},
{
"name": "Rhodomyrtus"
},
{
"name": "Johnston's Knotweed"
},
{
"name": "Kauai Bur Cucumber"
},
{
"name": "Cain's Reedgrass"
},
{
"name": "San Diego Pitchersage"
},
{
"name": "Rock Goldenrod"
},
{
"name": "Itchgrass"
},
{
"name": "Threadleaf Horsebrush"
},
{
"name": "Red Hills Vervain"
},
{
"name": "Louisiana Bluestar"
},
{
"name": "Utah Sweetvetch"
},
{
"name": "Kauila"
},
{
"name": "Sea Hibiscus"
},
{
"name": "Derris"
},
{
"name": "Florida Tasselflower"
},
{
"name": "Glossy Hawthorn"
},
{
"name": "Ahlner's Microcalicium Lichen"
},
{
"name": "Aster"
},
{
"name": "Elegant Hawthorn"
},
{
"name": "Pricklypear"
},
{
"name": "Parry's Sage"
},
{
"name": "Redberry Buckthorn"
},
{
"name": "Baden's Bluegrass"
},
{
"name": "Utah Columbine"
},
{
"name": "Obscure Shield Lichen"
},
{
"name": "Showy Orchid"
},
{
"name": "Silverleafed Princess Flower"
},
{
"name": "Oahu Stenogyne"
},
{
"name": "Hammond's Claytonia"
},
{
"name": "Owyhee River Stickseed"
},
{
"name": "Southwestern Cosmos"
},
{
"name": "Toothed Flatsedge"
},
{
"name": "Vegetable Fern"
},
{
"name": "Rose"
},
{
"name": "Desert Wishbone-bush"
},
{
"name": "Rocky Mountain Woodsia"
},
{
"name": "East Indian Lemongrass"
},
{
"name": "Coville's Erigeron"
},
{
"name": "Spiral Flag"
},
{
"name": "Nevada Milkvetch"
},
{
"name": "Douglas's Catchfly"
},
{
"name": "Silverleaf Phacelia"
},
{
"name": "Canadian Ricegrass"
},
{
"name": "Barrier Range Wattle"
},
{
"name": "Brooks' Alsophila"
},
{
"name": "Calder's Bladderpod"
},
{
"name": "Desert Brickellbush"
},
{
"name": "Echeveria"
},
{
"name": "Caruzo"
},
{
"name": "American Black Nightshade"
},
{
"name": "Whiteflower Goldenbush"
},
{
"name": "Littleleaf Milkwort"
},
{
"name": "Fir Mistletoe"
},
{
"name": "Disc Lichen"
},
{
"name": "Flagstaff Rockcress"
},
{
"name": "Golden Spiderflower"
},
{
"name": "Yellow Fumewort"
},
{
"name": "Dot Lichen"
},
{
"name": "Ross' Avens"
},
{
"name": "Sierra Bluecup"
},
{
"name": "Sausage Tree"
}
]

View File

@ -413,90 +413,5 @@
"name": "Marie-josée",
"email": "salford2a@hubpages.com",
"image": "https://robohash.org/nihilestillo.png?size=50x50&set=set1"
},
{
"name": "Maëlle",
"email": "lbaggs2b@deviantart.com",
"image": "https://robohash.org/blanditiisetvoluptate.png?size=50x50&set=set1"
},
{
"name": "Publicité",
"email": "vpurdy2c@bravesites.com",
"image": "https://robohash.org/eumenimipsa.png?size=50x50&set=set1"
},
{
"name": "Lén",
"email": "cstraun2d@youtube.com",
"image": "https://robohash.org/temporaquisofficia.png?size=50x50&set=set1"
},
{
"name": "Maëline",
"email": "rcorney2e@blog.com",
"image": "https://robohash.org/nisimodimagnam.png?size=50x50&set=set1"
},
{
"name": "Lucrèce",
"email": "florey2f@weather.com",
"image": "https://robohash.org/nequepariaturconsequatur.png?size=50x50&set=set1"
},
{
"name": "Dorothée",
"email": "briddles2g@ucoz.ru",
"image": "https://robohash.org/voluptaserroriure.png?size=50x50&set=set1"
},
{
"name": "Irène",
"email": "kscotsbrook2h@salon.com",
"image": "https://robohash.org/ullamutearum.png?size=50x50&set=set1"
},
{
"name": "Håkan",
"email": "sshipp2i@cnbc.com",
"image": "https://robohash.org/etautillum.png?size=50x50&set=set1"
},
{
"name": "Mylène",
"email": "bbanishevitz2j@biglobe.ne.jp",
"image": "https://robohash.org/consectetureosullam.png?size=50x50&set=set1"
},
{
"name": "Styrbjörn",
"email": "mjeffries2k@wsj.com",
"image": "https://robohash.org/expeditaautemvoluptatum.png?size=50x50&set=set1"
},
{
"name": "Sòng",
"email": "mpanks2l@tripadvisor.com",
"image": "https://robohash.org/eiussintnihil.png?size=50x50&set=set1"
},
{
"name": "Faîtes",
"email": "nstewartson2m@themeforest.net",
"image": "https://robohash.org/errornumquamanimi.png?size=50x50&set=set1"
},
{
"name": "Clémence",
"email": "bslany2n@naver.com",
"image": "https://robohash.org/laboriosamconsequaturdolore.png?size=50x50&set=set1"
},
{
"name": "Maëlys",
"email": "dshubotham2o@who.int",
"image": "https://robohash.org/nobisnecessitatibusipsa.png?size=50x50&set=set1"
},
{
"name": "Inès",
"email": "akeiling2p@ycombinator.com",
"image": "https://robohash.org/etvoluptatedeserunt.png?size=50x50&set=set1"
},
{
"name": "Maëlla",
"email": "jfolonin2q@livejournal.com",
"image": "https://robohash.org/voluptatibusatqueut.png?size=50x50&set=set1"
},
{
"name": "Clémentine",
"email": "cstammirs2r@a8.net",
"image": "https://robohash.org/nihilquaesint.png?size=50x50&set=set1"
}
]

View File

@ -1,10 +1,22 @@
import "dotenv/config";
import { db } from "../src/server/db";
import { users } from "../src/server/db/schema";
import fakeUsers from "./fake-users.json";
import { db, DBType } from "../src/server/db";
import { articles, categories, users } from "../src/server/db/schema";
import fakeArticles from "./fake-articles.json";
import { generateSlug } from "@/lib/utils";
import { createId } from "@paralleldrive/cuid2";
import { eq, sql } from "drizzle-orm";
async function seed() {
await db.insert(users).values(fakeUsers);
// await db.insert(users).values(fakeUsers);
// await db
// .insert(categories)
// .values(
// fakeCategories.map(({ name }) => ({ name, slug: generateSlug(name) })),
// );
await db
.insert(articles)
.values(fakeArticles.map(({ title }) => ({ title, slug: createId() })));
}
seed()

View File

@ -1,18 +0,0 @@
import { appRoutes } from "@/config";
import { Category } from "@/server/db/schema";
import Link from "next/link";
import React from "react";
function CategoryCard({
name,
slug,
createdAt,
}: Pick<Category, "name" | "slug" | "createdAt">) {
return (
<Link href={appRoutes.category(slug)}>
<div className="rounded-md border p-4">{name}</div>
</Link>
);
}
export default CategoryCard;

View File

@ -0,0 +1,79 @@
"use client";
import React from "react";
import CategoriesGrid, {
CategoriesGridSkeleton,
} from "@/components/category/categories-grid";
import ArticleGrid, {
ArticleGridSkeleton,
} from "@/components/article/grid/article-grid";
import { Article, Category } from "@/server/db/schema";
import ArrowLink from "@/components/arrow-link";
import { appRoutes } from "@/config";
import GlobalSearchWidget from "@/components/global-search-widget";
import { api } from "@/trpc/react";
import { useInfiniteQuery } from "@tanstack/react-query";
const ITEMS_PER_PAGE = 10;
function MainPage({
initialData,
}: {
initialData: { categories: Array<Category>; articles: Array<Article> };
}) {
// const [query, setQuery] = React.useState<string>("");
// // const {} = useInfiniteQuery();
// const { data: searchResults, isLoading } = api.app.searchContent.useQuery(
// {
// query,
// },
// {
// enabled: !!query,
// },
// );
// const data = query?.length ? searchResults : initialData;
return (
<>
main page xD
{/* <GlobalSearchWidget
onDebouncedSearch={
(q) => setQuery(q)
// q.length ? setQuery(q) : setQuery(undefined)
}
className="sticky top-0"
/> */}
{/* <div className="w-full space-y-1">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-medium">Kategorien</h2>
<ArrowLink href={appRoutes.allCategories}>Alle Kategorien</ArrowLink>
</div>
{isLoading ? (
<CategoriesGridSkeleton />
) : data?.categories?.length ? (
<CategoriesGrid categories={data.categories} />
) : (
<p>Keine Kategorien gefunden</p>
)}
</div>
<div className="min-h-screen w-full space-y-1">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-medium">Artikel</h2>
<ArrowLink href={appRoutes.allArticles}>Alle Artikel</ArrowLink>
</div>
{isLoading ? (
<ArticleGridSkeleton />
) : data?.articles?.length ? (
<ArticleGrid articles={data.articles as Article[]} />
) : (
<p>Keine Artikel gefunden</p>
)}
</div> */}
</>
);
}
export default MainPage;

View File

@ -1,9 +1,8 @@
import React from 'react'
import React from "react";
import InfiniteArticlesGrid from "@/components/article/grid/infinite-article-grid";
function Page() {
return (
<div>Alle Artikel Page</div>
)
return <InfiniteArticlesGrid />;
}
export default Page
export default Page;

View File

@ -1,62 +0,0 @@
import BreadNavigator from "@/components/bread-navigator";
import { Button } from "@/components/ui/button";
import { hasPermission, Role } from "@/lib/validation/permissions";
import { auth } from "@/server/auth";
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 { appRoutes } from "@/config";
async function Page({ params }: { params: Promise<{ name: string }> }) {
const { name } = await params;
const category = await api.category.get({ slug: name });
if (!category) return notFound();
const articles = await api.article.getAll({ categoryId: category.id });
const session = await auth();
const isEditor = session?.user
? hasPermission(session.user.role, Role.EDITOR)
: false;
return (
<>
<div className="flex items-center justify-between">
<BreadNavigator
links={[
{ label: "Kategorie", href: appRoutes.allCategories },
{
label: name,
href: appRoutes.category(name),
},
]}
/>
{isEditor && (
<Button>
<Edit className="size-4" />
<span>Bearbeiten</span>
</Button>
)}
</div>
<h1 className="text-3xl font-bold capitalize">{name}</h1>
<p className="text-muted-foreground">keine Beschreibung</p>
<h3 className="text-2xl font-bold">Artikel</h3>
{articles.length ? (
<menu>
{articles.map((article) => (
<li key={article.slug}>
<ArticleCard {...article} />
</li>
))}
</menu>
) : (
<p className="text-muted-foreground">
Noch keine Artikel in dieser Kategorie.
</p>
)}
</>
);
}
export default Page;

View File

@ -0,0 +1,27 @@
import React from "react";
import { CategoryPageProps } from "../page";
import { api } from "@/trpc/server";
import { auth } from "@/server/auth";
import { hasPermission, Role } from "@/lib/validation/permissions";
import { notFound } from "next/navigation";
import CategoryForm from "@/components/category/category-form";
import ImageUploadButton from "@/components/image-upload-form";
async function Page({ params }: CategoryPageProps) {
const { slug } = await params;
const session = await auth();
const category = await api.category.get({
slug,
});
const isEditor = session?.user
? hasPermission(session.user.role, Role.EDITOR)
: false;
if (!category || !isEditor) return notFound();
return (
<>
<CategoryForm server_category={category} />
</>
);
}
export default Page;

View File

@ -0,0 +1,86 @@
import BreadNavigator from "@/components/bread-navigator";
import { Button } from "@/components/ui/button";
import { hasPermission, Role } from "@/lib/validation/permissions";
import { auth } from "@/server/auth";
import { api } from "@/trpc/server";
import { Edit } from "lucide-react";
import { notFound } from "next/navigation";
import React from "react";
import { appRoutes } from "@/config";
import Link from "next/link";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { CATEGORY_BANNER_ASPECT_RATIO } from "@/components/category/category-form";
import Image from "next/image";
import ArticleGrid from "@/components/article/grid/article-grid";
export type CategoryPageProps = { params: Promise<{ slug: string }> };
async function Page({ params }: CategoryPageProps) {
const { slug } = await params;
const category = await api.category.get({
slug,
with: {
articles: true,
},
});
if (!category) return notFound();
const session = await auth();
const isEditor = session?.user
? hasPermission(session.user.role, Role.EDITOR)
: false;
return (
<>
<div className="flex w-full items-center justify-between">
<BreadNavigator
className="w-full"
links={[
{ label: "Kategorie", href: appRoutes.allCategories },
{
label: category.name!,
href: appRoutes.category(category.name!),
},
]}
/>
{isEditor && (
<Button asChild>
<Link href={appRoutes.editCategory(slug)}>
<Edit className="size-4" />
<span>Bearbeiten</span>
</Link>
</Button>
)}
</div>
{category.image?.length ? (
<AspectRatio
ratio={CATEGORY_BANNER_ASPECT_RATIO}
className="w-full bg-muted"
>
<Image
src={category.image}
alt="Kategorie-bild"
fill
className="h-full w-full rounded-md object-cover"
/>
</AspectRatio>
) : null}
<h1 className="text-3xl font-bold capitalize">{category.name}</h1>
<p className="text-muted-foreground">
{category.description ?? "keine Beschreibung"}
</p>
{category?.articles?.length ? (
<ArticleGrid articles={category.articles} />
) : (
<p className="text-muted-foreground">
Noch keine Artikel in dieser Kategorie.
</p>
)}
</>
);
}
export default Page;

View File

@ -1,7 +1,15 @@
import CategoriesGrid from "@/components/category/categories-grid";
import { api } from "@/trpc/server";
import React from "react";
function Page() {
return <div>Alle Kategorien</div>;
async function Page() {
const categories = await api.category.getAll();
return (
<>
<h1 className="text-2xl font-bold">Kategorien</h1>
<CategoriesGrid categories={categories} />
</>
);
}
export default Page;

View File

@ -1,4 +1,4 @@
import { AppSidebar } from "@/components/layout/app-sidebar";
import { WikiSidebar } from "@/components/layout/wiki-sidebar";
import Navbar from "@/components/layout/navbar";
import { SidebarProvider } from "@/components/ui/sidebar";
import React from "react";
@ -6,7 +6,7 @@ import React from "react";
function Layout({ children }: { children: React.ReactNode }) {
return (
<SidebarProvider>
<AppSidebar />
<WikiSidebar />
<div className="w-full">
<Navbar />
<main className="space-y-4 p-4">{children}</main>

View File

@ -1,43 +1,13 @@
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 CategoryCard from "./_components/category/category-card";
import MainPage from "./_components/main-page";
import { Article } from "@/server/db/schema";
export default async function Home() {
const session = await auth();
const articles = await api.article.getAllPreviews();
const categories = await api.category.getAll();
const articles = await api.article.getAll({ limit: 12 });
const categories = await api.category.getAll({ limit: 6 });
return (
<>
<div className="w-full space-y-1">
<h2 className="text-2xl font-medium">Kategorien</h2>
<menu className="grid grid-cols-1 gap-2 lg:grid-cols-2 xl:grid-cols-3">
{categories.map((category) => (
<li key={category.slug}>
<CategoryCard {...category} />
</li>
))}
</menu>
</div>
{/* <h1 className="text-4xl font-bold">Anti Rechts Wiki</h1> */}
<div className="flex h-64 w-full items-center justify-center rounded-md bg-muted">
<span>Artikel Suche</span>
</div>
{/* <GlobalStats /> */}
<div className="w-full space-y-1">
<h2 className="text-2xl font-medium">Artikel</h2>
<menu className="grid grid-cols-1 gap-2 lg:grid-cols-2">
{articles.map((article) => (
<li key={article.slug}>
<ArticleCard {...article} />
</li>
))}
</menu>
</div>
<MainPage initialData={{ articles: articles as Article[], categories }} />
</>
);
}

View File

@ -0,0 +1,56 @@
import * as cheerio from "cheerio";
import { NextApiRequest } from "next";
import { NextResponse } from "next/server";
export async function GET(req: NextApiRequest) {
if (req.method !== "GET") {
return NextResponse.json({ error: "Method not allowed" });
}
try {
const { searchParams } = new URL(req.url!);
const url = searchParams.get("url");
if (!url) {
return NextResponse.json({
success: false,
error: "URL parameter is required",
});
}
const response = await fetch(url, {
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" },
});
if (!response.ok) {
return NextResponse.json({
error: "Failed to fetch the URL",
success: false,
});
}
const html = await response.text();
const $ = cheerio.load(html);
// Extract metadata using cheerio
const title = $("title").text() || "No title found";
const description =
$('meta[name="description"]').attr("content") ||
$('meta[property="og:description"]').attr("content") ||
"No description available";
const image = $('meta[property="og:image"]').attr("content") || null;
console.log(title, description, image);
return NextResponse.json({
success: true,
url,
meta: { title, description, image },
});
} catch (error) {
console.error("Error fetching URL:", error);
return NextResponse.json({
error: "Failed to fetch URL metadata",
success: false,
});
}
}

View File

@ -0,0 +1,28 @@
import Link from "next/link";
import React from "react";
import { Button } from "./ui/button";
import { ArrowRight, ChevronRight } from "lucide-react";
function ArrowLink({
href,
children,
}: {
href: string;
children: React.ReactNode;
}) {
return (
<Button
asChild
variant={"link"}
size={"sm"}
className="text-muted-foreground hover:text-foreground"
>
<Link href={href}>
<span>{children}</span>
<ChevronRight className="size-4" />
</Link>
</Button>
);
}
export default ArrowLink;

View File

@ -1,16 +1,49 @@
import { appRoutes } from "@/config";
import { appConfig, appRoutes } from "@/config";
import { Article } from "@/server/db/schema";
import Link from "next/link";
import React from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import Avatar from "../avatar";
import { Icons } from "../icons";
function ArticleCard({
title,
slug,
author,
createdAt,
}: Pick<Article, "title" | "slug" | "createdAt">) {
}: Pick<Article, "title" | "slug" | "createdAt" | "author">) {
const authorName = author?.name ?? `${appConfig.name} Team`;
return (
<Link href={appRoutes.article(slug)}>
<div className="rounded-md border p-4">{title}</div>
<Card className="group flex h-full flex-col justify-between">
<CardHeader>
<CardTitle>{title}</CardTitle>
</CardHeader>
<CardFooter className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{author ? (
<Avatar className="size-6" src={author?.image} fb={authorName} />
) : (
<Icons.logo className="size-6 text-muted-foreground transition-colors duration-150 group-hover:text-foreground" />
)}
<span className="text-sm text-muted-foreground">{authorName}</span>
</div>
<p className="text-sm text-muted-foreground">
{createdAt.toLocaleDateString("de-DE", {
dateStyle: "long",
})}
</p>
</CardFooter>
</Card>
</Link>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import { Article, Category } from "@/server/db/schema";
import { Article } from "@/server/db/schema";
import React from "react";
@ -19,13 +19,13 @@ import {
import { articleSchema } from "@/lib/validation/zod/article";
import { cn, debounce } from "@/lib/utils";
import { updateArticle } from "@/server/actions/article";
import Editor from "../text-editor";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
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";
export default ({ server_article }: { server_article: Article }) => {
const [loading, setLoading] = React.useState(false);
@ -46,7 +46,10 @@ export default ({ server_article }: { server_article: Article }) => {
console.log("Content before save", values.content);
setLoading(true);
await updateArticle(values, server_article.id);
await updateArticle(
{ ...values, content: JSON.stringify(values.content) },
server_article.id,
);
setLoading(false);
form.reset(values);
}
@ -58,10 +61,14 @@ export default ({ server_article }: { server_article: Article }) => {
[form],
);
const published = form.watch("published");
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col-reverse gap-4 space-y-4 xl:flex-row"
>
<div className="flex w-full max-w-3xl flex-col gap-2">
<FormField
control={form.control}
name="title"
@ -70,7 +77,7 @@ export default ({ server_article }: { server_article: Article }) => {
<FormControl>
<TextareaAutosize
cols={1}
className="w-full resize-none text-4xl font-bold focus-visible:outline-none"
className="h-max w-full resize-none text-4xl font-bold focus-visible:outline-none"
value={field.value}
onChange={(e) => {
field.onChange(e);
@ -82,8 +89,6 @@ export default ({ server_article }: { server_article: Article }) => {
</FormItem>
)}
/>
</div>
<div className="flex w-full gap-4">
<FormField
control={form.control}
name="content"
@ -91,19 +96,10 @@ export default ({ server_article }: { server_article: Article }) => {
<FormItem className="w-full">
<FormControl>
<Editor
// content={field.value}
editorProviderProps={{
content: field.value,
editorProps: { attributes: { class: "min-h-64" } },
onUpdate: (value) => {
const newContent = value.editor.getHTML();
console.log(
"Content :: form",
JSON.stringify(newContent),
);
field.onChange(newContent);
debouncedSubmit();
},
initialContent={field.value}
onContentChange={(content) => {
field.onChange(content);
debouncedSubmit();
}}
/>
</FormControl>
@ -112,99 +108,97 @@ export default ({ server_article }: { server_article: Article }) => {
</FormItem>
)}
/>
<div
className={cn(
"sticky top-4 h-max w-full max-w-md",
// loading && "border-t-blue-600",
)}
>
<div className="relative w-full space-y-4 overflow-hidden rounded-md border-t-2 bg-muted p-4">
<div
className={cn(
"saving absolute left-0 right-0 top-0 h-0.5 rounded-full bg-primary",
!loading && "hidden",
)}
/>
</div>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<Badge
variant={"outline"}
className="flex items-center gap-1"
>
{!form.formState.isDirty && !loading ? (
<>
<CheckCircle className="size-4 text-emerald-600" />
<span>Gespeichert</span>
</>
) : (
<>
<XCircle className="size-4 text-destructive" />
<span>Nicht Gespeichert</span>
</>
)}
</Badge>
<span
className={cn(
"text-xs",
published ? "text-emerald-700" : "text-muted-foreground",
)}
>
{published
? "Veröffentlicht"
: "Draft (nicht veröffentlicht)"}
</span>
</div>
<Link
href={"/editoren-hilfe"}
target="_blank"
className="size-max scale-90 p-0 text-xs text-muted-foreground"
<div
className={cn(
"top-4 h-max w-full max-w-xl xl:sticky xl:max-w-md",
// loading && "border-t-blue-600",
)}
>
<div className="relative w-full space-y-4 overflow-hidden rounded-md border-t-2 bg-muted p-4">
<div
className={cn(
"saving absolute left-0 right-0 top-0 h-0.5 rounded-full bg-primary",
!loading && "hidden",
)}
/>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant={"outline"} className="flex items-center gap-1">
{!form.formState.isDirty && !loading ? (
<>
<CheckCircle className="size-4 text-emerald-600" />
<span>Gespeichert</span>
</>
) : (
<>
<XCircle className="size-4 text-destructive" />
<span>Nicht Gespeichert</span>
</>
)}
</Badge>
<span
className={cn(
"text-xs",
published ? "text-emerald-700" : "text-muted-foreground",
)}
>
<span>? Hilfe</span>
</Link>
{published
? "Veröffentlicht"
: "Draft (nicht veröffentlicht)"}
</span>
</div>
<FormField
control={form.control}
name="published"
render={({ field }) => (
<FormItem className="rounded-md border bg-background px-4 py-2">
<FormControl>
<div className="flex items-center gap-2">
<Label>Veröffentlicht </Label>
<PublishArticleAlertDialog
published={field.value}
setPublished={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<CategorySelect
initialValue={field.value}
onSelect={(categoryId) => {
field.onChange(categoryId);
<Link
href={"/editoren-hilfe"}
target="_blank"
className="size-max scale-90 p-0 text-xs text-muted-foreground"
>
<span>? Hilfe</span>
</Link>
</div>
<FormField
control={form.control}
name="published"
render={({ field }) => (
<FormItem className="rounded-md border bg-background px-4 py-2">
<FormControl>
<div className="flex items-center gap-2">
<Label>Veröffentlicht </Label>
<PublishArticleAlertDialog
published={field.value}
setPublished={(value) => {
field.onChange(value);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<CategorySelect
initialValue={field.value}
onSelect={(categoryId) => {
field.onChange(categoryId);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</form>

View File

@ -0,0 +1,33 @@
import { Article } from "@/server/db/schema";
import React from "react";
import ArticleCard from "../article-card";
import { Skeleton } from "../../ui/skeleton";
export const ARTICLE_GRID_CLASS = "grid grid-cols-1 gap-4 lg:grid-cols-2";
function ArticleGrid({ articles }: { articles: Article[] }) {
return (
<menu className={ARTICLE_GRID_CLASS}>
{articles.map((article) => (
<li key={article.slug} className="h-full">
<ArticleCard {...article} />
</li>
))}
</menu>
);
}
export default ArticleGrid;
export function ArticleGridSkeleton() {
const range = Array.from(new Array(6).keys());
return (
<ul className={ARTICLE_GRID_CLASS}>
{range.map((i) => (
<li key={i}>
<Skeleton className="h-12 w-full" />
</li>
))}
</ul>
);
}

View File

@ -0,0 +1,61 @@
"use client";
import { api } from "@/trpc/react";
import React from "react";
import { ARTICLE_GRID_CLASS } from "./article-grid";
import ArticleCard from "../article-card";
import { useInfiniteItemsObserver } from "@/lib/hooks/infinite-items-observer-hook";
import { Skeleton } from "@/components/ui/skeleton";
function InfiniteArticlesGrid() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
api.article.getByPage.useInfiniteQuery(
{},
{
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">
<menu className={`${ARTICLE_GRID_CLASS} overflow-auto`}>
{data?.pages?.length
? allItems.map((article, idx) => (
<li key={`article-${idx}`}>
<ArticleCard {...article} />
</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>
);
}
export default InfiniteArticlesGrid;

View File

@ -1,8 +1,7 @@
import React from "react";
import Editor from "../text-editor";
function RenderArticle({ content }: { content: string }) {
return <Editor readOnly editorProviderProps={{ content: content }} />;
return "render article: in work"; //<Editor readOnly editorProviderProps={{ content: content }} />;
}
export default RenderArticle;

View File

@ -10,13 +10,15 @@ import {
function BreadNavigator({
links,
className,
}: {
links: { label: string; href: string }[];
className?: string;
}) {
const labelClass =
"w-full max-w-52 overflow-hidden text-ellipsis whitespace-nowrap capitalize block";
return (
<Breadcrumb>
<Breadcrumb className={className}>
<BreadcrumbList>
{links.map(({ label, href }, idx) => {
if (idx < links.length - 1)

View File

@ -0,0 +1,33 @@
import CategoryCard from "@/components/category/category-card";
import { Category } from "@/server/db/schema";
import React from "react";
import { Skeleton } from "../ui/skeleton";
const GRID_CLASS = "grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3";
function CategoriesGrid({ categories }: { categories: Category[] }) {
return (
<menu className={GRID_CLASS}>
{categories.map((category) => (
<li key={category.id}>
<CategoryCard {...category} />
</li>
))}
</menu>
);
}
export default CategoriesGrid;
export function CategoriesGridSkeleton() {
const range = Array.from(new Array(6).keys());
return (
<ul className={GRID_CLASS}>
{range.map((i) => (
<li key={i}>
<Skeleton className="h-12 w-full" />
</li>
))}
</ul>
);
}

View File

@ -0,0 +1,50 @@
import { appRoutes } from "@/config";
import { Category } from "@/server/db/schema";
import Link from "next/link";
import React from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { AspectRatio } from "../ui/aspect-ratio";
import { CATEGORY_BANNER_ASPECT_RATIO } from "./category-form";
import Image from "next/image";
import { Icons } from "../icons";
function CategoryCard({
name,
slug,
createdAt,
image,
}: Pick<Category, "name" | "slug" | "createdAt" | "image">) {
return (
<Link href={appRoutes.category(slug)}>
<Card className="overflow-hidden">
{/* <AspectRatio
className="flex items-center justify-center bg-muted text-muted-foreground"
ratio={CATEGORY_BANNER_ASPECT_RATIO}
>
{image?.length ? (
<Image
src={image}
alt="Kategorie-bild"
fill
className="object-cover"
/>
) : (
<Icons.logo />
)}
</AspectRatio> */}
<CardHeader>
<CardTitle>{name}</CardTitle>
</CardHeader>
</Card>
</Link>
);
}
export default CategoryCard;

View File

@ -0,0 +1,120 @@
"use client";
import React from "react";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { categorySchema } from "@/lib/validation/zod/category";
import { Category } from "@/server/db/schema";
import { Textarea } from "../ui/textarea";
import { updateCategory } from "@/server/actions/category";
import { toast } from "sonner";
import ImageUploadButton from "../image-upload-form";
import Image from "next/image";
import { AspectRatio } from "../ui/aspect-ratio";
export const CATEGORY_BANNER_ASPECT_RATIO = 20 / 3;
function CategoryForm({ server_category }: { server_category: Category }) {
const form = useForm<z.infer<typeof categorySchema>>({
resolver: zodResolver(categorySchema),
defaultValues: {
name: server_category?.name ?? "",
description: server_category?.description ?? "",
image: server_category?.image ?? "",
},
});
// 2. Define a submit handler.
async function onSubmit(values: z.infer<typeof categorySchema>) {
const success = await updateCategory(values, server_category.id);
if (success) {
toast.success("Kategorie gespeichert.");
form.reset();
} else toast.error("Speichern fehlgeschlagen.");
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name der Kategorie</FormLabel>
<FormControl>
<Input placeholder="Name der Kategorie" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Beschreibung der Kategorie</FormLabel>
<FormControl>
<Textarea
placeholder="Das ist eine kurze beschreibung"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="image"
render={({ field }) => (
<FormItem>
<FormLabel>Kategorie Banner</FormLabel>
<FormControl>
<ImageUploadButton
onUploaded={(url) => {
field.onChange(url);
form.handleSubmit(onSubmit)();
}}
/>
</FormControl>
<FormMessage />
{field.value?.length ? (
<AspectRatio
ratio={CATEGORY_BANNER_ASPECT_RATIO}
className="w-full max-w-lg bg-muted"
>
<Image
src={field.value}
alt="Kategorie-bild"
fill
className="h-full w-full rounded-md object-cover"
/>
</AspectRatio>
) : null}
</FormItem>
)}
/>
<Button type="submit" disabled={!form.formState.isDirty}>
Kategorie Speichern
</Button>
</form>
</Form>
);
}
export default CategoryForm;

View File

@ -36,6 +36,7 @@ export function DataTable<TData, TValue>({
columns,
data,
}: DataTableProps<TData, TValue>) {
"use no memo";
const [pagination, setPagination] = React.useState<PaginationState>({
pageSize: 25,
pageIndex: 0,
@ -45,6 +46,7 @@ export function DataTable<TData, TValue>({
[],
);
const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({});
const table = useReactTable({
data,
columns,
@ -65,6 +67,7 @@ export function DataTable<TData, TValue>({
});
return (
<div className="space-y-4">
{JSON.stringify(sorting)}
<div className="flex items-center">
<Input
placeholder="Email suchen..."

View File

@ -0,0 +1,124 @@
import {
CharacterCount,
Color,
CustomKeymap,
GlobalDragHandle,
HighlightExtension,
HorizontalRule,
Placeholder,
StarterKit,
TaskItem,
TaskList,
TextStyle,
TiptapImage,
TiptapLink,
TiptapUnderline,
UpdatedImage,
UploadImagesPlugin,
Youtube,
} from "novel";
import LinkPreview from "./link-preview";
import { Heading } from "@tiptap/extension-heading";
import { cx } from "class-variance-authority";
import { slashCommand } from "./slash-commands";
const placeholder = Placeholder;
const tiptapLink = TiptapLink.configure({
HTMLAttributes: {
class: cx(
"text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer",
),
},
});
const tiptapImage = TiptapImage.extend({
addProseMirrorPlugins() {
return [
UploadImagesPlugin({
imageClass: cx("opacity-40 rounded-lg border border-stone-200"),
}),
];
},
}).configure({
allowBase64: true,
HTMLAttributes: {
class: cx("rounded-lg border border-muted"),
},
});
const updatedImage = UpdatedImage.configure({
HTMLAttributes: {
class: cx("rounded-lg border border-muted"),
},
});
const taskList = TaskList.configure({
HTMLAttributes: {
class: cx("not-prose pl-2 "),
},
});
const taskItem = TaskItem.configure({
HTMLAttributes: {
class: cx("flex gap-2 items-start my-4"),
},
nested: true,
});
const horizontalRule = HorizontalRule.configure({
HTMLAttributes: {
class: cx("mt-4 mb-6 border-t border-muted-foreground"),
},
});
const starterKit = StarterKit.configure({
dropcursor: {
color: "#DBEAFE",
width: 4,
},
heading: {
levels: [2, 3, 4, 5, 6],
},
gapcursor: false,
horizontalRule: false,
});
const youtube = Youtube.configure({
HTMLAttributes: {
class: cx("rounded-lg border border-muted"),
},
inline: false,
});
const linkPreview = LinkPreview.configure({
async fetchMetadata(url) {
const response = await fetch(`/api/url-preview?url=${url}`);
const metadata = await response.json();
console.log("metadata", metadata);
return metadata?.meta;
},
});
const characterCount = CharacterCount.configure();
export const defaultExtensions = [
starterKit,
placeholder,
tiptapLink,
tiptapImage,
updatedImage,
taskList,
taskItem,
horizontalRule,
youtube,
characterCount,
TiptapUnderline,
HighlightExtension,
TextStyle,
Color,
CustomKeymap,
GlobalDragHandle,
slashCommand,
linkPreview,
];

View File

@ -0,0 +1,140 @@
import { mergeAttributes, Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { LinkPreviewComponent } from "./link-preview-component";
export interface LinkPreviewOptions {
HTMLAttributes: {
[key: string]: any;
};
fetchMetadata?: (
url: string,
) => Promise<{ title?: string; description?: string; cover?: string }>;
}
declare module "@tiptap/core" {
interface Commands<ReturnType> {
linkPreview: {
/**
* Add an linkPreview
*/
createLinkPreview: () => ReturnType;
};
}
}
export default Node.create<LinkPreviewOptions>({
name: "linkPreview",
group: "block",
atom: true,
// content: "block+",
addOptions() {
return {
HTMLAttributes: {
class: "link-preview-wrapper",
},
fetchMetadata: async (url) => {
// Default: Simple metadata fetch (Override this via `configure()`)
return {
title: "Placeholder Title",
description:
"You need to provide a custom `fetchMetadata` function You need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` function",
cover: "",
};
},
};
},
addAttributes() {
return {
href: {
default: null,
parseHTML(element) {
return element.getAttribute("href");
},
},
title: {
default: null,
parseHTML(element) {
return element?.firstChild?.childNodes?.[0]?.childNodes?.[0]
?.nodeValue;
},
renderHTML() {
return;
},
},
description: {
default: null,
parseHTML(element) {
return element?.firstChild?.childNodes?.[1]?.childNodes?.[0]
?.nodeValue;
},
renderHTML() {
return;
},
},
image: {
default: null,
parseHTML(element) {
// @ts-ignore
return element.childNodes[1].src;
},
renderHTML() {
return;
},
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${this.name}"]`,
priority: 1000,
},
];
},
renderHTML({ HTMLAttributes }) {
return ["div", mergeAttributes({ "data-type": this.name }, HTMLAttributes)];
},
addNodeView() {
return ReactNodeViewRenderer(LinkPreviewComponent);
},
addPasteRules() {
return [
{
find: /(https?:\/\/[^\s]+)/g,
handler: ({ match, state, range }) => {
const url = match[0];
// Replace the pasted URL with the link preview node
state.tr.replaceWith(
range.from,
range.to,
this.type.create({ href: url }),
);
},
},
];
},
addCommands() {
return {
createLinkPreview:
() =>
({ chain }) => {
return chain()
.insertContent({
type: "linkPreview",
attrs: {},
})
.focus()
.run();
},
};
},
});

View File

@ -0,0 +1,136 @@
"use client";
import React from "react";
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ArrowRight } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";
export type LinkPreviewData = {
href: string;
title: string;
description: string;
image: string;
};
const InputLink = ({ onSubmit }: { onSubmit: (link: string) => void }) => {
const [link, setLink] = React.useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); // Prevent page reload
if (link.trim()) {
onSubmit(link); // Pass link to parent function
setLink(""); // Clear input field after submission
}
};
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
inputRef.current?.focus();
}, []);
return (
<form onSubmit={handleSubmit} className="flex gap-2 " autoFocus>
<Input
ref={inputRef}
autoFocus
type="url"
placeholder="Enter a link"
value={link}
onChange={(e) => setLink(e.target.value)}
className="flex-1 focus-visible:ring-transparent border-0"
/>
<Button
type="submit"
variant={"ghost"}
size={"icon"}
className="text-muted-foreground"
>
<ArrowRight />
</Button>
</form>
);
};
const Preview = ({
href,
title,
description: _description,
image,
}: LinkPreviewData) => {
const description =
_description?.length > 100
? `${_description?.slice(0, 150)}...`
: _description;
return (
<a href={href} target="_blank">
<div className=" flex gap-4 flex-col-reverse md:flex-row">
<div className="w-full space-y-2">
<h2
className="text-xl"
style={{
margin: 0,
fontSize: "1.5rem",
}}
>
{title}
</h2>
<p className="text-sm mt-2">{description}</p>
<span className="text-xs text-muted-foreground ">{href}</span>
</div>
{image?.length ? (
<img
src={image}
alt={title}
className="w-full max-w-40 rounded-md object-cover "
/>
) : (
<div className="size-20 rounded-md bg-muted" />
)}
</div>
</a>
);
};
export const LinkPreviewComponent: React.FC<NodeViewProps> = ({
node,
updateAttributes,
extension,
}) => {
const [preview, setPreview] = React.useState<LinkPreviewData | undefined>(
(node.attrs?.href?.length && (node.attrs as LinkPreviewData)) ?? undefined
);
const [loading, setLoading] = React.useState(false);
return (
<NodeViewWrapper as="div" className="p-4 rounded-md bg-background border ">
{loading ? (
<Skeleton className="h-8 w-full rounded" />
) : preview ? (
<Preview {...preview} />
) : (
<InputLink
onSubmit={async (url: string) => {
setLoading(true);
try {
const metadata = await extension.options.fetchMetadata(url);
const newAttrs = {
href: url,
title: metadata?.title,
description: metadata?.description,
image: metadata?.image,
};
updateAttributes(newAttrs);
setPreview(newAttrs);
setLoading(false);
} catch (error) {
console.error("Error fetching metadata:", error);
setLoading(false);
}
}}
/>
)}
</NodeViewWrapper>
);
};

View File

@ -0,0 +1,195 @@
import {
CheckSquare,
Heading2,
Heading3,
Heading4,
ImageIcon,
Link2Icon,
List,
ListOrdered,
Text,
TextQuote,
Youtube,
} from "lucide-react";
import { Command, renderItems, createSuggestionItems } from "novel";
import { selectionItems } from "../../selector/selection-items";
// const items = selectionItems.filter((item) => !item.inline);
// const defaultSuggestionItems = items.map((item) => (
// {
// title: item.name,
// description: item.name,
// searchTerms: [item.name],
// icon: item.icon,
// command: ({ editor, range }) => {
// editor.chain().focus().deleteRange(range).toggleNode(item.name).run();
// },
// }
// ));
export const suggestionItems = createSuggestionItems([
{
title: "Text",
description: "Just start typing with plain text.",
searchTerms: ["p", "paragraph"],
icon: <Text size={18} />,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.run();
},
},
{
title: "To-do List",
description: "Track tasks with a to-do list.",
searchTerms: ["todo", "task", "list", "check", "checkbox"],
icon: <CheckSquare size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleTaskList().run();
},
},
{
title: "Heading 2",
description: "Medium section heading.",
searchTerms: ["subtitle", "medium"],
icon: <Heading2 size={18} />,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 2 })
.run();
},
},
{
title: "Heading 3",
description: "Small section heading.",
searchTerms: ["subtitle", "small"],
icon: <Heading3 size={18} />,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 3 })
.run();
},
},
{
title: "Heading 4",
description: "Big section heading.",
searchTerms: ["title", "big", "large"],
icon: <Heading4 size={18} />,
command: ({ editor, range }) => {
editor
.chain()
.focus()
.deleteRange(range)
.setNode("heading", { level: 4 })
.run();
},
},
{
title: "Bullet List",
description: "Create a simple bullet list.",
searchTerms: ["unordered", "point"],
icon: <List size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleBulletList().run();
},
},
{
title: "Numbered List",
description: "Create a list with numbering.",
searchTerms: ["ordered"],
icon: <ListOrdered size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
},
},
{
title: "Quote",
description: "Capture a quote.",
searchTerms: ["blockquote"],
icon: <TextQuote size={18} />,
command: ({ editor, range }) =>
editor
.chain()
.focus()
.deleteRange(range)
.toggleNode("paragraph", "paragraph")
.toggleBlockquote()
.run(),
},
{
title: "Link Preview",
description: "Embed a Link Preview.",
searchTerms: ["link"],
icon: <Link2Icon size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).createLinkPreview().run();
},
},
{
title: "Image",
description: "Upload an image from your computer.",
searchTerms: ["photo", "picture", "media"],
icon: <ImageIcon size={18} />,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// upload image
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
// uploadFn(file, editor.view, pos);
}
};
input.click();
},
},
{
title: "Youtube",
description: "Embed a Youtube video.",
searchTerms: ["video", "youtube", "embed"],
icon: <Youtube size={18} />,
command: ({ editor, range }) => {
const videoLink = prompt("Please enter Youtube Video Link");
//From https://regexr.com/3dj5t
const ytregex = new RegExp(
/^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
);
if (ytregex.test(String(videoLink))) {
editor
.chain()
.focus()
.deleteRange(range)
.setYoutubeVideo({
src: String(videoLink),
})
.run();
} else {
if (videoLink !== null) {
alert("Please enter a correct Youtube Video Link");
}
}
},
},
]);
export const slashCommand = Command.configure({
suggestion: {
items: () => suggestionItems,
render: renderItems,
},
});

View File

@ -0,0 +1,38 @@
"use client";
import {
EditorCommand,
EditorCommandEmpty,
EditorCommandItem,
EditorCommandList,
} from "novel";
import { suggestionItems } from ".";
export function SlashCommandComponent() {
return (
<EditorCommand className="z-50 h-auto max-h-[330px] w-72 overflow-y-auto rounded-md border border-muted bg-background px-1 py-2 shadow-md transition-all">
<EditorCommandEmpty className="px-2 text-muted-foreground">
No results
</EditorCommandEmpty>
<EditorCommandList>
{suggestionItems.map((item) => (
<EditorCommandItem
value={item.title}
onCommand={(val) => item?.command?.(val)}
className={`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm hover:bg-accent aria-selected:bg-accent `}
key={item.title}
>
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-muted bg-background">
{item.icon}
</div>
<div>
<p className="font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground">
{item.description}
</p>
</div>
</EditorCommandItem>
))}
</EditorCommandList>
</EditorCommand>
);
}

View File

@ -0,0 +1,43 @@
"use client";
import "./styles.css";
import {
EditorContent,
EditorRoot,
handleCommandNavigation,
JSONContent,
} from "novel";
import { defaultExtensions } from "./extentions";
import { SlashCommandComponent } from "./extentions/slash-commands/slash-command-component";
import BubbleMenu from "./menu/bubble-menu";
import { MenuBar } from "./menu/menu-bar";
const Editor = ({
onContentChange,
initialContent,
}: {
initialContent: JSONContent | null;
onContentChange: (content: JSONContent) => void;
}) => {
return (
<EditorRoot>
<EditorContent
slotBefore={<MenuBar />}
extensions={defaultExtensions}
editorProps={{
handleDOMEvents: {
keydown: (_view, event) => handleCommandNavigation(event),
},
}}
initialContent={initialContent ?? { type: "doc" }}
onUpdate={({ editor }) => {
onContentChange(editor.getJSON());
}}
>
<SlashCommandComponent />
<BubbleMenu />
</EditorContent>
</EditorRoot>
);
};
export default Editor;

View File

@ -0,0 +1,43 @@
"use client";
import { EditorBubble, useEditor } from "novel";
import { Fragment, useState } from "react";
import { NodeSelector } from "../selector/node-selector";
import { Separator } from "@/components/ui/separator";
import { LinkSelector } from "../selector/link-selector";
import { TextButtons } from "../selector/text-buttont";
import { ColorSelector } from "../selector/color-selector";
const BubbleMenu = () => {
const { editor } = useEditor();
const [openNode, setOpenNode] = useState(false);
const [openColor, setOpenColor] = useState(false);
const [openLink, setOpenLink] = useState(false);
const [open, setOpen] = useState(false);
return (
<EditorBubble
tippyOptions={{
placement: open ? "bottom-start" : "top",
onHidden: () => {
setOpen(false);
editor?.chain().unsetHighlight().run();
},
}}
className="flex w-fit max-w-[90vw] overflow-hidden rounded-md border border-muted bg-background shadow-xl"
>
{!open && (
<Fragment>
<NodeSelector open={openNode} onOpenChange={setOpenNode} />
<Separator orientation="vertical" />
<LinkSelector open={openLink} onOpenChange={setOpenLink} />
<Separator orientation="vertical" />
<TextButtons />
<Separator orientation="vertical" />
<ColorSelector open={openColor} onOpenChange={setOpenColor} />
</Fragment>
)}
</EditorBubble>
);
};
export default BubbleMenu;

View File

@ -0,0 +1,49 @@
import { Button } from "@/components/ui/button";
import { useEditor } from "novel";
import { RedoIcon, UndoIcon } from "lucide-react";
import { selectionItems } from "../selector/selection-items";
export const MenuBar = () => {
const { editor } = useEditor();
if (!editor) {
return null;
}
return (
<div className="sticky top-0 z-50 flex justify-between gap-1 bg-background py-4">
<div className="flex flex-wrap gap-1">
{selectionItems.map((item) => (
<Button
key={item.name}
size={"icon"}
variant={"outline"}
onClick={() => item.command(editor)}
disabled={item?.canRun ? item.canRun(editor) : false}
className={item.isActive(editor) ? "border-foreground/75" : ""}
>
<item.icon />
</Button>
))}
</div>
<div className="flex gap-1">
<Button
variant={"outline"}
size={"icon"}
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().chain().focus().undo().run()}
>
<UndoIcon className="size-4" />
</Button>
<Button
variant={"outline"}
size={"icon"}
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().chain().focus().redo().run()}
>
<RedoIcon className="size-4" />
</Button>
</div>
</div>
);
};

View File

@ -0,0 +1,196 @@
import { Check, ChevronDown } from "lucide-react";
import { EditorBubbleItem, useEditor } from "novel";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
export interface BubbleColorMenuItem {
name: string;
color: string;
}
const TEXT_COLORS: BubbleColorMenuItem[] = [
{
name: "Default",
color: "var(--novel-black)",
},
{
name: "Purple",
color: "#9333EA",
},
{
name: "Red",
color: "#E00000",
},
{
name: "Yellow",
color: "#EAB308",
},
{
name: "Blue",
color: "#2563EB",
},
{
name: "Green",
color: "#008A00",
},
{
name: "Orange",
color: "#FFA500",
},
{
name: "Pink",
color: "#BA4081",
},
{
name: "Gray",
color: "#A8A29E",
},
];
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
{
name: "Default",
color: "var(--novel-highlight-default)",
},
{
name: "Purple",
color: "var(--novel-highlight-purple)",
},
{
name: "Red",
color: "var(--novel-highlight-red)",
},
{
name: "Yellow",
color: "var(--novel-highlight-yellow)",
},
{
name: "Blue",
color: "var(--novel-highlight-blue)",
},
{
name: "Green",
color: "var(--novel-highlight-green)",
},
{
name: "Orange",
color: "var(--novel-highlight-orange)",
},
{
name: "Pink",
color: "var(--novel-highlight-pink)",
},
{
name: "Gray",
color: "var(--novel-highlight-gray)",
},
];
interface ColorSelectorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => {
const { editor } = useEditor();
if (!editor) return null;
const activeColorItem = TEXT_COLORS.find(({ color }) =>
editor.isActive("textStyle", { color })
);
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
editor.isActive("highlight", { color })
);
return (
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button size="sm" className="gap-2 rounded-none" variant="ghost">
<span
className="rounded-sm px-1"
style={{
color: activeColorItem?.color,
backgroundColor: activeHighlightItem?.color,
}}
>
A
</span>
<ChevronDown className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent
sideOffset={5}
className="my-1 flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl "
align="start"
>
<div className="flex flex-col">
<div className="my-1 px-2 text-sm font-semibold text-muted-foreground">
Color
</div>
{TEXT_COLORS.map(({ name, color }) => (
<EditorBubbleItem
key={name}
onSelect={() => {
editor.commands.unsetColor();
name !== "Default" &&
editor
.chain()
.focus()
.setColor(color || "")
.run();
onOpenChange(false);
}}
className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent"
>
<div className="flex items-center gap-2">
<div
className="rounded-sm border px-2 py-px font-medium"
style={{ color }}
>
A
</div>
<span>{name}</span>
</div>
</EditorBubbleItem>
))}
</div>
<div>
<div className="my-1 px-2 text-sm font-semibold text-muted-foreground">
Background
</div>
{HIGHLIGHT_COLORS.map(({ name, color }) => (
<EditorBubbleItem
key={name}
onSelect={() => {
editor.commands.unsetHighlight();
name !== "Default" &&
editor.chain().focus().setHighlight({ color }).run();
onOpenChange(false);
}}
className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent"
>
<div className="flex items-center gap-2">
<div
className="rounded-sm border px-2 py-px font-medium"
style={{ backgroundColor: color }}
>
A
</div>
<span>{name}</span>
</div>
{editor.isActive("highlight", { color }) && (
<Check className="h-4 w-4" />
)}
</EditorBubbleItem>
))}
</div>
</PopoverContent>
</Popover>
);
};

View File

@ -0,0 +1,106 @@
import { Button } from "@/components/ui/button";
import { PopoverContent } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Popover, PopoverTrigger } from "@radix-ui/react-popover";
import { Check, Trash } from "lucide-react";
import { useEditor } from "novel";
import { useEffect, useRef } from "react";
export function isValidUrl(url: string) {
try {
new URL(url);
return true;
} catch (_e) {
return false;
}
}
export function getUrlFromString(str: string) {
if (isValidUrl(str)) return str;
try {
if (str.includes(".") && !str.includes(" ")) {
return new URL(`https://${str}`).toString();
}
} catch (_e) {
return null;
}
}
interface LinkSelectorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const { editor } = useEditor();
// Autofocus on input by default
useEffect(() => {
inputRef.current?.focus();
});
if (!editor) return null;
return (
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
size="sm"
variant="ghost"
className="gap-2 rounded-none border-none"
>
<p className="text-base"></p>
<p
className={cn("underline decoration-stone-400 underline-offset-4", {
"text-blue-500": editor.isActive("link"),
})}
>
Link
</p>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-60 p-0" sideOffset={10}>
<form
onSubmit={(e) => {
const target = e.currentTarget as HTMLFormElement;
e.preventDefault();
const input = target[0] as HTMLInputElement;
const url = getUrlFromString(input.value);
if (url) {
editor.chain().focus().setLink({ href: url }).run();
onOpenChange(false);
}
}}
className="flex p-1"
>
<input
ref={inputRef}
type="text"
placeholder="Paste a link"
className="flex-1 bg-background p-1 text-sm outline-none"
defaultValue={editor.getAttributes("link").href || ""}
/>
{editor.getAttributes("link").href ? (
<Button
size="icon"
variant="outline"
type="button"
className="flex h-8 items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
onClick={() => {
editor.chain().focus().unsetLink().run();
if (inputRef?.current) {
inputRef.current.value = "";
}
onOpenChange(false);
}}
>
<Trash className="h-4 w-4" />
</Button>
) : (
<Button size="icon" className="h-8">
<Check className="h-4 w-4" />
</Button>
)}
</form>
</PopoverContent>
</Popover>
);
};

View File

@ -0,0 +1,56 @@
import { Check, ChevronDown } from "lucide-react";
import { EditorBubbleItem, useEditor } from "novel";
import { Button } from "@/components/ui/button";
import { PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Popover } from "@radix-ui/react-popover";
import { selectionItems } from "./selection-items";
interface NodeSelectorProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const items = selectionItems.filter((item) => !item.inline);
export const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => {
const { editor } = useEditor();
if (!editor) return null;
const activeList = items.filter((item) => item.isActive(editor));
const activeItem = activeList.pop() ?? {
name: "Multiple",
};
return (
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
asChild
className="gap-2 rounded-none border-none hover:bg-accent focus:ring-0"
>
<Button size="sm" variant="ghost" className="gap-2">
<span className="whitespace-nowrap text-sm">{activeItem.name}</span>
<ChevronDown className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent sideOffset={5} align="start" className="w-48 p-1">
{items.map((item) => (
<EditorBubbleItem
key={item.name}
onSelect={(editor) => {
item.command(editor);
onOpenChange(false);
}}
className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent"
>
<div className="flex items-center space-x-2">
<div className="rounded-sm border p-1">
<item.icon className="h-3 w-3" />
</div>
<span>{item.name}</span>
</div>
{activeItem.name === item.name && <Check className="h-4 w-4" />}
</EditorBubbleItem>
))}
</PopoverContent>
</Popover>
);
};

View File

@ -0,0 +1,140 @@
import {
BoldIcon,
ItalicIcon,
LucideIcon,
StrikethroughIcon,
UnderlineIcon,
CheckSquare,
Heading2,
Heading3,
Heading4,
ListOrdered,
TextIcon,
TextQuote,
SeparatorHorizontalIcon,
Heading5,
Heading6,
QuoteIcon,
} from "lucide-react";
import { EditorInstance } from "novel";
export type SelectorItem = {
name: string;
icon: LucideIcon;
inline?: boolean;
command: (editor: EditorInstance) => void;
isActive: (editor: EditorInstance) => boolean;
canRun?: (editor: EditorInstance) => boolean;
};
export const selectionItems: SelectorItem[] = [
{
name: "bold",
isActive: (editor) => editor.isActive("bold"),
command: (editor) => editor.chain().focus().toggleBold().run(),
icon: BoldIcon,
inline: true,
canRun: (editor) => !editor.can().chain().focus().toggleBold().run(),
},
{
name: "italic",
isActive: (editor) => editor.isActive("italic"),
command: (editor) => editor.chain().focus().toggleItalic().run(),
icon: ItalicIcon,
inline: true,
canRun: (editor) => !editor.can().chain().focus().toggleItalic().run(),
},
{
name: "underline",
isActive: (editor) => editor.isActive("underline"),
command: (editor) => editor.chain().focus().toggleUnderline().run(),
icon: UnderlineIcon,
inline: true,
canRun: (editor) => !editor.can().chain().focus().toggleUnderline().run(),
},
{
name: "strike",
isActive: (editor) => editor.isActive("strike"),
command: (editor) => editor.chain().focus().toggleStrike().run(),
icon: StrikethroughIcon,
inline: true,
canRun: (editor) => !editor.can().chain().focus().toggleStrike().run(),
},
// blocks
{
name: "Text",
icon: TextIcon,
command: (editor) => editor.chain().focus().clearNodes().run(),
isActive: (editor) => editor.isActive("paragraph"), //node?.type?.name === "paragraph",
},
{
name: "Überschrift",
icon: Heading2,
command: (editor) =>
editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(),
isActive: (editor) => editor.isActive("heading", { level: 2 }),
},
{
name: "Überschrift 3",
icon: Heading3,
command: (editor) =>
editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(),
isActive: (editor) => editor.isActive("heading", { level: 3 }),
},
{
name: "Überschrift 4",
icon: Heading4,
command: (editor) =>
editor.chain().focus().clearNodes().toggleHeading({ level: 4 }).run(),
isActive: (editor) => editor.isActive("heading", { level: 4 }),
},
{
name: "Überschrift 5",
icon: Heading5,
command: (editor) =>
editor.chain().focus().clearNodes().toggleHeading({ level: 5 }).run(),
isActive: (editor) => editor.isActive("heading", { level: 5 }),
},
{
name: "Überschrift 6",
icon: Heading6,
command: (editor) =>
editor.chain().focus().clearNodes().toggleHeading({ level: 6 }).run(),
isActive: (editor) => editor.isActive("heading", { level: 6 }),
},
{
name: "To-do List",
icon: CheckSquare,
command: (editor) =>
editor.chain().focus().clearNodes().toggleTaskList().run(),
isActive: (editor) => editor.isActive("taskItem"),
},
{
name: "Bullet List",
icon: ListOrdered,
command: (editor) =>
editor.chain().focus().clearNodes().toggleBulletList().run(),
isActive: (editor) => editor.isActive("bulletList"),
},
{
name: "Numbered List",
icon: ListOrdered,
command: (editor) =>
editor.chain().focus().clearNodes().toggleOrderedList().run(),
isActive: (editor) => editor.isActive("orderedList"),
},
{
name: "Quote",
icon: QuoteIcon,
command: (editor) =>
editor.chain().focus().clearNodes().toggleBlockquote().run(),
isActive: (editor) => editor.isActive("blockquote"),
},
{
name: "Seperator",
icon: SeparatorHorizontalIcon,
command: (editor) => editor.chain().focus().setHorizontalRule().run(),
isActive: (editor) => editor.isActive("horizontalRule"),
},
];

View File

@ -0,0 +1,41 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { EditorBubbleItem, useEditor } from "novel";
import { selectionItems } from "./selection-items";
const items = selectionItems.filter((item) => item.inline);
export const TextButtons = () => {
const { editor } = useEditor();
if (!editor) return null;
editor.on("selectionUpdate", () => {
editor.view.dispatch(editor.state.tr);
});
return (
<div className="flex">
{items.map((item) => (
<EditorBubbleItem
key={item.name}
onSelect={(editor) => {
item.command(editor);
}}
>
<Button
size="sm"
className="rounded-none"
variant="ghost"
type="button"
>
<item.icon
className={cn(
"h-4 w-4",
item.isActive(editor) && "text-blue-500",
)}
/>
</Button>
</EditorBubbleItem>
))}
</div>
);
};

View File

@ -0,0 +1,175 @@
/* Basic editor styles */
.tiptap > *:first-child {
margin-top: 0;
}
.ProseMirror {
min-height: 100vh;
width: 100%;
}
.ProseMirror:focus {
outline: none;
}
.tiptap ul li p,
.tiptap ol li p {
margin: 0.75rem 0;
}
.tiptap ul,
.tiptap ol {
margin-left: 1rem;
padding: 0.25rem;
}
.tiptap ul {
list-style-type: disc;
}
.tiptap ol {
list-style-type: decimal;
}
/* Heading styles */
.tiptap h1,
.tiptap h2,
.tiptap h3,
.tiptap h4,
.tiptap h5,
.tiptap h6 {
margin: 0.5rem 0;
line-height: 1.1;
text-wrap: pretty;
}
.tiptap h1,
.tiptap h2 {
margin: 1rem 0;
font-size: 2.25rem; /* Equivalent to text-4xl */
}
.tiptap h3 {
font-size: 1.875rem; /* Equivalent to text-3xl */
}
.tiptap h4 {
font-size: 1.5rem; /* Equivalent to text-2xl */
}
.tiptap h5 {
font-size: 1.25rem; /* Equivalent to text-xl */
}
.tiptap h6 {
font-size: 1.125rem; /* Equivalent to text-lg */
}
.tiptap blockquote {
@apply m-4 border-l-4 border-border pl-4;
/* border-left: 1rem solid var(--primary);
padding-left: 1rem; */
}
.tiptap hr {
margin: 0.25rem 0;
}
::selection {
background-color: #5abbf7;
border-radius: 0.375rem;
}
.menu-bar button {
transition-duration: 0s;
}
.menu-bar .is-active {
border-color: var(--primary-color);
}
/* Task List */
ul[data-type="taskList"] li > label input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
margin: 0;
cursor: pointer;
width: 1.2em;
height: 1.2em;
position: relative;
top: 5px;
border: 2px solid var(--border);
margin-right: 0.3rem;
display: grid;
border-radius: 0.25rem;
place-content: center;
}
/* ul[data-type="taskList"] li > label input[type="checkbox"]:hover {
background-color: #000;
}
ul[data-type="taskList"] li > label input[type="checkbox"]:active {
background-color: #000;
} */
ul[data-type="taskList"] li > label input[type="checkbox"]::before {
content: "";
width: 0.65em;
height: 0.65em;
transform: scale(0);
transition: 120ms transform ease-in-out;
box-shadow: inset 1em 1em;
transform-origin: center;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
ul[data-type="taskList"] li > label input[type="checkbox"]:checked::before {
transform: scale(1);
}
ul[data-type="taskList"] li[data-checked="true"] > div > p {
color: var(--muted-foreground);
text-decoration: line-through;
text-decoration-thickness: 2px;
}
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
outline: none !important;
background-color: var(--novel-highlight-blue);
transition: background-color 0.2s;
box-shadow: none;
}
.drag-handle {
position: fixed;
opacity: 1;
transition: opacity ease-in 0.2s;
border-radius: 0.25rem;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(0, 0, 0, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem);
background-repeat: no-repeat;
background-position: center;
width: 1.2rem;
height: 1.5rem;
z-index: 50;
cursor: grab;
}
.drag-handle:hover {
background-color: var(--novel-stone-100);
transition: background-color 0.2s;
}
.drag-handle:active {
background-color: var(--novel-stone-200);
transition: background-color 0.2s;
cursor: grabbing;
}
.drag-handle.hide {
opacity: 0;
pointer-events: none;
}
@media screen and (max-width: 600px) {
.drag-handle {
display: none;
pointer-events: none;
}
}
.dark .drag-handle {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(255, 255, 255, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
}

View File

@ -0,0 +1,43 @@
"use client";
import { cn, debounce } from "@/lib/utils";
import React from "react";
import { Input } from "./ui/input";
function GlobalSearchWidget({
className,
onDebouncedSearch,
}: {
className?: string;
onDebouncedSearch: (query: string) => void;
}) {
const [query, setQuery] = React.useState("");
const debouncedSearch = React.useMemo(
() =>
debounce((q: string) => {
onDebouncedSearch(q);
}, 300),
[],
);
return (
<div
className={cn(
"flex min-h-20 w-full items-center justify-center rounded-md bg-background p-4",
className,
)}
>
<Input
className="shadow-none"
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
debouncedSearch(e.currentTarget.value);
}}
placeholder="Suche Artikel oder Kategorien..."
/>
</div>
);
}
export default GlobalSearchWidget;

25
src/components/icons.tsx Normal file
View File

@ -0,0 +1,25 @@
import { LucideProps } from "lucide-react";
export const Icons = {
logo(props: LucideProps) {
return (
<svg
width="70"
height="40"
viewBox="0 0 70 40"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M37.2551 1.61586C38.1803 0.653384 39.4368 0.112671 40.7452 0.112671C46.6318 0.112671 52.1793 0.112674 57.6424 0.112685C68.6302 0.112708 74.1324 13.9329 66.3629 22.0156L49.4389 39.6217C48.662 40.43 47.3335 39.8575 47.3335 38.7144V23.2076L49.2893 21.1729C50.8432 19.5564 49.7427 16.7923 47.5451 16.7923H22.6667L37.2551 1.61586Z"
fill="currentColor"
/>
<path
d="M32.7449 38.3842C31.8198 39.3467 30.5633 39.8874 29.2549 39.8874C23.3683 39.8874 17.8208 39.8874 12.3577 39.8874C1.36983 39.8873 -4.13236 26.0672 3.63721 17.9844L20.5612 0.378369C21.3381 -0.429908 22.6666 0.142547 22.6666 1.28562L22.6667 16.7923L20.7108 18.8271C19.1569 20.4437 20.2574 23.2077 22.455 23.2077L47.3335 23.2076L32.7449 38.3842Z"
fill="currentColor"
/>
</svg>
);
},
};

View File

@ -0,0 +1,66 @@
"use client";
import { uploadFile } from "@/server/actions/image";
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { formatFileSize } from "@/lib/utils";
export default function ImageUploadButton({
onUploaded,
}: {
onUploaded: (url: string) => void;
}) {
const [image, setImage] = useState<File | undefined>(undefined);
const [loading, setLoading] = useState(false);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 1024 * 1024 * 2) return toast.error("Datei ist zu groß.");
if (file.type.startsWith("image/")) {
return toast.error("Nur Bilder können hochgeladen werden.");
}
setImage(file);
};
const handleUpload = async () => {
if (!image) return;
setLoading(true);
const formData = new FormData();
formData.append("file", image); // Append file with a field name
try {
const url = await uploadFile(formData);
if (typeof url !== "string") toast.error("Etwas ist fehlgeschlagen");
else {
onUploaded(url);
toast.success("Upload erfolgreich");
}
} catch (e) {
console.error("Fehler aufgetreten: " + e);
}
setLoading(false);
};
return (
<div className="flex max-w-lg items-center gap-4">
<Button
disabled={loading || !image}
onClick={(e) => {
e.preventDefault();
handleUpload();
}}
>
{loading ? "laden..." : "Bild Hochladen"}
</Button>
<Input
id="file"
type="file"
accept="image/*"
onChange={handleFileChange}
/>
{image && <p className="w-full">{formatFileSize(image.size)}</p>}
</div>
);
}

View File

@ -11,7 +11,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import CreateArticleDialog from "@/components/article/create-article-dialog";
import CreateCategoryDialog from "@/app/(PAGES)/_components/category/create-category-dialog";
import CreateCategoryDialog from "@/components/category/create-category-dialog";
import { appRoutes } from "@/config";
async function Navbar() {

View File

@ -10,7 +10,6 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
import Link from "next/link";
@ -26,7 +25,7 @@ import {
import { appRoutes } from "@/config";
import SidebarLink from "./sidebar-link";
export async function AppSidebar({
export async function WikiSidebar({
...props
}: React.ComponentProps<typeof Sidebar>) {
const sidebarContent = await api.app.getSidebarContent();
@ -49,7 +48,9 @@ export async function AppSidebar({
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton>
{category.name}{" "}
<Link href={appRoutes.category(category.slug)}>
<span>{category.name}</span>
</Link>
<Plus className="ml-auto group-data-[state=open]/collapsible:hidden" />
<Minus className="ml-auto group-data-[state=closed]/collapsible:hidden" />
</SidebarMenuButton>

View File

@ -1,92 +0,0 @@
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>
);
};

View File

@ -1,55 +0,0 @@
import { Color } from "@tiptap/extension-color";
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] }),
/* @ts-ignore */
TextStyle.configure({ types: [ListItem.name] }),
StarterKit.configure({
code: false,
codeBlock: false,
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
},
orderedList: {
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
},
}),
GlobalDragHandle,
Image,
DraggableBlocks.configure({
types: [
"paragraph",
"heading",
"blockquote",
"codeBlock",
"bulletList",
"orderedList",
],
}),
];

View File

@ -1,36 +0,0 @@
"use client";
import "./styles.css";
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,
readOnly,
}: {
editorProviderProps: EditorProviderProps;
readOnly?: boolean;
}) {
return (
<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;

View File

@ -1,212 +0,0 @@
import ColorPickerPopover from "@/components/color-picker/color-picker-popover";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { RGBAToHexA } from "@/lib/utils";
import { useCurrentEditor } from "@tiptap/react";
import {
BoldIcon,
CodeIcon,
CodeSquareIcon,
Heading2Icon,
Heading3Icon,
Heading4Icon,
Heading5Icon,
Heading6Icon,
ItalicIcon,
ListIcon,
ListOrderedIcon,
QuoteIcon,
RedoIcon,
SeparatorHorizontalIcon,
StrikethroughIcon,
TextIcon,
TypeIcon,
UndoIcon,
} from "lucide-react";
export const MenuBar = () => {
const { editor } = useCurrentEditor();
if (!editor) {
return null;
}
return (
<div className="menu-bar mb-4 flex items-center justify-between space-y-2">
{/* <div className="flex w-full items-center justify-between">
</div> */}
<div className="Button-group flex items-center gap-1">
<Button
size={"icon"}
variant={"outline"}
onClick={() => editor.chain().focus().toggleBold().run()}
disabled={!editor.can().chain().focus().toggleBold().run()}
className={editor.isActive("bold") ? "is-active" : ""}
>
<BoldIcon className="size-4" />
</Button>
<Button
size={"icon"}
variant={"outline"}
onClick={() => editor.chain().focus().toggleItalic().run()}
disabled={!editor.can().chain().focus().toggleItalic().run()}
className={
editor.isActive("italic") ? "bg-foreground text-background" : ""
}
>
<ItalicIcon className="size-4" />
</Button>
<Button
size={"icon"}
variant={"outline"}
onClick={() => editor.chain().focus().toggleStrike().run()}
disabled={!editor.can().chain().focus().toggleStrike().run()}
className={editor.isActive("strike") ? "is-active" : ""}
>
<StrikethroughIcon className="size-4" />
</Button>
{/* <Button
size={"icon"}
variant={"outline"}
onClick={() => editor.chain().focus().toggleCode().run()}
disabled={!editor.can().chain().focus().toggleCode().run()}
className={editor.isActive("code") ? "is-active" : ""}
>
<CodeIcon className="size-4" />
</Button> */}
<Button
variant={"outline"}
size={"icon"}
onClick={() => editor.chain().focus().setParagraph().run()}
className={editor.isActive("paragraph") ? "is-active" : ""}
>
<TypeIcon className="size-4" />
</Button>
<Button
variant={"outline"}
size={"icon"}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
className={
editor.isActive("heading", { level: 2 }) ? "is-active" : ""
}
>
<Heading2Icon className="size-4" />
</Button>
<Button
variant={"outline"}
size={"icon"}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
className={
editor.isActive("heading", { level: 3 }) ? "is-active" : ""
}
>
<Heading3Icon className="size-4" />
</Button>
<Button
variant={"outline"}
size={"icon"}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 4 }).run()
}
className={
editor.isActive("heading", { level: 4 }) ? "is-active" : ""
}
>
<Heading4Icon className="size-4" />
</Button>
<Button
variant={"outline"}
size={"icon"}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 5 }).run()
}
className={
editor.isActive("heading", { level: 5 }) ? "is-active" : ""
}
>
<Heading5Icon className="size-4" />
</Button>
<Button
variant={"outline"}
size={"icon"}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 6 }).run()
}
className={
editor.isActive("heading", { level: 6 }) ? "is-active" : ""
}
>
<Heading6Icon className="size-4" />
</Button>
<Button
variant={"outline"}
size={"icon"}
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={editor.isActive("bulletList") ? "is-active" : ""}
>
<ListIcon className="size-4" />
</Button>
<Button
variant={"outline"}
size={"icon"}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={editor.isActive("orderedList") ? "is-active" : ""}
>
<ListOrderedIcon className="size-4" />
</Button>
{/* <Button
variant={"outline"}
size={"icon"}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
className={editor.isActive("codeBlock") ? "is-active" : ""}
>
<CodeSquareIcon className="size-4" />
</Button> */}
<Button
variant={"outline"}
size={"icon"}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={editor.isActive("blockquote") ? "is-active" : ""}
>
<QuoteIcon className="size-4" />
</Button>
<Button
variant={"outline"}
size={"icon"}
onClick={() => editor.chain().focus().setHorizontalRule().run()}
>
<SeparatorHorizontalIcon className="size-4" />
</Button>
<ColorPickerPopover
// initialColor={editor.getAttributes("textStyle").color}
onInput={(color) => editor.chain().focus().setColor(color).run()}
/>
</div>
<div className="flex items-center gap-1">
<Button
variant={"outline"}
size={"icon"}
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().chain().focus().undo().run()}
>
<UndoIcon className="size-4" />
</Button>
<Button
variant={"outline"}
size={"icon"}
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().chain().focus().redo().run()}
>
<RedoIcon className="size-4" />
</Button>
</div>
</div>
);
};

View File

@ -1,34 +0,0 @@
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;

View File

@ -1,135 +0,0 @@
/* Basic editor styles */
.tiptap > *:first-child {
margin-top: 0;
}
.ProseMirror:focus {
outline: none;
}
/* List styles */
/* .tiptap ul,
.tiptap ol {
padding: 0 1rem;
margin: 1.25rem 1rem 1.25rem 0.4rem;
} */
.tiptap ul li p,
.tiptap ol li p {
@apply my-1;
}
.tiptap ul,
ol {
@apply ml-4 p-1;
}
.tiptap ul {
@apply list-disc;
}
.tiptap ol {
@apply list-decimal;
}
/* Heading styles */
.tiptap h1,
.tiptap h2,
.tiptap h3,
.tiptap h4,
.tiptap h5,
.tiptap h6 {
@apply my-2;
line-height: 1.1;
text-wrap: pretty;
}
.tiptap h1,
.tiptap h2 {
@apply my-4;
}
.tiptap h2 {
@apply text-4xl;
}
.tiptap h3 {
@apply text-3xl;
}
.tiptap h4 {
@apply text-2xl;
}
.tiptap h5 {
@apply text-xl;
}
.tiptap h6 {
@apply text-lg;
}
/* Code and preformatted text styles */
/* .tiptap code {
@apply bg-foreground p-1 text-background;
}
.tiptap pre {
background: var(--black);
border-radius: 0.5rem;
color: var(--white);
font-family: "JetBrainsMono", monospace;
margin: 1.5rem 0;
padding: 0.75rem 1rem;
}
.tiptap pre code {
background: none;
color: inherit;
font-size: 0.8rem;
padding: 0;
} */
/* Blockquote styles */
.tiptap blockquote {
@apply m-6 border-l-2 pl-4;
/* border-left: 3px solid var(--gray-3);
margin: 1.5rem 0;
padding-left: 1rem; */
}
/* Horizontal rule styles */
.tiptap hr {
@apply my-1;
}
::selection {
@apply rounded-md bg-muted-foreground/25;
}
.menu-bar button {
@apply duration-0;
}
.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;
}

View File

@ -0,0 +1,7 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
const AspectRatio = AspectRatioPrimitive.Root
export { AspectRatio }

View File

@ -1,6 +1,6 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
@ -9,13 +9,13 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
"rounded-lg border bg-card text-card-foreground shadow-none",
className,
)}
{...props}
/>
))
Card.displayName = "Card"
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLDivElement,
@ -38,8 +38,8 @@ const CardTitle = React.forwardRef<
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLDivElement,
@ -50,16 +50,16 @@ const CardDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
@ -70,7 +70,14 @@ const CardFooter = React.forwardRef<
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

View File

@ -0,0 +1,44 @@
"use client";
import React from "react";
export function useInfiniteItemsObserver({
bottomObserverRef,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
}: {
bottomObserverRef: React.RefObject<HTMLLIElement>;
fetchNextPage: () => void;
hasNextPage: boolean;
isFetchingNextPage: boolean;
}) {
const bottomObserver = React.useCallback(
(entries: IntersectionObserverEntry[]) => {
const [entry] = entries;
if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
[fetchNextPage, hasNextPage, isFetchingNextPage],
);
// Setup bottom observer
React.useEffect(() => {
const bottomObserverElement = bottomObserverRef.current;
// Bottom observer
const observerBottom = new IntersectionObserver(bottomObserver, {
root: null,
rootMargin: "100px",
threshold: 0.1,
});
if (bottomObserverElement) observerBottom.observe(bottomObserverElement);
return () => {
if (bottomObserverElement)
observerBottom.unobserve(bottomObserverElement);
};
}, [bottomObserver, bottomObserverRef]);
}

View File

@ -26,3 +26,9 @@ export function debounce<T extends (...args: any[]) => void>(
timeoutId = setTimeout(() => func(...args), delay);
};
}
export const formatFileSize = (size: number) => {
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`;
return `${(size / (1024 * 1024)).toFixed(2)} MB`;
};

View File

@ -2,4 +2,6 @@ import { z } from "zod";
export const categorySchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
image: z.string().optional(),
});

View File

@ -6,6 +6,7 @@ import {
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";
@ -16,16 +17,15 @@ export async function createArticle(
article,
});
if (!result[0]?.slug?.length) return false;
revalidatePath("/");
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,
article: { ...article, content: JSON.parse(article.content) },
articleId,
});
// if (!result[0]?.id?.length) return false;

View File

@ -16,12 +16,19 @@ export async function updateCategory(
category: z.infer<typeof categorySchema>,
categoryId: string,
) {
const result = await api.category.update({
category,
categoryId,
});
// if (!result[0]?.id?.length) return false;
// return revalidatePath(`/artikel/${result[0].id}/edit`);
try {
const result = await api.category.update({
category,
categoryId,
});
if (!result[0]?.id?.length) return false;
revalidatePath(`/artikel/${result[0].id}/edit`);
return true;
} catch (e) {
console.error(e);
return false;
}
}
export async function deleteCategory(categoryId: string) {
const result = await api.category.delete({

View File

@ -0,0 +1,17 @@
"use server";
import fs from "node:fs/promises";
import { revalidatePath } from "next/cache";
export async function uploadFile(formData: FormData) {
const file = formData.get("file") as File;
const arrayBuffer = await file.arrayBuffer();
const buffer = new Uint8Array(arrayBuffer);
const filename = `upload-${Date.now()}-${file.name}.png`;
try {
await fs.writeFile(`./public/uploads/${filename}`, buffer);
revalidatePath("/");
return `/uploads/${filename}`;
} catch (e) {
return false;
}
}

View File

@ -1,6 +1,29 @@
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 };
}),
getSidebarContent: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.query.categories.findMany({
with: {

View File

@ -5,17 +5,25 @@ import {
protectedProcedure,
publicProcedure,
} from "@/server/api/trpc";
import { articles } from "@/server/db/schema";
import { Article, articles } from "@/server/db/schema";
import {
articleSchema,
createArticleSchema,
} from "@/lib/validation/zod/article";
import { count, eq } from "drizzle-orm";
import { and, count, eq, gt, like, sql } from "drizzle-orm";
import { hasPermission, Role } from "@/lib/validation/permissions";
import { generateSlug } from "@/lib/utils";
export const articleRouter = createTRPCRouter({
// queries
search: publicProcedure
.input(z.object({ query: z.string() }))
.query(async ({ ctx, input }) => {
return (await ctx.db.query.articles.findMany({
where: like(articles.title, "%" + input.query + "%"),
with: { category: true },
})) as Article[];
}),
get: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ ctx, input }) => {
@ -24,23 +32,57 @@ export const articleRouter = createTRPCRouter({
with: { category: true },
});
}),
getAll: publicProcedure
.input(z.object({ categoryId: z.string() }).optional())
getByPage: publicProcedure
.input(
z.object({
categoryId: z.string().optional(),
limit: z.number().optional(),
cursor: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
return await ctx.db.query.articles.findMany({
where: input?.categoryId
? eq(articles.categoryId, input.categoryId)
: undefined,
const { categoryId, cursor } = input!;
const limit = input?.limit ?? 50;
const cursorArg = cursor ? gt(articles.slug, cursor) : undefined;
const categoryArg = categoryId
? eq(articles.categoryId, categoryId)
: undefined;
const items = await ctx.db.query.articles.findMany({
where: and(cursorArg, categoryArg),
limit: limit + 1,
orderBy: articles.slug,
columns: {
title: true,
slug: true,
createdAt: true,
},
});
let nextCursor: typeof cursor | undefined = undefined;
if (items.length > limit) {
const nextItem = items.pop();
nextCursor = nextItem!.slug;
}
return {
items,
nextCursor,
previousCursor: cursor,
};
}),
getAllPreviews: publicProcedure
.input(z.object({ categoryId: z.string() }).optional())
getAll: publicProcedure
.input(
z
.object({
categoryId: z.string().optional(),
limit: z.number().optional(),
})
.optional(),
)
.query(async ({ ctx, input }) => {
return await ctx.db.query.articles.findMany({
where: input?.categoryId
? eq(articles.categoryId, input.categoryId)
: undefined,
limit: input?.limit,
columns: {
title: true,
slug: true,
@ -48,6 +90,7 @@ export const articleRouter = createTRPCRouter({
},
});
}),
getCount: publicProcedure
.input(z.object({ categoryId: z.string() }).optional())
.query(async ({ ctx, input }) => {
@ -76,6 +119,12 @@ export const articleRouter = createTRPCRouter({
.values({ ...input.article, slug })
.returning({
slug: articles.slug,
})
.onConflictDoUpdate({
target: articles.slug,
set: {
slug: sql`${slug} || '-' || (SELECT COUNT(*) FROM ${articles} WHERE slug LIKE ${slug + "-%"})`,
},
});
}),
update: protectedProcedure

View File

@ -4,25 +4,43 @@ import {
protectedProcedure,
publicProcedure,
} from "@/server/api/trpc";
import { categories } from "@/server/db/schema";
import { categories, Category } from "@/server/db/schema";
import { count, eq } from "drizzle-orm";
import { count, eq, like } from "drizzle-orm";
import { hasPermission, Role } from "@/lib/validation/permissions";
import { categorySchema } from "@/lib/validation/zod/category";
import { generateSlug } from "@/lib/utils";
export const categoryRouter = createTRPCRouter({
get: publicProcedure
.input(z.object({ slug: z.string() }))
search: publicProcedure
.input(z.object({ query: z.string() }))
.query(async ({ ctx, input }) => {
return await ctx.db.query.categories.findFirst({
where: eq(categories.slug, input.slug),
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 }) => {
return (await ctx.db.query.categories.findFirst({
where: eq(categories.slug, input.slug),
with: input?.with,
})) as Category;
}),
getAll: publicProcedure.query(async ({ ctx }) => {
return await ctx.db.query.categories.findMany();
}),
getAll: publicProcedure
.input(
z
.object({
limit: z.number().optional(),
})
.optional(),
)
.query(async ({ ctx, input }) => {
return await ctx.db.query.categories.findMany({
limit: input?.limit,
});
}),
getCount: publicProcedure.query(async ({ ctx }) => {
return (await ctx.db.select({ count: count() }).from(categories))[0]?.count;

View File

@ -16,3 +16,5 @@ const conn = globalForDb.conn ?? postgres(env.DATABASE_URL);
if (env.NODE_ENV !== "production") globalForDb.conn = conn;
export const db = drizzle(conn, { schema });
export type DBType = typeof db;

View File

@ -1,6 +1,7 @@
import { createId } from "@paralleldrive/cuid2";
import { relations, sql } from "drizzle-orm";
import { relations, SQL, sql } from "drizzle-orm";
import {
AnyPgColumn,
boolean,
index,
integer,
@ -11,15 +12,14 @@ import {
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import { User } from "next-auth";
import { type AdapterAccount } from "next-auth/adapters";
import { type JSONContent } from "@tiptap/react";
import { JSONContent } from "novel";
export function lower(value: AnyPgColumn): SQL {
return sql`lower(${value})`;
}
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
*
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
*/
export const createTable = pgTableCreator((name) => `wiki-antifa_${name}`);
export const articles = createTable(
@ -32,7 +32,7 @@ export const articles = createTable(
title: varchar("title", { length: 256 }),
slug: varchar("slug", { length: 256 }).unique().notNull(),
authorId: varchar("author_id", { length: 255 }),
content: text("content"),
content: jsonb("content").$type<JSONContent>(),
categoryId: varchar("category_id", { length: 255 }),
published: boolean("published").default(false),
createdAt: timestamp("created_at", { withTimezone: true })
@ -46,12 +46,19 @@ export const articles = createTable(
articleTitleIndex: index("article_title_idx").on(example.title),
}),
);
export type Article = typeof articles.$inferSelect;
export type Article = typeof articles.$inferSelect & {
author?: User;
category?: Category;
};
export const articleRelations = relations(articles, ({ one }) => ({
category: one(categories, {
fields: [articles.categoryId],
references: [categories.id],
}),
author: one(users, {
fields: [articles.authorId],
references: [users.id],
}),
}));
export const categories = createTable(
@ -62,8 +69,10 @@ export const categories = createTable(
.$defaultFn(() => createId())
.notNull(),
name: varchar("name", { length: 256 }),
description: text("description"),
slug: varchar("slug", { length: 256 }).unique().notNull(),
image: varchar("image", { length: 255 }),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
@ -75,7 +84,9 @@ export const categories = createTable(
categoryNameIndex: index("category_name_idx").on(example.name),
}),
);
export type Category = typeof categories.$inferSelect;
export type Category = typeof categories.$inferSelect & {
articles?: Article[];
};
export const categoryRelations = relations(categories, ({ many }) => ({
articles: many(articles),

View File

@ -57,7 +57,7 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
},
}),
],
})
}),
);
return (

View File

@ -9,7 +9,7 @@
# On Linux and macOS you can run this script directly - `./start-database.sh`
DB_CONTAINER_NAME="wiki-antifa-postgres"
DB_CONTAINER_NAME="logipedia-postgres"
if ! [ -x "$(command -v docker)" ]; then
echo -e "Docker is not installed. Please install docker and try again.\nDocker install guide: https://docs.docker.com/engine/install/"
@ -55,6 +55,6 @@ docker run -d \
--name $DB_CONTAINER_NAME \
-e POSTGRES_USER="postgres" \
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
-e POSTGRES_DB=wiki-antifa \
-e POSTGRES_DB=logipedia \
-p "$DB_PORT":5432 \
docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"