Skip to content

Commit

Permalink
Add support to move articles to tutorials and setup a redirect
Browse files Browse the repository at this point in the history
  • Loading branch information
sergiodxa committed Jan 15, 2024
1 parent 25f5f86 commit 9cd9879
Show file tree
Hide file tree
Showing 16 changed files with 192 additions and 17 deletions.
2 changes: 2 additions & 0 deletions app/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ export default {
likes: "Likes",
tutorials: "Tutorials",
cache: "Cache Keys",
redirects: "Redirects",
},
},
},
Expand Down Expand Up @@ -276,6 +277,7 @@ export default {
item: {
publishedOn: "Published on {{date}}",
edit: "Edit Article",
moveToTutorial: "Move to Tutorial",
},
},
},
Expand Down
22 changes: 22 additions & 0 deletions app/modules/redirects.server.ts
Original file line number Diff line number Diff line change
@@ -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() });
21 changes: 19 additions & 2 deletions app/routes/_layout.cms.articles/article-list.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof loader>();
return (
Expand All @@ -25,6 +28,8 @@ type ItemProps = {

function Item(props: ItemProps) {
let t = useT("cms.articles.list.item");
let fetcher = useFetcher();

return (
<li className="flex items-center justify-between gap-3 gap-x-6 py-5">
<div className="flex flex-col gap-1">
Expand All @@ -45,13 +50,25 @@ function Item(props: ItemProps) {
</div>
</div>

<div className="flex-shrink-0">
<div className="flex flex-shrink-0 gap-0.5">
<Link
to={props.id}
className="block rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 no-underline shadow-sm ring-1 ring-inset ring-gray-300 visited:text-gray-900 hover:bg-gray-50"
>
{t("edit")}
</Link>

<fetcher.Form method="post">
<input type="hidden" name="id" value={props.id} />
<Button
type="submit"
name="intent"
value={INTENT.moveToTutorial}
className="block rounded-md bg-white px-2.5 py-1.5 text-sm font-semibold text-gray-900 no-underline shadow-sm ring-1 ring-inset ring-gray-300 visited:text-gray-900 hover:bg-gray-50"
>
{t("moveToTutorial")}
</Button>
</fetcher.Form>
</div>
</li>
);
Expand Down
32 changes: 32 additions & 0 deletions app/routes/_layout.cms.articles/queries.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";

Expand Down Expand Up @@ -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");
Expand Down
17 changes: 10 additions & 7 deletions app/routes/_layout.cms.articles/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand All @@ -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");
}

Expand Down
5 changes: 5 additions & 0 deletions app/routes/_layout.cms.articles/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const INTENT = {
import: "IMPORT_ARTICLES" as const,
reset: "RESET_ARTICLES" as const,
moveToTutorial: "MOVE_TO_TUTORIAL" as const,
};
10 changes: 5 additions & 5 deletions app/routes/_layout.cms.cache/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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)));
}
Expand All @@ -65,7 +65,7 @@ export default function Component() {
<Button
type="submit"
name="intent"
value={INTENTS.clear}
value={INTENT.clear}
className="block flex-shrink-0 rounded-md border-2 border-blue-600 bg-blue-100 px-4 py-2 text-center text-base font-medium text-blue-900"
>
Clear Cache
Expand All @@ -75,7 +75,7 @@ export default function Component() {
<Button
type="submit"
name="intent"
value={INTENTS.deleteSelected}
value={INTENT.deleteSelected}
form={id}
className="block flex-shrink-0 rounded-md border-2 border-blue-600 bg-blue-100 px-4 py-2 text-center text-base font-medium text-blue-900"
>
Expand Down
2 changes: 1 addition & 1 deletion app/routes/_layout.cms.cache/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const INTENTS = {
export const INTENT = {
clear: "CLEAR_CACHE" as const,
deleteSelected: "DELETE_SELECTED" as const,
};
29 changes: 29 additions & 0 deletions app/routes/_layout.cms.redirects/list.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { loader } from "./route";

import { useLoaderData } from "@remix-run/react";
import { GridList, GridListItem } from "react-aria-components";

export function RedirectsList() {
let { list } = useLoaderData<typeof loader>();

return (
<GridList
aria-label="Redirects"
selectionMode="multiple"
className="flex flex-col gap-0.5 rounded-md border border-neutral-300 p-1 data-[focus-visible]:outline-2 data-[focus-visible]:outline-blue-600"
>
{list.map((redirect) => {
return (
<GridListItem
key={redirect.from + redirect.to}
textValue={`From: ${redirect.from} To: ${redirect.to}`}
className="data-[selected]:bg-grey-100 flex items-center gap-2.5 rounded-md p-1 pl-2 data-[selected]:text-blue-600"
>
{/* <StyledCheckbox value={key} /> */}
From: {redirect.from} - To: {redirect.to}
</GridListItem>
);
})}
</GridList>
);
}
54 changes: 54 additions & 0 deletions app/routes/_layout.cms.redirects/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type {
ActionFunctionArgs,
LoaderFunctionArgs,
} from "@remix-run/cloudflare";

import { redirect, json } from "@remix-run/cloudflare";
import { Form } from "@remix-run/react";
import { useId } from "react";

import { Logger } from "~/modules/logger.server";
import { Redirects } from "~/modules/redirects.server";
import { SessionStorage } from "~/modules/session.server";

import { RedirectsList } from "./list";

export async function loader({ request, context }: LoaderFunctionArgs) {
void new Logger(context).http(request);

let user = await SessionStorage.requireUser(context, request, "/auth/login");
if (user.role !== "admin") throw redirect("/");

let redirects = new Redirects(context);
let list = await redirects.list();

return json({ list });
}

export async function action({ request, context }: ActionFunctionArgs) {
void new Logger(context).http(request);

let user = await SessionStorage.requireUser(context, request, "/auth/login");
if (user.role !== "admin") throw redirect("/");

return redirect("/cms/cache");
}

export default function Component() {
let id = useId();

return (
<div className="flex flex-col gap-8 pb-10">
<header className="flex justify-between gap-4 px-5">
<h2 className="text-3xl font-bold">Redirects</h2>

{/* <div className="flex items-center gap-4">
</div> */}
</header>

<Form method="post" id={id}>
<RedirectsList />
</Form>
</div>
);
}
4 changes: 4 additions & 0 deletions app/routes/_layout.cms.redirects/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const INTENT = {
clear: "CLEAR_CACHE" as const,
deleteSelected: "DELETE_SELECTED" as const,
};
1 change: 1 addition & 0 deletions app/routes/_layout.cms/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function Navigation() {
{ name: t("items.likes"), to: "likes" },
{ name: t("items.tutorials"), to: "tutorials" },
{ name: t("items.cache"), to: "cache" },
{ name: t("items.redirects"), to: "redirects" },
] as const;

return (
Expand Down
6 changes: 5 additions & 1 deletion server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,11 @@ server.use(
return {
env,
db: ctx.env.DB,
kv: { cache: ctx.env.cache, auth: ctx.env.auth },
kv: {
cache: ctx.env.cache,
auth: ctx.env.auth,
redirects: ctx.env.redirects,
},
waitUntil: ctx.executionCtx.waitUntil.bind(ctx.executionCtx),
time: measurer.time.bind(measurer),
};
Expand Down
1 change: 1 addition & 0 deletions types/cloudflare.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
interface RuntimeEnv {
auth: KVNamespace;
cache: KVNamespace;
redirects: KVNamspace;
DB: D1Database;
DSN?: string | undefined;
[key: string]: unknown;
Expand Down
2 changes: 1 addition & 1 deletion types/sdx.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ declare global {
declare module "@remix-run/server-runtime" {
export interface AppLoadContext {
db: D1Database;
kv: Record<"cache" | "auth", KVNamespace>;
kv: Record<"cache" | "auth" | "redirects", KVNamespace>;
waitUntil(promise: Promise<unknown>): void;
env: Env;
time: Measurer["time"];
Expand Down
1 change: 1 addition & 0 deletions wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ node_compat = true
kv_namespaces = [
{ binding = "cache", id = "597a3ce0108c42798aeac0044a566e31", preview_id = "cache" },
{ binding = "auth", id = "fda5fdfe90e74e2b82dd0ccd49346c27", preview_id = "auth@sergiodxa.com" },
{ binding = "redirects", id = "2bcc558cadce40dcb9c73f047c26be6c", preview_id = "redirects" },
]

[[d1_databases]]
Expand Down

0 comments on commit 9cd9879

Please sign in to comment.