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 (
@@ -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