From cb450a634516679c38c04e947d1f9f700872695f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Xalambr=C3=AD?= Date: Sun, 16 Jun 2024 01:25:18 -0500 Subject: [PATCH] Make CMS list of articles and editor like tutorials Use the same design in the list of articles and the editor as in the tutorials routes inside the CMS --- app/routes/_.cms.articles/article-list.tsx | 67 +++---- app/routes/_.cms.articles/queries.tsx | 131 +++---------- app/routes/_.cms.articles/route.tsx | 112 +++-------- app/routes/_.cms.articles/types.ts | 4 +- app/routes/cms.articles_.$postId/route.tsx | 43 ----- app/routes/cms.articles_.new/route.tsx | 30 --- app/routes/cms_.articles_.$postId/actions.tsx | 30 +++ .../cms_.articles_.$postId/controls.tsx | 60 ++++++ app/routes/cms_.articles_.$postId/editor.tsx | 26 +++ app/routes/cms_.articles_.$postId/queries.ts | 54 ++++++ .../cms_.articles_.$postId/quick-actions.tsx | 66 +++++++ app/routes/cms_.articles_.$postId/route.tsx | 180 ++++++++++++++++++ 12 files changed, 505 insertions(+), 298 deletions(-) delete mode 100644 app/routes/cms.articles_.$postId/route.tsx delete mode 100644 app/routes/cms.articles_.new/route.tsx create mode 100644 app/routes/cms_.articles_.$postId/actions.tsx create mode 100644 app/routes/cms_.articles_.$postId/controls.tsx create mode 100644 app/routes/cms_.articles_.$postId/editor.tsx create mode 100644 app/routes/cms_.articles_.$postId/queries.ts create mode 100644 app/routes/cms_.articles_.$postId/quick-actions.tsx create mode 100644 app/routes/cms_.articles_.$postId/route.tsx diff --git a/app/routes/_.cms.articles/article-list.tsx b/app/routes/_.cms.articles/article-list.tsx index 0f40e565..3da6a8cb 100644 --- a/app/routes/_.cms.articles/article-list.tsx +++ b/app/routes/_.cms.articles/article-list.tsx @@ -1,17 +1,22 @@ +import type { SerializeFrom } from "@remix-run/cloudflare"; +import type { UUID } from "~/utils/uuid"; import type { loader } from "./route"; -import { Link, useFetcher, useLoaderData } from "@remix-run/react"; -import { Button } from "react-aria-components"; +import { useFetcher, useLoaderData } from "@remix-run/react"; +import { useId } from "react"; import { Trans } from "react-i18next"; import { useT } from "~/helpers/use-i18n.hook"; +import { Button } from "~/ui/Button"; +import { Form } from "~/ui/Form"; +import { Link } from "~/ui/Link"; import { INTENT } from "./types"; -export function ArticleList() { +export function ArticlesList() { let { articles } = useLoaderData(); return ( -
    +
      {articles.map((article) => ( ))} @@ -19,27 +24,22 @@ export function ArticleList() { ); } -type ItemProps = { - id: string; - path: string; - title: string; - date: string; -}; +type ItemProps = SerializeFrom["articles"][number]; function Item(props: ItemProps) { let t = useT("cms.articles.list.item"); - let fetcher = useFetcher(); + let id = useId(); return (
    1. - -

      + +

      {props.title}

      -
      +
      -
      - - {t("edit")} - - - - - - + + +
    2. ); } + +function DeleteButton({ id }: { id: UUID }) { + let fetcher = useFetcher(); + + return ( + + + + + + ); +} diff --git a/app/routes/_.cms.articles/queries.tsx b/app/routes/_.cms.articles/queries.tsx index 61d85619..5cb29fc5 100644 --- a/app/routes/_.cms.articles/queries.tsx +++ b/app/routes/_.cms.articles/queries.tsx @@ -1,103 +1,46 @@ import type { AppLoadContext } from "@remix-run/cloudflare"; -import type { User } from "~/modules/session.server"; import type { UUID } from "~/utils/uuid"; -import { and, eq } from "drizzle-orm"; -import fm from "front-matter"; import { z } from "zod"; +import { and, eq } from "drizzle-orm"; import { Article } from "~/models/article.server"; import { Cache } from "~/modules/cache.server"; -import { Logger } from "~/modules/logger.server"; -import { Markdown } from "~/modules/md.server"; import { Redirects } from "~/modules/redirects.server"; -import { CollectedNotes } from "~/services/cn.server"; import { Tables, database } from "~/services/db.server"; -const AttributesSchema = z - .object({ - title: z.string(), - date: z.date(), - description: z.string(), - lang: z.string(), - tags: z.string(), - path: z.string(), - canonical_url: z.string().url(), - next: z.object({ - title: z.string(), - path: z.string(), - description: z.string(), - }), - translate_from: z.object({ - url: z.string().url(), - lang: z.string(), - title: z.string(), - }), - translated_to: z.object({ lang: z.string(), path: z.string() }).array(), - }) - .partial(); - -const FrontMatterSchema = z.object({ - attributes: AttributesSchema, - body: z.string(), -}); - -export async function importArticles( - context: AppLoadContext, - user: User, - page: number, -) { - let logger = new Logger(context); - - let cn = new CollectedNotes( - context.env.CN_EMAIL, - context.env.CN_TOKEN, - context.env.CN_SITE, - ); - - let articles = await cn.fetchNotes(page); +export const MarkdownSchema = z + .string() + .transform((content) => { + if (content.startsWith("# ")) { + let [title, ...body] = content.split("\n"); - let db = database(context.db); + let plain = body.join("\n").trimStart(); - for await (let article of articles) { - try { - let { body, attributes } = FrontMatterSchema.parse(fm(article.body)); - - body = stripTitle(body); - - let plainBody = await Markdown.plain(body); - - await Article.create( - { db }, - { - title: attributes.title ?? article.title, - slug: attributes.path ?? article.path, - locale: attributes.lang ?? "en", - content: body, - excerpt: extractExcerpt({ - body: plainBody.toString().replace("\n", " "), - headline: article.headline, - description: attributes.description, - }), - authorId: user.id, - createdAt: attributes.date ?? new Date(article.created_at), - updatedAt: new Date(article.updated_at), - canonical_url: attributes.canonical_url, - }, - ); - } catch (exception) { - if (exception instanceof Error) { - void logger.info( - `error importing ${article.title}: ${exception.message}`, - ); - } + return { + attributes: { title: title.slice(1).trim() }, + body: plain, + }; } - } -} -export async function resetArticles(context: AppLoadContext) { + let [title, ...body] = content.trim().split("\n"); + let plain = body.join("\n").trimStart(); + + return { + attributes: { title: title.slice(1).trim() }, + body: plain, + }; + }) + .pipe( + z.object({ + attributes: z.object({ title: z.string().min(1) }), + body: z.string(), + }), + ); + +export async function deleteArticle(context: AppLoadContext, id: UUID) { let db = database(context.db); - await db.delete(Tables.posts).where(eq(Tables.posts.type, "article")); + await Article.destroy({ db }, id); } export async function moveToTutorial(context: AppLoadContext, id: UUID) { @@ -139,21 +82,3 @@ export async function moveToTutorial(context: AppLoadContext, id: UUID) { `/tutorials/${slugMeta.value}`, ); } - -function stripTitle(body: string) { - if (!body.startsWith("# ")) return body; - let [, ...rest] = body.split("\n"); - return rest.join("\n").trim(); -} - -function extractExcerpt(input: { - body: string; - headline: string; - description?: string; -}) { - if (input.description) return input.description; - if (!input.headline.includes("title: \n")) { - return `${input.headline.slice(0, -3)}…`; - } - return `${input.body.slice(0, 139)}…`.replaceAll("\n", " "); -} diff --git a/app/routes/_.cms.articles/route.tsx b/app/routes/_.cms.articles/route.tsx index d7ffb6a9..e7bf0aed 100644 --- a/app/routes/_.cms.articles/route.tsx +++ b/app/routes/_.cms.articles/route.tsx @@ -4,20 +4,19 @@ import type { } from "@remix-run/cloudflare"; import { json, redirect } from "@remix-run/cloudflare"; -import { Link, useSubmit } from "@remix-run/react"; -import { Button, Form, Input, Label, NumberField } from "react-aria-components"; import { z } from "zod"; -import { useT } from "~/helpers/use-i18n.hook"; -import { Article } from "~/models/article.server"; import { I18n } from "~/modules/i18n.server"; import { Logger } from "~/modules/logger.server"; import { SessionStorage } from "~/modules/session.server"; import { database } from "~/services/db.server"; +import { Button } from "~/ui/Button"; +import { Form } from "~/ui/Form"; import { assertUUID } from "~/utils/uuid"; -import { ArticleList } from "./article-list"; -import { importArticles, moveToTutorial, resetArticles } from "./queries"; +import { Article } from "~/models/article.server"; +import { ArticlesList } from "./article-list"; +import { deleteArticle } from "./queries"; import { INTENT } from "./types"; export async function loader({ request, context }: LoaderFunctionArgs) { @@ -55,24 +54,21 @@ export async function action({ request, context }: ActionFunctionArgs) { if (user.role !== "admin") throw redirect("/"); let formData = await request.formData(); - let intent = z - .enum([INTENT.import, INTENT.reset, INTENT.moveToTutorial]) - .parse(formData.get("intent")); - - if (intent === INTENT.import) { - let page = z.coerce.number().parse(formData.get("page")); - await importArticles(context, user, page); + let intent = z.enum([INTENT.delete]).parse(formData.get("intent")); + + try { + if (intent === INTENT.delete) { + let id = formData.get("id"); + assertUUID(id); + await deleteArticle(context, id); + } + + throw redirect("/cms/articles"); + } catch (exception) { + if (exception instanceof Response) throw exception; + if (exception instanceof Error) console.error(exception); + throw redirect("/cms/articles"); } - - if (intent === INTENT.reset) await resetArticles(context); - - if (intent === INTENT.moveToTutorial) { - let id = formData.get("id"); - assertUUID(id); - await moveToTutorial(context, id); - } - - throw redirect("/cms/articles"); } export default function Component() { @@ -82,73 +78,15 @@ export default function Component() {

      Articles

      - - Write Article - - - - +
      + +
      - + ); } - -function ImportArticles() { - let submit = useSubmit(); - let t = useT("cms.articles.import"); - - return ( -
      { - event.preventDefault(); - submit(event.currentTarget); - }} - > - - - - - - -
      - ); -} - -function ResetArticles() { - let submit = useSubmit(); - let t = useT("cms.articles.reset"); - - return ( -
      { - event.preventDefault(); - submit(event.currentTarget); - }} - > - - -
      - ); -} diff --git a/app/routes/_.cms.articles/types.ts b/app/routes/_.cms.articles/types.ts index dbb5a6bc..8109f3a9 100644 --- a/app/routes/_.cms.articles/types.ts +++ b/app/routes/_.cms.articles/types.ts @@ -1,5 +1,3 @@ export const INTENT = { - import: "IMPORT_ARTICLES" as const, - reset: "RESET_ARTICLES" as const, - moveToTutorial: "MOVE_TO_TUTORIAL" as const, + delete: "DELETE_ARTICLE" as const, }; diff --git a/app/routes/cms.articles_.$postId/route.tsx b/app/routes/cms.articles_.$postId/route.tsx deleted file mode 100644 index 44897222..00000000 --- a/app/routes/cms.articles_.$postId/route.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { - DataFunctionArgs, - MetaDescriptor, - MetaFunction, -} from "@remix-run/cloudflare"; - -import { json } from "@remix-run/cloudflare"; -import { useLoaderData } from "@remix-run/react"; - -import { Article } from "~/models/article.server"; -import { I18n } from "~/modules/i18n.server"; -import { Editor } from "~/routes/components.editor/route"; -import { database } from "~/services/db.server"; -import { assertUUID } from "~/utils/uuid"; - -export const handle: SDX.Handle = { hydrate: true }; - -export async function loader({ request, params, context }: DataFunctionArgs) { - let t = await new I18n().getFixedT(request); - - let meta: MetaDescriptor[] = [{ title: t("write.title") }]; - - let postId = params.postId; - assertUUID(postId); - - let db = database(context.db); - - let article = await Article.findById({ db }, postId); - - return json({ meta, article: article.toJSON() }); -} - -export const meta: MetaFunction = ({ data }) => data?.meta ?? []; - -export default function Component() { - let loaderData = useLoaderData(); - - return ( -
      - -
      - ); -} diff --git a/app/routes/cms.articles_.new/route.tsx b/app/routes/cms.articles_.new/route.tsx deleted file mode 100644 index 9d3b2bc1..00000000 --- a/app/routes/cms.articles_.new/route.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import type { - LoaderFunctionArgs, - MetaDescriptor, - MetaFunction, -} from "@remix-run/cloudflare"; - -import { json } from "@remix-run/cloudflare"; - -import { I18n } from "~/modules/i18n.server"; -import { Editor } from "~/routes/components.editor/route"; - -export const handle: SDX.Handle = { hydrate: true }; - -export async function loader({ request }: LoaderFunctionArgs) { - let t = await new I18n().getFixedT(request); - - let meta: MetaDescriptor[] = [{ title: t("write.title") }]; - - return json({ meta }); -} - -export const meta: MetaFunction = ({ data }) => data?.meta ?? []; - -export default function Component() { - return ( -
      - -
      - ); -} diff --git a/app/routes/cms_.articles_.$postId/actions.tsx b/app/routes/cms_.articles_.$postId/actions.tsx new file mode 100644 index 00000000..6be02a22 --- /dev/null +++ b/app/routes/cms_.articles_.$postId/actions.tsx @@ -0,0 +1,30 @@ +import type { loader } from "./route"; + +import { useLoaderData } from "@remix-run/react"; +import { ArrowLeft } from "lucide-react"; + +import { Button } from "~/ui/Button"; +import { Link } from "~/ui/Link"; +import { Toolbar } from "~/ui/Toolbar"; + +export function Actions() { + let loaderData = useLoaderData(); + + return ( + + + + Go back + +
      + + + ); +} diff --git a/app/routes/cms_.articles_.$postId/controls.tsx b/app/routes/cms_.articles_.$postId/controls.tsx new file mode 100644 index 00000000..8ed214e4 --- /dev/null +++ b/app/routes/cms_.articles_.$postId/controls.tsx @@ -0,0 +1,60 @@ +import type { loader } from "./route"; + +import { useLoaderData } from "@remix-run/react"; +import { parameterize } from "inflected"; +import { Heading } from "react-aria-components"; +import { useHydrated } from "remix-utils/use-hydrated"; + +import { useValue } from "~/helpers/use-value.hook"; +import { TextField } from "~/ui/TextField"; + +export function Controls() { + let loaderData = useLoaderData(); + let isHydrated = useHydrated(); + + let [title, setTitle] = useValue( + loaderData.article.id + ? Symbol.for(`article:${loaderData.article.id}:title`) + : Symbol.for("article:new:title"), + loaderData.article.title, + ); + + let slug = loaderData.article.slug || parameterize(title); + + return ( +
      + + Write an Article + + + + + + + +
      + ); +} diff --git a/app/routes/cms_.articles_.$postId/editor.tsx b/app/routes/cms_.articles_.$postId/editor.tsx new file mode 100644 index 00000000..b94df9fc --- /dev/null +++ b/app/routes/cms_.articles_.$postId/editor.tsx @@ -0,0 +1,26 @@ +import { useRef } from "react"; + +import { FieldGroup, TextArea } from "~/ui/Field"; + +type EditorProps = { + value: string; + onChange(value: string): void; +}; + +export function Editor({ value, onChange }: EditorProps) { + let $textarea = useRef(null); + + return ( + +