From 9cd9879dbb89dd53e90268bc5f531a1407098dce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sergio=20Xalambr=C3=AD?= Date: Mon, 15 Jan 2024 00:56:01 -0500 Subject: [PATCH] Add support to move articles to tutorials and setup a redirect --- app/locales/en.ts | 2 + app/modules/redirects.server.ts | 22 ++++++++ .../_layout.cms.articles/article-list.tsx | 21 +++++++- app/routes/_layout.cms.articles/queries.tsx | 32 +++++++++++ app/routes/_layout.cms.articles/route.tsx | 17 +++--- app/routes/_layout.cms.articles/types.ts | 5 ++ app/routes/_layout.cms.cache/route.tsx | 10 ++-- app/routes/_layout.cms.cache/types.ts | 2 +- app/routes/_layout.cms.redirects/list.tsx | 29 ++++++++++ app/routes/_layout.cms.redirects/route.tsx | 54 +++++++++++++++++++ app/routes/_layout.cms.redirects/types.ts | 4 ++ app/routes/_layout.cms/nav.tsx | 1 + server/index.ts | 6 ++- types/cloudflare.d.ts | 1 + types/sdx.d.ts | 2 +- wrangler.toml | 1 + 16 files changed, 192 insertions(+), 17 deletions(-) create mode 100644 app/modules/redirects.server.ts create mode 100644 app/routes/_layout.cms.articles/types.ts create mode 100644 app/routes/_layout.cms.redirects/list.tsx create mode 100644 app/routes/_layout.cms.redirects/route.tsx create mode 100644 app/routes/_layout.cms.redirects/types.ts diff --git a/app/locales/en.ts b/app/locales/en.ts index 8ce8ddd..745ecd9 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -215,6 +215,7 @@ export default { likes: "Likes", tutorials: "Tutorials", cache: "Cache Keys", + redirects: "Redirects", }, }, }, @@ -276,6 +277,7 @@ export default { item: { publishedOn: "Published on {{date}}", edit: "Edit Article", + moveToTutorial: "Move to Tutorial", }, }, }, diff --git a/app/modules/redirects.server.ts b/app/modules/redirects.server.ts new file mode 100644 index 0000000..3f780aa --- /dev/null +++ b/app/modules/redirects.server.ts @@ -0,0 +1,22 @@ +import type { AppLoadContext } from "@remix-run/cloudflare"; + +import { z } from "zod"; + +export class Redirects { + constructor(protected loadContext: AppLoadContext) {} + + async list() { + let { keys } = await this.loadContext.kv.redirects.list(); + return RedirectSchema.array().parse(keys.map((key) => key.metadata)); + } + + async add(name: string, from: string, to: string) { + await this.loadContext.kv.redirects.put( + name, + JSON.stringify({ from, to }), + { metadata: { from, to } }, + ); + } +} + +const RedirectSchema = z.object({ from: z.string(), to: z.string() }); diff --git a/app/routes/_layout.cms.articles/article-list.tsx b/app/routes/_layout.cms.articles/article-list.tsx index 7b27fba..0f40e56 100644 --- a/app/routes/_layout.cms.articles/article-list.tsx +++ b/app/routes/_layout.cms.articles/article-list.tsx @@ -1,10 +1,13 @@ import type { loader } from "./route"; -import { Link, useLoaderData } from "@remix-run/react"; +import { Link, useFetcher, useLoaderData } from "@remix-run/react"; +import { Button } from "react-aria-components"; import { Trans } from "react-i18next"; import { useT } from "~/helpers/use-i18n.hook"; +import { INTENT } from "./types"; + export function ArticleList() { let { articles } = useLoaderData(); return ( @@ -25,6 +28,8 @@ type ItemProps = { function Item(props: ItemProps) { let t = useT("cms.articles.list.item"); + let fetcher = useFetcher(); + return (
  • @@ -45,13 +50,25 @@ function Item(props: ItemProps) {
    -
    +
    {t("edit")} + + + + +
  • ); diff --git a/app/routes/_layout.cms.articles/queries.tsx b/app/routes/_layout.cms.articles/queries.tsx index 31f552d..fba1ec9 100644 --- a/app/routes/_layout.cms.articles/queries.tsx +++ b/app/routes/_layout.cms.articles/queries.tsx @@ -1,5 +1,6 @@ import type { AppLoadContext } from "@remix-run/cloudflare"; import type { User } from "~/modules/session.server"; +import type { UUID } from "~/utils/uuid"; import { eq } from "drizzle-orm"; import fm from "front-matter"; @@ -8,6 +9,7 @@ import { z } from "zod"; import { Article } from "~/models/article.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"; @@ -97,6 +99,36 @@ export async function resetArticles(context: AppLoadContext) { await db.delete(Tables.posts).where(eq(Tables.posts.type, "article")); } +export async function moveToTutorial(context: AppLoadContext, id: UUID) { + let redirects = new Redirects(context); + let db = database(context.db); + + let article = await db.query.posts.findFirst({ + where: eq(Tables.posts.id, id), + with: { meta: { where: eq(Tables.postMeta.key, "slug") } }, + }); + + if (!article) throw new Error("Article not found"); + + let slugMeta = article.meta.find((meta) => meta.key === "slug"); + if (!slugMeta) throw new Error("Slug meta not found"); + + try { + await db + .update(Tables.posts) + .set({ type: "tutorial" }) + .where(eq(Tables.posts.id, id)); + + await redirects.add( + slugMeta.value, + `/article/${slugMeta.value}`, + `/tutorial/${slugMeta.value}`, + ); + } catch (error) { + console.log(error); + } +} + function stripTitle(body: string) { if (!body.startsWith("# ")) return body; let [, ...rest] = body.split("\n"); diff --git a/app/routes/_layout.cms.articles/route.tsx b/app/routes/_layout.cms.articles/route.tsx index 983e301..441970b 100644 --- a/app/routes/_layout.cms.articles/route.tsx +++ b/app/routes/_layout.cms.articles/route.tsx @@ -14,14 +14,11 @@ 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 { assertUUID } from "~/utils/uuid"; import { ArticleList } from "./article-list"; -import { importArticles, resetArticles } from "./queries"; - -const INTENT = { - import: "IMPORT_ARTICLES" as const, - reset: "RESET_ARTICLES" as const, -}; +import { importArticles, moveToTutorial, resetArticles } from "./queries"; +import { INTENT } from "./types"; export async function loader({ request, context }: LoaderFunctionArgs) { void new Logger(context).http(request); @@ -59,7 +56,7 @@ export async function action({ request, context }: ActionFunctionArgs) { let formData = await request.formData(); let intent = z - .enum([INTENT.import, INTENT.reset]) + .enum([INTENT.import, INTENT.reset, INTENT.moveToTutorial]) .parse(formData.get("intent")); if (intent === INTENT.import) { @@ -69,6 +66,12 @@ export async function action({ request, context }: ActionFunctionArgs) { 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"); } diff --git a/app/routes/_layout.cms.articles/types.ts b/app/routes/_layout.cms.articles/types.ts new file mode 100644 index 0000000..dbb5a6b --- /dev/null +++ b/app/routes/_layout.cms.articles/types.ts @@ -0,0 +1,5 @@ +export const INTENT = { + import: "IMPORT_ARTICLES" as const, + reset: "RESET_ARTICLES" as const, + moveToTutorial: "MOVE_TO_TUTORIAL" as const, +}; diff --git a/app/routes/_layout.cms.cache/route.tsx b/app/routes/_layout.cms.cache/route.tsx index b56cb57..d00835d 100644 --- a/app/routes/_layout.cms.cache/route.tsx +++ b/app/routes/_layout.cms.cache/route.tsx @@ -14,7 +14,7 @@ import { Logger } from "~/modules/logger.server"; import { SessionStorage } from "~/modules/session.server"; import { CacheKeyList } from "./list"; -import { INTENTS } from "./types"; +import { INTENT } from "./types"; export async function loader({ request, context }: LoaderFunctionArgs) { void new Logger(context).http(request); @@ -39,12 +39,12 @@ export async function action({ request, context }: ActionFunctionArgs) { let formData = await request.formData(); - if (formData.get("intent") === INTENTS.clear) { + if (formData.get("intent") === INTENT.clear) { let keys = await cache.list(); await Promise.all(keys.map((key) => cache.delete(key))); } - if (formData.get("intent") === INTENTS.deleteSelected) { + if (formData.get("intent") === INTENT.deleteSelected) { let keys = z.string().array().parse(formData.getAll("key")); await Promise.all(keys.map((key) => cache.delete(key))); } @@ -65,7 +65,7 @@ export default function Component() {