Add categories for docs template

This commit is contained in:
thiloho
2024-08-27 16:39:29 +02:00
parent 0753345bba
commit 1b1767c0f7
24 changed files with 651 additions and 154 deletions

View File

@@ -5,6 +5,7 @@
const {
id,
contentType,
title,
children,
fullPreview = false,
@@ -12,6 +13,7 @@
previewScrollTop = 0
}: {
id: string;
contentType: string;
title: string;
children: Snippet;
fullPreview?: boolean;
@@ -41,6 +43,9 @@
<li>
<a href="/website/{id}/articles">Articles</a>
</li>
{#if contentType === "Docs"}
<a href="/website/{id}/categories">Categories</a>
{/if}
<li>
<a href="/website/{id}/collaborators">Collaborators</a>
</li>

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import BlogHead from "./common/BlogHead.svelte";
import BlogNav from "./common/BlogNav.svelte";
import BlogFooter from "./common/BlogFooter.svelte";
import Head from "../common/Head.svelte";
import Nav from "../common/Nav.svelte";
import Footer from "../common/Footer.svelte";
const {
favicon,
@@ -24,9 +24,9 @@
} = $props();
</script>
<BlogHead {title} {favicon} nestingLevel={1} />
<Head {title} {favicon} nestingLevel={1} />
<BlogNav {logoType} {logo} />
<Nav {logoType} {logo} />
<header>
<div class="container">
@@ -48,4 +48,4 @@
</main>
{/if}
<BlogFooter text={footerAdditionalText} />
<Footer text={footerAdditionalText} />

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import BlogHead from "./common/BlogHead.svelte";
import BlogNav from "./common/BlogNav.svelte";
import BlogFooter from "./common/BlogFooter.svelte";
import Head from "../common/Head.svelte";
import Nav from "../common/Nav.svelte";
import Footer from "../common/Footer.svelte";
const {
favicon,
@@ -22,9 +22,9 @@
} = $props();
</script>
<BlogHead {title} {favicon} />
<Head {title} {favicon} />
<BlogNav {logoType} {logo} />
<Nav {logoType} {logo} />
<header>
<div class="container">
@@ -62,4 +62,4 @@
</div>
</main>
<BlogFooter text={footerAdditionalText} />
<Footer text={footerAdditionalText} />

View File

@@ -1,17 +0,0 @@
<script lang="ts">
const { logoType, logo }: { logoType: "text" | "image"; logo: string } = $props();
</script>
<nav>
<div class="container">
<a href="../">
{#if logoType === "text"}
<p>
<strong>{logo}</strong>
</p>
{:else}
<img src={logo} width="24" height="24" alt="" />
{/if}
</a>
</div>
</nav>

View File

@@ -0,0 +1,45 @@
<script lang="ts">
const {
logoType,
logo,
isDocsTemplate = false
}: {
logoType: "text" | "image";
logo: string;
isDocsTemplate?: boolean;
} = $props();
</script>
<nav>
<div class="container">
{#if isDocsTemplate}
<input type="checkbox" id="toggle-sidebar" hidden />
<label for="toggle-sidebar">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
width="20"
height="20"
>
<path
fill-rule="evenodd"
d="M2 4.75A.75.75 0 0 1 2.75 4h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 4.75ZM2 10a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H2.75A.75.75 0 0 1 2 10Zm0 5.25a.75.75 0 0 1 .75-.75h14.5a.75.75 0 0 1 0 1.5H2.75a.75.75 0 0 1-.75-.75Z"
clip-rule="evenodd"
></path>
</svg>
</label>
<ul class="docs-navigation">
<li>nav comes here</li>
</ul>
{/if}
<a href="../">
{#if logoType === "text"}
<strong>{logo}</strong>
{:else}
<img src={logo} width="24" height="24" alt="" />
{/if}
</a>
</div>
</nav>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
import Head from "../common/Head.svelte";
import Nav from "../common/Nav.svelte";
import Footer from "../common/Footer.svelte";
const {
favicon,
title,
logoType,
logo,
mainContent,
coverImage,
publicationDate,
footerAdditionalText
}: {
favicon: string;
title: string;
logoType: "text" | "image";
logo: string;
mainContent: string;
coverImage: string;
publicationDate: string;
footerAdditionalText: string;
} = $props();
</script>
<Head {title} {favicon} nestingLevel={1} />
<Nav {logoType} {logo} isDocsTemplate={true} />
<header>
<div class="container">
<hgroup>
<p>{publicationDate}</p>
<h1>{title}</h1>
</hgroup>
{#if coverImage}
<img src={coverImage} alt="" />
{/if}
</div>
</header>
{#if mainContent}
<main>
<div class="container">
{@html mainContent}
</div>
</main>
{/if}
<Footer text={footerAdditionalText} />

View File

@@ -1,52 +0,0 @@
<script lang="ts">
const {
title,
logoType,
logo,
mainContent,
coverImage,
publicationDate,
footerAdditionalText
}: {
title: string;
logoType: "text" | "image";
logo: string;
mainContent: string;
coverImage: string;
publicationDate: string;
footerAdditionalText: string;
} = $props();
</script>
<svelte:head>
<head>
<title>{title}</title>
<link rel="stylesheet" href="../styles.css" />
</head>
</svelte:head>
<nav>
{#if logoType === "text"}
<p>
<strong>{logo}</strong>
</p>
{:else}
<img src={logo} alt="" />
{/if}
</nav>
<header>
{#if coverImage}
<img src={coverImage} alt="" />
{/if}
<h1>{title}</h1>
<p>{publicationDate}</p>
</header>
<main>
{@html mainContent}
</main>
<footer>
{footerAdditionalText}
</footer>

View File

@@ -1,5 +1,10 @@
<script lang="ts">
import Head from "../common/Head.svelte";
import Nav from "../common/Nav.svelte";
import Footer from "../common/Footer.svelte";
const {
favicon,
title,
logoType,
logo,
@@ -7,6 +12,7 @@
articles,
footerAdditionalText
}: {
favicon: string;
title: string;
logoType: "text" | "image";
logo: string;
@@ -16,50 +22,44 @@
} = $props();
</script>
<svelte:head>
<head>
<title>{title}</title>
<link rel="stylesheet" href="./styles.css" />
</head>
</svelte:head>
<Head {title} {favicon} />
<nav>
{#if logoType === "text"}
<p>
<strong>{logo}</strong>
</p>
{:else}
<img src={logo} alt="" />
{/if}
</nav>
<Nav {logoType} {logo} isDocsTemplate={true} />
<header>
<h1>{title}</h1>
<div class="container">
<h1>{title}</h1>
</div>
</header>
<main>
{@html mainContent}
{#if articles.length > 0}
<section class="articles" id="articles">
<h2>Articles</h2>
<div class="container">
{@html mainContent}
{#if articles.length > 0}
<section class="articles" id="articles">
<h2>
<a href="#articles">Articles</a>
</h2>
{#each articles as article}
{@const articleFileName = article.title.toLowerCase().split(" ").join("-")}
<article>
<p>{article.publication_date}</p>
<h3>
<a href="./documents/{articleFileName}.html">{article.title}</a>
</h3>
{#if article.meta_description}
<p>{article.meta_description}</p>
{/if}
</article>
{/each}
</section>
{/if}
<ul class="unpadded">
{#each articles as article}
{@const articleFileName = article.title.toLowerCase().split(" ").join("-")}
<li>
<p>{article.publication_date}</p>
<p>
<strong>
<a href="./articles/{articleFileName}.html">{article.title}</a>
</strong>
</p>
{#if article.meta_description}
<p>{article.meta_description}</p>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
</div>
</main>
<footer>
{footerAdditionalText}
</footer>
<Footer text={footerAdditionalText} />

View File

@@ -36,7 +36,7 @@ const createMarkdownParser = (showToc = true) => {
const unescapeTest = /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;
function unescape(html: string) {
const unescape = (html: string) => {
return html.replace(unescapeTest, (_, n) => {
n = n.toLowerCase();
if (n === "colon") return ":";
@@ -47,13 +47,13 @@ const createMarkdownParser = (showToc = true) => {
}
return "";
});
}
};
let slugger = new GithubSlugger();
let headings: { text: string; raw: string; level: number; id: string }[] = [];
let sectionStack: { level: number; id: string }[] = [];
function gfmHeadingId({ prefix = "", showToc = true } = {}) {
const gfmHeadingId = ({ prefix = "", showToc = true } = {}) => {
return {
renderer: {
heading(this: Renderer, { tokens, depth }: { tokens: Token[]; depth: number }) {
@@ -141,7 +141,7 @@ const createMarkdownParser = (showToc = true) => {
}
}
};
}
};
marked.use(gfmHeadingId({ showToc: showToc }));

View File

@@ -30,6 +30,7 @@
<WebsiteEditor
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
{previewContent}
previewScrollTop={textareaScrollTop}

View File

@@ -85,7 +85,6 @@ export const actions: Actions = {
},
body: JSON.stringify({
website_id: params.websiteId,
user_id: locals.user.id,
title: data.get("title")
})
});

View File

@@ -14,6 +14,7 @@
<WebsiteEditor
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
previewContent={data.home.main_content}
>

View File

@@ -11,10 +11,22 @@ export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) =
}
});
const categoryData = await fetch(
`${API_BASE_PREFIX}/docs_category?website_id=eq.${params.websiteId}&order=category_weight.desc`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
}
);
const article = await articleData.json();
const categories = await categoryData.json();
const { website } = await parent();
return { website, article, API_BASE_PREFIX };
return { website, article, categories, API_BASE_PREFIX };
};
export const actions: Actions = {
@@ -53,7 +65,8 @@ export const actions: Actions = {
meta_author: data.get("author"),
cover_image: uploadedImage.file_id,
publication_date: data.get("publication-date"),
main_content: data.get("main-content")
main_content: data.get("main-content"),
category: data.get("category")
})
});

View File

@@ -30,6 +30,7 @@
<WebsiteEditor
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
previewContent={previewContent ||
"Put some markdown content in main content to see a live preview here"}
@@ -50,6 +51,17 @@
};
}}
>
{#if data.website.content_type === "Docs"}
<label>
Category:
<select name="category">
{#each data.categories as { id, category_name }}
<option value={id} selected={id === data.article.category}>{category_name}</option>
{/each}
</select>
</label>
{/if}
<label>
Title:
<input

View File

@@ -0,0 +1,95 @@
import type { Actions, PageServerLoad } from "./$types";
import { API_BASE_PREFIX } from "$lib/server/utils";
export const load: PageServerLoad = async ({ parent, params, cookies, fetch }) => {
const categoryData = await fetch(
`${API_BASE_PREFIX}/docs_category?website_id=eq.${params.websiteId}&order=category_weight.desc`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
}
);
const categories = await categoryData.json();
const { website, home } = await parent();
return {
categories,
website,
home
};
};
export const actions: Actions = {
createCategory: async ({ request, fetch, cookies, params }) => {
const data = await request.formData();
const res = await fetch(`${API_BASE_PREFIX}/docs_category`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
website_id: params.websiteId,
category_name: data.get("category-name"),
category_weight: data.get("category-weight")
})
});
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return { success: true, message: "Successfully created category" };
},
updateCategory: async ({ request, fetch, cookies, params }) => {
const data = await request.formData();
const res = await fetch(
`${API_BASE_PREFIX}/docs_category?website_id=eq.${params.websiteId}&id=eq.${data.get("category-id")}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
},
body: JSON.stringify({
category_weight: data.get("category-weight")
})
}
);
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return { success: true, message: "Successfully updated category" };
},
deleteCategory: async ({ request, fetch, cookies, params }) => {
const data = await request.formData();
const res = await fetch(
`${API_BASE_PREFIX}/docs_category?website_id=eq.${params.websiteId}&id=eq.${data.get("category-id")}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`
}
}
);
if (!res.ok) {
const response = await res.json();
return { success: false, message: response.message };
}
return { success: true, message: "Successfully deleted category" };
}
};

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import { enhance } from "$app/forms";
import WebsiteEditor from "$lib/components/WebsiteEditor.svelte";
import SuccessOrError from "$lib/components/SuccessOrError.svelte";
import Modal from "$lib/components/Modal.svelte";
import type { ActionData, PageServerData } from "./$types";
const { data, form }: { data: PageServerData; form: ActionData } = $props();
</script>
<SuccessOrError success={form?.success} message={form?.message} />
<WebsiteEditor
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
previewContent={data.home.main_content}
>
<section id="create-category">
<h2>
<a href="#create-category">Create category</a>
</h2>
<Modal id="create-category" text="Create category">
<h3>Create category</h3>
<form
method="POST"
action="?/createCategory"
use:enhance={() => {
return async ({ update }) => {
await update();
window.location.hash = "!";
};
}}
>
<label>
Name:
<input type="text" name="category-name" maxlength="50" required />
</label>
<label>
Weight:
<input name="category-weight" type="number" min="0" required />
</label>
<button type="submit">Submit</button>
</form>
</Modal>
</section>
{#if data.categories.length > 0}
<section id="all-categories">
<h2>
<a href="#all-categories">All categories</a>
</h2>
<ul class="unpadded">
{#each data.categories as { id, website_id, category_name, category_weight } (`${website_id}-${id}`)}
<li class="category-card">
<p>
<strong>{category_name} ({category_weight})</strong>
</p>
<div class="category-card__actions">
<Modal id="update-category-{id}" text="Update">
<h4>Update category</h4>
<form
method="POST"
action="?/updateCategory"
use:enhance={() => {
return async ({ update }) => {
await update({ reset: false });
window.location.hash = "!";
};
}}
>
<input type="hidden" name="category-id" value={id} />
<label>
Weight:
<input type="number" name="category-weight" value={category_weight} min="0" />
</label>
<button type="submit">Update category</button>
</form>
</Modal>
<Modal id="delete-category-{id}" text="Delete">
<h4>Delete category</h4>
<p>Do you really want to delete the category?</p>
<form
method="POST"
action="?/deleteCategory"
use:enhance={() => {
return async ({ update }) => {
await update();
window.location.hash = "!";
};
}}
>
<input type="hidden" name="category-id" value={id} />
<button type="submit">Delete category</button>
</form>
</Modal>
</div>
</li>
{/each}
</ul>
</section>
{/if}
</WebsiteEditor>
<style>
.category-card {
display: flex;
align-items: center;
column-gap: var(--space-s);
row-gap: var(--space-2xs);
flex-wrap: wrap;
justify-content: space-between;
margin-block-start: var(--space-xs);
}
.category-card + .category-card {
padding-block-start: var(--space-xs);
border-block-start: var(--border-primary);
}
.category-card__actions {
display: flex;
gap: var(--space-2xs);
align-items: center;
}
</style>

View File

@@ -12,6 +12,7 @@
<WebsiteEditor
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
previewContent={data.home.main_content}
>
@@ -35,7 +36,14 @@
>
<label>
User id:
<input type="text" name="user-id" minlength="36" maxlength="36" required />
<input
type="text"
name="user-id"
minlength="36"
maxlength="36"
placeholder="00000000-0000-0000-0000-000000000000"
required
/>
</label>
<label>

View File

@@ -7,7 +7,7 @@ import { render } from "svelte/server";
import BlogIndex from "$lib/templates/blog/BlogIndex.svelte";
import BlogArticle from "$lib/templates/blog/BlogArticle.svelte";
import DocsIndex from "$lib/templates/docs/DocsIndex.svelte";
import DocsEntry from "$lib/templates/docs/DocsEntry.svelte";
import DocsArticle from "$lib/templates/docs/DocsArticle.svelte";
import { dev } from "$app/environment";
export const load: PageServerLoad = async ({ params, fetch, cookies, parent }) => {
@@ -38,10 +38,20 @@ export const load: PageServerLoad = async ({ params, fetch, cookies, parent }) =
};
export const actions: Actions = {
publishWebsite: async ({ request }) => {
const data = await request.formData();
const websiteOverview = JSON.parse(data.get("website-overview") as string);
publishWebsite: async ({ fetch, params, cookies }) => {
const websiteOverviewData = await fetch(
`${API_BASE_PREFIX}/website_overview?id=eq.${params.websiteId}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${cookies.get("session_token")}`,
Accept: "application/vnd.pgrst.object+json"
}
}
);
const websiteOverview = await websiteOverviewData.json();
generateStaticFiles(websiteOverview, false);
return { success: true, message: "Successfully published website" };
@@ -77,9 +87,15 @@ const generateStaticFiles = async (websiteData: any, isPreview: boolean = true)
{
({ head, body } = render(DocsIndex, {
props: {
favicon: websiteData.favicon_image
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.favicon_image}`
: "",
title: websiteData.title,
logoType: websiteData.logo_type,
logo: websiteData.logo_text,
logo:
websiteData.logo_type === "text"
? websiteData.logo_text
: `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.logo_image}`,
mainContent: md(websiteData.main_content ?? "", false),
articles: websiteData.articles ?? [],
footerAdditionalText: md(websiteData.additional_text ?? "")
@@ -101,7 +117,7 @@ const generateStaticFiles = async (websiteData: any, isPreview: boolean = true)
await mkdir(uploadDir, { recursive: true });
await writeFile(join(uploadDir, "index.html"), indexFileContents);
await mkdir(join(uploadDir, websiteData.content_type === "Blog" ? "articles" : "documents"), {
await mkdir(join(uploadDir, "articles"), {
recursive: true
});
@@ -137,11 +153,17 @@ const generateStaticFiles = async (websiteData: any, isPreview: boolean = true)
break;
case "Docs":
{
({ head, body } = render(DocsEntry, {
({ head, body } = render(DocsArticle, {
props: {
favicon: websiteData.favicon_image
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.favicon_image}`
: "",
title: article.title,
logoType: websiteData.logo_type,
logo: websiteData.logo_text,
logo:
websiteData.logo_type === "text"
? websiteData.logo_text
: `${API_BASE_PREFIX}/rpc/retrieve_file?id=${websiteData.logo_image}`,
coverImage: article.cover_image
? `${API_BASE_PREFIX}/rpc/retrieve_file?id=${article.cover_image}`
: "",
@@ -156,22 +178,18 @@ const generateStaticFiles = async (websiteData: any, isPreview: boolean = true)
const articleFileContents = head.concat(body);
await writeFile(
join(
uploadDir,
websiteData.content_type === "Blog" ? "articles" : "documents",
`${articleFileName}.html`
),
articleFileContents
);
await writeFile(join(uploadDir, "articles", `${articleFileName}.html`), articleFileContents);
}
const commonStyles = await readFile(`${process.cwd()}/template-styles/common-styles.css`, {
encoding: "utf-8"
});
const specificStyles = await readFile(`${process.cwd()}/template-styles/blog-styles.css`, {
encoding: "utf-8"
});
const specificStyles = await readFile(
`${process.cwd()}/template-styles/${websiteData.content_type.toLowerCase()}-styles.css`,
{
encoding: "utf-8"
}
);
await writeFile(
join(uploadDir, "styles.css"),
commonStyles

View File

@@ -11,6 +11,7 @@
<WebsiteEditor
id={data.website.id}
contentType={data.website.content_type}
title={data.website.title}
previewContent={data.websitePreviewUrl}
fullPreview={true}
@@ -19,15 +20,13 @@
<h2>
<a href="#publish-website">Publish website</a>
</h2>
{JSON.stringify(data.websiteOverview.articles)}
<p>
The preview area on this page allows you to see exactly how your website will look when it is
is published. If you are happy with the results, click the button below and your website will
be published on the Internet.
</p>
<form method="POST" action="?/publishWebsite" use:enhance>
<input type="hidden" name="website-overview" value={JSON.stringify(data.websiteOverview)} />
<button type="submit">Publish</button>
</form>
</section>

View File

@@ -108,6 +108,7 @@ select,
[role="button"],
[role="option"],
label[for="toggle-mobile-preview"],
label[for="toggle-sidebar"],
summary {
cursor: pointer;
}
@@ -118,6 +119,7 @@ textarea,
select,
a[role="button"],
label[for="toggle-mobile-preview"],
label[for="toggle-sidebar"],
summary {
font: inherit;
color: inherit;
@@ -146,7 +148,6 @@ input[type="color"] {
}
a[role="button"] {
display: inline-block;
max-inline-size: fit-content;
text-decoration: none;
}
@@ -158,16 +159,23 @@ summary {
button,
a[role="button"],
label[for="toggle-mobile-preview"],
label[for="toggle-sidebar"],
summary {
background-color: var(--bg-secondary);
}
:is(button, a[role="button"], label[for="toggle-mobile-preview"], summary):hover {
:is(
button,
a[role="button"],
label[for="toggle-mobile-preview"],
label[for="toggle-sidebar"],
summary
):hover {
background-color: var(--bg-tertiary);
}
:is(button, input, textarea, select, a, summary, pre):focus,
#toggle-mobile-preview:checked + label {
:is(#toggle-mobile-preview, #toggle-sidebar):checked + label {
outline: 0.125rem solid var(--color-accent);
outline-offset: 0.25rem;
}
@@ -314,7 +322,6 @@ table {
th,
td {
text-align: start;
padding-inline: var(--space-2xs);
padding-block: var(--space-3xs);
padding: var(--space-2xs);
border: var(--border-primary);
}

View File

@@ -0,0 +1,58 @@
.container {
margin-inline: auto;
inline-size: min(100% - var(--space-m), 75ch);
}
nav {
position: sticky;
block-size: var(--space-xl);
display: flex;
align-items: center;
inset-block-start: 0;
background-color: var(--bg-primary);
border-block-end: var(--border-primary);
}
nav > .container {
display: flex;
align-items: center;
gap: var(--space-2xs);
}
header > .container {
display: flex;
flex-direction: column;
gap: var(--space-s);
}
nav,
header,
main,
footer {
padding-block: var(--space-s);
}
section {
scroll-margin-block-start: var(--space-xl);
}
label[for="toggle-sidebar"] {
display: inline-grid;
place-content: center;
}
@media (min-width: 1525px) {
#table-of-contents {
position: fixed;
inset-inline-start: calc(50% + 37.5ch + var(--space-m));
inset-block-start: calc(var(--space-xl) + var(--space-s));
max-inline-size: 35ch;
padding: var(--space-s);
background-color: var(--bg-primary);
border: var(--border-primary);
}
#table-of-contents + section {
margin-block-start: 0;
}
}