Skip to content

Commit

Permalink
Make CMS list of articles and editor like tutorials
Browse files Browse the repository at this point in the history
Use the same design in the list of articles and the editor as in the tutorials routes inside the CMS
  • Loading branch information
sergiodxa committed Jun 16, 2024
1 parent ca83929 commit cb450a6
Show file tree
Hide file tree
Showing 12 changed files with 505 additions and 298 deletions.
67 changes: 35 additions & 32 deletions app/routes/_.cms.articles/article-list.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,45 @@
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<typeof loader>();
return (
<ol className="rouned-lg divide-y divide-gray-100 bg-white px-5">
<ol className="rouned-lg divide-y divide-zinc-100 bg-white px-5 dark:divide-zinc-700 dark:bg-zinc-800">
{articles.map((article) => (
<Item key={article.id} {...article} />
))}
</ol>
);
}

type ItemProps = {
id: string;
path: string;
title: string;
date: string;
};
type ItemProps = SerializeFrom<typeof loader>["articles"][number];

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

return (
<li className="flex items-center justify-between gap-3 gap-x-6 py-5">
<div className="flex flex-col gap-1">
<Link to={props.path}>
<h3 className="text-sm font-semibold leading-6 text-gray-900 underline">
<Link href={props.path}>
<h3 className="text-sm font-semibold leading-6 text-zinc-900 underline dark:text-zinc-50">
{props.title}
</h3>
</Link>

<div className="flex items-center gap-x-2 text-xs leading-5 text-gray-500">
<div className="flex items-baseline gap-x-2 text-xs leading-5 text-zinc-500 dark:text-zinc-300">
<Trans
t={t}
className="whitespace-nowrap"
Expand All @@ -50,26 +50,29 @@ function Item(props: ItemProps) {
</div>
</div>

<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")}
<div className="flex flex-shrink-0 items-center gap-2">
<Form method="get" action={`/cms/articles/${props.id}`}>
<Button type="submit" variant="primary">
{t("edit")}
</Button>
</fetcher.Form>
</Form>

<DeleteButton id={props.id} />
</div>
</li>
);
}

function DeleteButton({ id }: { id: UUID }) {
let fetcher = useFetcher();

return (
<fetcher.Form method="POST">
<input type="hidden" name="intent" value={INTENT.delete} />
<input type="hidden" name="id" value={id} />
<Button type="submit" variant="destructive">
Delete
</Button>
</fetcher.Form>
);
}
131 changes: 28 additions & 103 deletions app/routes/_.cms.articles/queries.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand Down Expand Up @@ -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", " ");
}
Loading

0 comments on commit cb450a6

Please sign in to comment.