From 2c0207dc65ac6d32e794d7c097d3e5cebbefc60a Mon Sep 17 00:00:00 2001 From: Janis Date: Sun, 22 Jan 2023 17:04:42 +0100 Subject: [PATCH] asd --- app/Markdown.tsx | 20 +++ app/admin/articles/create/page.tsx | 126 ++++++++++++++---- .../[articleName]/ContentTable.tsx | 9 +- .../[categoryName]/[articleName]/page.tsx | 9 +- app/utils.tsx | 3 - pages/api/articles/[articleName].ts | 14 +- pages/api/articles/create.tsx | 40 +++++- pages/api/articles/index.ts | 2 +- .../20230122141410_contentable/migration.sql | 40 ++++++ .../20230122141436_contenttable/migration.sql | 2 + prisma/schema.prisma | 35 ++--- styles/buttons.scss | 34 +++++ .../modules/AdminArticlesCreate.module.scss | 38 +++++- styles/modules/markdown.module.scss | 2 +- styles/typography.scss | 4 + tsconfig.json | 17 +-- types/contentTable.ts | 5 + types/{postData.tsx => postData.ts} | 5 +- .../{responseErrors.tsx => responseErrors.ts} | 0 utils.tsx | 16 +++ validators.ts | 10 ++ 21 files changed, 338 insertions(+), 93 deletions(-) delete mode 100644 app/utils.tsx create mode 100644 prisma/migrations/20230122141410_contentable/migration.sql create mode 100644 prisma/migrations/20230122141436_contenttable/migration.sql create mode 100644 styles/buttons.scss create mode 100644 types/contentTable.ts rename types/{postData.tsx => postData.ts} (55%) rename types/{responseErrors.tsx => responseErrors.ts} (100%) create mode 100644 utils.tsx create mode 100644 validators.ts diff --git a/app/Markdown.tsx b/app/Markdown.tsx index e371589..cfb6967 100644 --- a/app/Markdown.tsx +++ b/app/Markdown.tsx @@ -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 (
@@ -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 ? ( diff --git a/app/admin/articles/create/page.tsx b/app/admin/articles/create/page.tsx index e9343bc..8e98100 100644 --- a/app/admin/articles/create/page.tsx +++ b/app/admin/articles/create/page.tsx @@ -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(null); + const router = useRouter(); + const [title, setTitle] = useState(""); - const [markdown, setMarkdown] = useState(""); const [selectCategoriesOptions, setSelectCategoriesOptions] = useState([]); + const [introduction, setIntroduction] = useState(""); + const [markdown, setMarkdown] = useState(""); + const [contentTable, setContentTable] = useState([]); const titleRef = useRef(null); const categorySelectRef = useRef(null); const introductionRef = useRef(null); const markdownTextAreaRef = useRef(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 (

Create a new article

- -
-
contenttable
+
+

+ +
+
@@ -90,7 +128,7 @@ export default function AdminArticlesCreate() {
@@ -117,11 +155,10 @@ export default function AdminArticlesCreate() { />
- {" "}
+ +
+ +
+
+ {contentTable.map((entry: IContentTableEntry, i: number) => { + return ( +
+ { + changeContentTableEntryAnchor(i, e.target.value); + }} + type="text" + placeholder={"Anchor"} + /> + { + changeContentTableEntryTitle(i, e.target.value); + }} + type="text" + placeholder={"Title"} + /> +
+ ); + })} + + +
+ +
+
diff --git a/app/articles/[categoryName]/[articleName]/ContentTable.tsx b/app/articles/[categoryName]/[articleName]/ContentTable.tsx index 2c55083..4947fc2 100644 --- a/app/articles/[categoryName]/[articleName]/ContentTable.tsx +++ b/app/articles/[categoryName]/[articleName]/ContentTable.tsx @@ -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 (

Contents

- {contentTableEntries?.map((e, i) => { + {contentTableData?.map((e, i) => { return ( {e.title} @@ -16,7 +17,7 @@ export default function ContentTable({ contentTableEntries }: { contentTableEntr ); })}
- {contentTableEntries?.length < 15 ?
Future advertisement
: ""} + {contentTableData?.length < 15 ?
Future advertisement
: ""}
); diff --git a/app/articles/[categoryName]/[articleName]/page.tsx b/app/articles/[categoryName]/[articleName]/page.tsx index 319eb33..7afaed4 100644 --- a/app/articles/[categoryName]/[articleName]/page.tsx +++ b/app/articles/[categoryName]/[articleName]/page.tsx @@ -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 { @@ -35,7 +34,7 @@ export default async function ArticlePage({ params }: { params: { articleName: s return (
- +

@@ -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(); diff --git a/app/utils.tsx b/app/utils.tsx deleted file mode 100644 index 66f1e84..0000000 --- a/app/utils.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function getUrlSafeString(value: string): string { - return encodeURIComponent(value.toLowerCase().replace(/[^a-z0-9 _-]+/gi, "-")); -} diff --git a/pages/api/articles/[articleName].ts b/pages/api/articles/[articleName].ts index e7a57cf..b541fbb 100644 --- a/pages/api/articles/[articleName].ts +++ b/pages/api/articles/[articleName].ts @@ -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 = { diff --git a/pages/api/articles/create.tsx b/pages/api/articles/create.tsx index fce1ab4..b1d2359 100644 --- a/pages/api/articles/create.tsx +++ b/pages/api/articles/create.tsx @@ -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(); + }); } diff --git a/pages/api/articles/index.ts b/pages/api/articles/index.ts index eb056ad..bb82a8f 100644 --- a/pages/api/articles/index.ts +++ b/pages/api/articles/index.ts @@ -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 }) diff --git a/prisma/migrations/20230122141410_contentable/migration.sql b/prisma/migrations/20230122141410_contentable/migration.sql new file mode 100644 index 0000000..ee4f741 --- /dev/null +++ b/prisma/migrations/20230122141410_contentable/migration.sql @@ -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; diff --git a/prisma/migrations/20230122141436_contenttable/migration.sql b/prisma/migrations/20230122141436_contenttable/migration.sql new file mode 100644 index 0000000..eecba73 --- /dev/null +++ b/prisma/migrations/20230122141436_contenttable/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Article" ALTER COLUMN "contentTable" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b1b7e5f..25c70f6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/styles/buttons.scss b/styles/buttons.scss new file mode 100644 index 0000000..4ed7714 --- /dev/null +++ b/styles/buttons.scss @@ -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); + } +} diff --git a/styles/modules/AdminArticlesCreate.module.scss b/styles/modules/AdminArticlesCreate.module.scss index e810959..79b027d 100644 --- a/styles/modules/AdminArticlesCreate.module.scss +++ b/styles/modules/AdminArticlesCreate.module.scss @@ -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; + } } } } diff --git a/styles/modules/markdown.module.scss b/styles/modules/markdown.module.scss index 6618a74..248f08e 100644 --- a/styles/modules/markdown.module.scss +++ b/styles/modules/markdown.module.scss @@ -21,7 +21,7 @@ transition: all 50ms ease-in-out; svg { - fill: #bdbdbd; + fill: #bdbdbd3a; transition: all 50ms linear; &:hover { fill: var(--color-accent); diff --git a/styles/typography.scss b/styles/typography.scss index caf8779..4da92ba 100644 --- a/styles/typography.scss +++ b/styles/typography.scss @@ -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; diff --git a/tsconfig.json b/tsconfig.json index b72635b..58edb3f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] } diff --git a/types/contentTable.ts b/types/contentTable.ts new file mode 100644 index 0000000..86be6e1 --- /dev/null +++ b/types/contentTable.ts @@ -0,0 +1,5 @@ + +export interface IContentTableEntry { + anchor: string; + title: string; +} \ No newline at end of file diff --git a/types/postData.tsx b/types/postData.ts similarity index 55% rename from types/postData.tsx rename to types/postData.ts index dd50c86..554a67c 100644 --- a/types/postData.tsx +++ b/types/postData.ts @@ -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; } diff --git a/types/responseErrors.tsx b/types/responseErrors.ts similarity index 100% rename from types/responseErrors.tsx rename to types/responseErrors.ts diff --git a/utils.tsx b/utils.tsx new file mode 100644 index 0000000..8686156 --- /dev/null +++ b/utils.tsx @@ -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; + } +} diff --git a/validators.ts b/validators.ts new file mode 100644 index 0000000..7d12d2d --- /dev/null +++ b/validators.ts @@ -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); +} \ No newline at end of file