This commit is contained in:
Janis
2023-01-22 17:04:42 +01:00
parent bead72cde7
commit 2c0207dc65
21 changed files with 338 additions and 93 deletions

View File

@@ -10,6 +10,20 @@ import remarkGemoji from "remark-gemoji";
import remarkStringify from "remark-stringify";
import { useState, useEffect } from "react";
import { useLocalStorage } from "usehooks-ts";
import React from "react";
import Head from "./head";
import { formatTextToUrlName } from "../utils";
function flatten(text, child) {
return typeof child === "string" ? text + child : React.Children.toArray(child.props.children).reduce(flatten, text);
}
function HeadingRenderer({ children, level }) {
children = React.Children.toArray(children);
const text = children.reduce(flatten, "");
return React.createElement("h" + level, { id: formatTextToUrlName(text) }, children);
}
export default function Markdown({ value }: { value: any }) {
return (
<div>
@@ -18,6 +32,12 @@ export default function Markdown({ value }: { value: any }) {
//@ts-ignore
remarkPlugins={[remarkGfm, remarkGemoji, remarkStringify]}
components={{
h1: HeadingRenderer,
h2: HeadingRenderer,
h3: HeadingRenderer,
h4: HeadingRenderer,
h5: HeadingRenderer,
h6: HeadingRenderer,
code({ node, inline, className, children, ...props }) {
const match = /language-(\w+)/.exec(className || "");
return !inline ? (

View File

@@ -6,39 +6,68 @@ import { useState, useRef, useEffect } from "react";
import styles from "../../../../styles/modules/AdminArticlesCreate.module.scss";
import { PostArticle } from "../../../../types/postData";
import Markdown from "../../../Markdown";
import { Category } from "@prisma/client";
import { Article, Category, Prisma } from "@prisma/client";
import "../../../../styles/inputs.scss";
import "../../../../styles/buttons.scss";
import Select, { GroupBase, OptionsOrGroups } from "react-select";
import { apiUrl } from "../../../global";
import urlJoin from "url-join";
import { getUrlSafeString } from "../../../utils";
import { formatTextToUrlName } from "../../../../utils";
import { isValidText } from "../../../../validators";
import { useRouter } from "next/navigation";
import ContentTable from "../../../articles/[categoryName]/[articleName]/ContentTable";
import { IContentTableEntry } from "../../../../types/contentTable";
type ArticleWithCategory = Prisma.ArticleGetPayload<{ include: { category: true } }>;
export default function AdminArticlesCreate() {
const [formData, setFormData] = useState<PostArticle>(null);
const router = useRouter();
const [title, setTitle] = useState<string>("");
const [markdown, setMarkdown] = useState<string>("");
const [selectCategoriesOptions, setSelectCategoriesOptions] = useState<any>([]);
const [introduction, setIntroduction] = useState<string>("");
const [markdown, setMarkdown] = useState<string>("");
const [contentTable, setContentTable] = useState<IContentTableEntry[]>([]);
const titleRef = useRef<HTMLInputElement>(null);
const categorySelectRef = useRef(null);
const introductionRef = useRef<HTMLInputElement>(null);
const markdownTextAreaRef = useRef<HTMLTextAreaElement>(null);
const errorTextRef = useRef(null);
function changeContentTableEntryAnchor(index: number, newAnchor: string) {
setContentTable((prevArray) => {
let newArray = [...prevArray];
newArray[index].anchor = newAnchor;
return newArray;
});
}
function changeContentTableEntryTitle(index: number, newTitle: string) {
setContentTable((prevArray) => {
let newArray = [...prevArray];
newArray[index].anchor = newTitle;
return newArray;
});
}
function handleFormChange() {
setMarkdown(markdownTextAreaRef.current.value);
setTitle(titleRef.current.value);
setFormData({
name: getUrlSafeString(titleRef.current.value),
setIntroduction(introductionRef.current.value);
}
async function postData() {
const formData: PostArticle = {
title: titleRef.current.value,
introduction: introductionRef.current.value,
markdown: markdown,
categoryId: Number(categorySelectRef?.current?.getValue()[0]?.value),
});
}
async function postData() {
contentTable: contentTable,
};
console.log(formData);
await fetch("/api/articles/create", {
const result = await fetch("/api/articles/create", {
method: "POST",
headers: {
Accept: "application/json",
@@ -46,6 +75,14 @@ export default function AdminArticlesCreate() {
},
body: JSON.stringify(formData),
});
const response = await result.json();
console.log(response);
errorTextRef.current.innerText = response.error ?? "";
if (response.success) {
const newArticle: ArticleWithCategory = response.data;
router.push(urlJoin(`/articles/`, newArticle.category.name, newArticle.name));
}
}
useEffect(() => {
@@ -54,7 +91,6 @@ export default function AdminArticlesCreate() {
cache: "no-cache",
next: { revalidate: 60 * 1 },
});
console.log();
const categories = await result.json();
let newSelectCategoriesOptions = [];
@@ -72,17 +108,19 @@ export default function AdminArticlesCreate() {
return (
<div className={styles.adminArticlesCreate}>
<h1>Create a new article</h1>
<button
type="button"
onClick={() => {
postData();
}}
>
send
</button>
<div className={styles.form}>
<div className={styles.contentTableEditor}>contenttable</div>
<div className={styles.formControl}>
<p className="text-error" ref={errorTextRef}></p>
<button
type="button"
onClick={() => {
postData();
}}
>
Create Article
</button>
</div>
<div className={styles.form}>
<div className={styles.articleEditor}>
<div className={styles.title}>
<label htmlFor="title">Title</label>
@@ -90,7 +128,7 @@ export default function AdminArticlesCreate() {
<div className={styles.titleInputs}>
<input
onChange={handleFormChange}
className={""}
className={!isValidText(title) && title ? "error" : ""}
type="text"
name="title"
placeholder="title"
@@ -102,7 +140,7 @@ export default function AdminArticlesCreate() {
className={""}
type="text"
name="name"
value={getUrlSafeString(title)}
value={title ? formatTextToUrlName(title) : ""}
/>
</div>
</div>
@@ -117,11 +155,10 @@ export default function AdminArticlesCreate() {
/>
</div>
<div className={styles.introduction}>
{" "}
<label htmlFor="title">Introduction</label>
<input
onChange={handleFormChange}
className={""}
className={!isValidText(introduction) && introduction ? "error" : ""}
type="text"
name="introduction"
placeholder="Introduction"
@@ -135,6 +172,43 @@ export default function AdminArticlesCreate() {
<Markdown value={markdown} />
</div>
</div>
<div className={styles.contentTable}>
<label htmlFor="">Table of contents</label>
<div className={styles.contentTableEditor}>
<div className={styles.entries}>
{contentTable.map((entry: IContentTableEntry, i: number) => {
return (
<div key={i}>
<input
onChange={(e) => {
changeContentTableEntryAnchor(i, e.target.value);
}}
type="text"
placeholder={"Anchor"}
/>
<input
onChange={(e) => {
changeContentTableEntryTitle(i, e.target.value);
}}
type="text"
placeholder={"Title"}
/>
</div>
);
})}
<button
onClick={() => {
setContentTable([...contentTable, { title: "Title", anchor: "Anchor" }]);
}}
>
Add
</button>
</div>
<Markdown value={markdown} />
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,14 +1,15 @@
import React from "react";
import styles from "../../../../styles/modules/ArticleContentTable.module.scss";
import { Article, ContentTableEntry } from "@prisma/client";
import { Article } from "@prisma/client";
import { IContentTableEntry } from "../../../../types/contentTable";
export default function ContentTable({ contentTableEntries }: { contentTableEntries: ContentTableEntry[] }) {
export default function ContentTable({ contentTableData }: { contentTableData: any }) {
return (
<div className={styles.articleContentTable}>
<div className={styles.stickyContainer}>
<div className={styles.list}>
<h2>Contents</h2>
{contentTableEntries?.map((e, i) => {
{contentTableData?.map((e, i) => {
return (
<a key={i} href={"#" + e.anchor}>
{e.title}
@@ -16,7 +17,7 @@ export default function ContentTable({ contentTableEntries }: { contentTableEntr
);
})}
</div>
{contentTableEntries?.length < 15 ? <div className={styles.adContainer}>Future advertisement</div> : ""}
{contentTableData?.length < 15 ? <div className={styles.adContainer}>Future advertisement</div> : ""}
</div>
</div>
);

View File

@@ -2,16 +2,15 @@ import { marked } from "marked";
import ContentTable from "./ContentTable";
import Sidebar from "./Sidebar";
import styles from "../../../../styles/modules/Article.module.scss";
import { Article, Category, ContentTableEntry } from "@prisma/client";
import Image from "next/image";
import urlJoin from "url-join";
import { apiUrl } from "../../../global";
import { Prisma } from "@prisma/client";
import Markdown from "../../../Markdown";
import { IContentTableEntry } from "../../../../types/contentTable";
type ArticleWithIncludes = Prisma.ArticleGetPayload<{
include: { contentTableEntries: true; category: true; image: true };
include: { category: true; image: true };
}>;
export async function GetArticle(articleName: string): Promise<any> {
@@ -35,7 +34,7 @@ export default async function ArticlePage({ params }: { params: { articleName: s
return (
<div className={styles.article}>
<ContentTable contentTableEntries={article.contentTableEntries} />
<ContentTable contentTableData={article.contentTable ? article.contentTable : []} />
<div className={styles.tutorialContent}>
<div className={styles.header}>
<p className={`${styles.dates} text-muted`}>
@@ -78,7 +77,7 @@ export default async function ArticlePage({ params }: { params: { articleName: s
export async function generateStaticParams() {
const articles: ArticleWithIncludes[] = await (
await fetch(urlJoin(apiUrl, `articles/`), {
cache: "force-cache",
cache: "no-cache",
next: { revalidate: 60 * 10 },
})
).json();

View File

@@ -1,3 +0,0 @@
export function getUrlSafeString(value: string): string {
return encodeURIComponent(value.toLowerCase().replace(/[^a-z0-9 _-]+/gi, "-"));
}

View File

@@ -1,26 +1,22 @@
import { Request, Response } from "express";
import prisma from "../../../lib/prisma";
import { Prisma, ContentTableEntry } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { ResponseError } from "../../../types/responseErrors";
import { getUrlSafeString } from "../../../app/utils";
import { formatTextToUrlName } from "../../../utils";
type ArticleWithIncludes = Prisma.ArticleGetPayload<{ include: { contentTableEntries: true, category: true, image: true } }>
function sortContentTableEntries(entries: ContentTableEntry[]): ContentTableEntry[] {
return entries.sort((a, b) => a.orderIndex - b.orderIndex);
}
export default async function handler(req: Request, res: Response) {
res.setHeader("Content-Type", "application/json");
const articleName: string = getUrlSafeString(req.query.articleName.toString())
console.log(articleName)
const articleName: string = formatTextToUrlName(req.query.articleName.toString())
await prisma.article
.findUnique({ where: { name: articleName }, include: { category: true, contentTableEntries: true, image: true } })
.findUnique({ where: { name: articleName }, include: { category: true, image: true } })
.then((result: ArticleWithIncludes) => {
if (result !== null) {
result.contentTableEntries = sortContentTableEntries(result.contentTableEntries);
res.end(JSON.stringify(result));
} else {
const error: ResponseError = {

View File

@@ -3,10 +3,46 @@ import prisma from "../../../lib/prisma";
import { Prisma } from "@prisma/client";
import { Article, Category } from "@prisma/client";
import { ResponseError } from "../../../types/responseErrors";
import { PostArticle } from "../../../types/postData";
import { isValidText } from "../../../validators";
import { formatTextToUrlName } from "../../../utils";
import { json } from "stream/consumers";
export default async function handler(req: Request, res: Response) {
res.setHeader("Content-Type", "application/json");
await prisma.article.create({ data: req.body });
console.log();
const postData: any = req.body;
console.log(postData);
if (!isValidText(postData.title)) {
res.send(JSON.stringify({ target: "title", error: "Not a valid title" }));
return;
}
if (!isValidText(postData.introduction)) {
res.send(JSON.stringify({ target: "introduction", error: "Not a valid introduction" }));
return;
}
if (!postData.categoryId) {
res.send(JSON.stringify({ target: "category", error: "Category is required" }));
return;
}
postData.name = formatTextToUrlName(postData.title);
prisma.article
.create({ data: postData, include: { category: true } })
.then(
(data) => {
res.send(JSON.stringify({ success: true, data: data }));
},
(errorReason) => {
if (errorReason.code === "P2002") {
res.send(JSON.stringify({ target: errorReason.meta.target[0], error: "Already exists" }));
}
}
)
.catch((err) => {
console.error(err);
res.sendStatus(500).end();
});
}

View File

@@ -27,7 +27,7 @@ export default async function handler(req: Request, res: Response) {
await prisma.article
.findMany({
where: { category: categoryName.length > 0 ? category : undefined },
include: { category: true, contentTableEntries: true },
include: { category: true },
take: limit,
orderBy: orderByObj
})

View File

@@ -0,0 +1,40 @@
/*
Warnings:
- You are about to drop the column `typeId` on the `Article` table. All the data in the column will be lost.
- You are about to drop the `ArticleType` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `ContentTableEntry` table. If the table is not empty, all the data it contains will be lost.
- Added the required column `contentTable` to the `Article` table without a default value. This is not possible if the table is not empty.
- Made the column `categoryId` on table `Article` required. This step will fail if there are existing NULL values in that column.
*/
-- DropForeignKey
ALTER TABLE "Article" DROP CONSTRAINT "Article_categoryId_fkey";
-- DropForeignKey
ALTER TABLE "Article" DROP CONSTRAINT "Article_typeId_fkey";
-- DropForeignKey
ALTER TABLE "ContentTableEntry" DROP CONSTRAINT "ContentTableEntry_articleId_fkey";
-- AlterTable
ALTER TABLE "Article" DROP COLUMN "typeId",
ADD COLUMN "contentTable" JSONB NOT NULL,
ADD COLUMN "imageId" INTEGER,
ADD COLUMN "introduction" TEXT NOT NULL DEFAULT '',
ALTER COLUMN "categoryId" SET NOT NULL;
-- AlterTable
ALTER TABLE "Image" ADD COLUMN "alt" TEXT NOT NULL DEFAULT '';
-- DropTable
DROP TABLE "ArticleType";
-- DropTable
DROP TABLE "ContentTableEntry";
-- AddForeignKey
ALTER TABLE "Article" ADD CONSTRAINT "Article_imageId_fkey" FOREIGN KEY ("imageId") REFERENCES "Image"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Article" ADD CONSTRAINT "Article_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Article" ALTER COLUMN "contentTable" DROP NOT NULL;

View File

@@ -8,29 +8,18 @@ datasource db {
}
model Article {
id Int @id @default(autoincrement())
name String @unique
title String @unique
introduction String @default("")
imageId Int?
image Image? @relation(fields: [imageId], references: [id])
markdown String
contentTableEntries ContentTableEntry[]
categoryId Int
category Category @relation(fields: [categoryId], references: [id])
dateCreated DateTime @default(now())
dateUpdated DateTime @default(now())
}
model ContentTableEntry {
id Int @id @default(autoincrement())
title String
anchor String
orderIndex Int
articleId Int
article Article @relation(fields: [articleId], references: [id])
dateCreated DateTime @default(now())
dateUpdated DateTime @default(now())
id Int @id @default(autoincrement())
name String @unique
title String @unique
introduction String @default("")
imageId Int?
image Image? @relation(fields: [imageId], references: [id])
markdown String
contentTable Json?
categoryId Int
category Category @relation(fields: [categoryId], references: [id])
dateCreated DateTime @default(now())
dateUpdated DateTime @default(now())
}
model Category {

34
styles/buttons.scss Normal file
View File

@@ -0,0 +1,34 @@
button {
border: 2px solid rgba(95, 95, 95, 0.5);
background-color: transparent;
height: 30px;
padding: 5px 5px 5px 5px;
color: var(--color-font);
border-radius: 3px;
outline: none;
transition: all 50ms linear;
cursor: pointer;
&::placeholder {
font-weight: bold;
}
&:hover {
border-color: var(--color-accent);
}
&.error {
border-color: var(--color-error);
}
&.success {
border-color: var(--color-success);
}
&.warning {
border-color: var(--color-warning);
}
&.info {
border-color: var(--color-info);
}
}

View File

@@ -1,10 +1,22 @@
@import "../variables.scss";
.adminArticlesCreate {
& > h1 {
text-align: center;
}
.formControl {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 10px 30px;
}
.form {
display: grid;
display: flex;
flex-direction: column;
gap: 70px;
grid-template-columns: $tutorial-content-table-width minmax(0px, 1fr);
margin: 0px auto;
max-width: 1800px;
padding: 0px 24px;
@@ -42,17 +54,35 @@
gap: 10px 10px;
textarea {
color: var(--font-color);
border: 2px solid rgba(59, 59, 59, 0.434);
border: 2px solid #3b3b3b80;
background-color: transparent;
min-height: 700px;
resize: none;
display: block;
border-radius: 0px;
outline: 0;
resize: vertical;
resize: both;
font-family: inherit;
font-size: inherit;
}
& > div {
max-width: 1000px;
border: 2px solid #3b3b3b80;
}
}
}
.contentTable {
.contentTableEditor {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 10px;
& > div {
border: 2px solid #3b3b3b80;
max-width: 1000px;
}
}
}
}

View File

@@ -21,7 +21,7 @@
transition: all 50ms ease-in-out;
svg {
fill: #bdbdbd;
fill: #bdbdbd3a;
transition: all 50ms linear;
&:hover {
fill: var(--color-accent);

View File

@@ -70,6 +70,10 @@ a {
font-size: 0.8em;
}
.text-error {
font-weight: bold;
color: var(--color-error);
}
label {
font-weight: bold;
font-size: 0.9em;

View File

@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
@@ -25,13 +21,6 @@
}
]
},
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
]
"include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}

5
types/contentTable.ts Normal file
View File

@@ -0,0 +1,5 @@
export interface IContentTableEntry {
anchor: string;
title: string;
}

View File

@@ -1,8 +1,11 @@
import { IContentTableEntry } from "./contentTable";
export interface PostArticle {
name: string;
title: string;
name?: string;
markdown: string;
introduction: string;
categoryId: number;
contentTable: IContentTableEntry[]
imageId?: number;
}

16
utils.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { isValidName } from "./validators";
export function formatTextToUrlName(text: string): string {
text = text.toLowerCase();
let name = text;
name = name.replace(/[^a-z0-9\-_\s]+/gi, ""); // Replace all invalid characters (except spaces)
name = name.replace(/\s/g, "-"); // Replace spaces to -
// double check to be sure
if (isValidName(name)) {
return name;
} else {
console.error("formatTitleToName function not working");
return null;
}
}

10
validators.ts Normal file
View File

@@ -0,0 +1,10 @@
export function isValidText(text: string): boolean {
const textRegex = /^[a-zA-Z\s\d\-_\(\)\[\]\{\}\!\@\#\$\%\^\&\*\+\=\,\.\?\/\\\|\'\":;]+$/;
return textRegex.test(text) && text.length > 0;
}
export function isValidName(name: string): boolean {
const nameRegex = /^[a-zA-Z0-9\-_]+$/;
return nameRegex.test(name);
}