Clone starter
This commit is contained in:
17
apps/web/.eslintrc.cjs
Normal file
17
apps/web/.eslintrc.cjs
Normal file
@ -0,0 +1,17 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["custom", "plugin:astro/recommended"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["*.astro"],
|
||||
parser: "astro-eslint-parser",
|
||||
parserOptions: {
|
||||
parser: "@typescript-eslint/parser",
|
||||
extraFileExtensions: [".astro"],
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
21
apps/web/.gitignore
vendored
Normal file
21
apps/web/.gitignore
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
13
apps/web/README.md
Normal file
13
apps/web/README.md
Normal file
@ -0,0 +1,13 @@
|
||||
# Astro with Tailwind
|
||||
|
||||
```
|
||||
npm create astro@latest -- --template with-tailwindcss
|
||||
```
|
||||
|
||||
[](https://stackblitz.com/github/withastro/astro/tree/latest/examples/with-tailwindcss)
|
||||
[](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/with-tailwindcss)
|
||||
[](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/with-tailwindcss/devcontainer.json)
|
||||
|
||||
Astro comes with [Tailwind](https://tailwindcss.com) support out of the box. This example showcases how to style your Astro project with Tailwind.
|
||||
|
||||
For complete setup instructions, please see our [Tailwind Integration Guide](https://docs.astro.build/en/guides/integrations-guide/tailwind).
|
24
apps/web/astro.config.mjs
Normal file
24
apps/web/astro.config.mjs
Normal file
@ -0,0 +1,24 @@
|
||||
import mdx from "@astrojs/mdx";
|
||||
import node from "@astrojs/node";
|
||||
import svelte from "@astrojs/svelte";
|
||||
import tailwind from "@astrojs/tailwind";
|
||||
|
||||
import { defineConfig } from "astro/config";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [mdx(), tailwind(), svelte()],
|
||||
server: {
|
||||
port: parseInt(process.env.ASTRO_PORT ?? "3000"),
|
||||
},
|
||||
output: "server",
|
||||
adapter: node({
|
||||
mode: "standalone",
|
||||
}),
|
||||
vite: {
|
||||
define: {
|
||||
"import.meta.env.PAYLOAD_PUBLIC_SERVER_URL": JSON.stringify(
|
||||
process.env.PAYLOAD_PUBLIC_SERVER_URL,
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
43
apps/web/package.json
Normal file
43
apps/web/package.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@turbopress/web",
|
||||
"description": "Front end website based on Astro",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"lint": "eslint \"./src/**/*.{js,ts,tsx,astro}\" --fix",
|
||||
"serve": "node dist/server/entry.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "latest",
|
||||
"@astrojs/node": "latest",
|
||||
"@astrojs/svelte": "latest",
|
||||
"@astrojs/tailwind": "latest",
|
||||
"@turbopress/api": "*",
|
||||
"astro": "latest",
|
||||
"autoprefixer": "latest",
|
||||
"@iconify/svelte": "latest",
|
||||
"postcss": "latest",
|
||||
"qs": "^6.11.2",
|
||||
"slate": "latest",
|
||||
"tailwindcss": "latest",
|
||||
"astro-icon": "latest",
|
||||
"nanostores": "latest",
|
||||
"svelte": "latest",
|
||||
"svelte-inview": "4.0.1 "
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@types/escape-html": "^1.0.2",
|
||||
"@types/qs": "^6.9.7",
|
||||
"escape-html": "^1.0.3",
|
||||
"eslint": "latest",
|
||||
"eslint-config-custom": "*",
|
||||
"eslint-plugin-astro": "latest",
|
||||
"svelte-breakpoints": "latest"
|
||||
}
|
||||
}
|
9
apps/web/public/favicon.svg
Normal file
9
apps/web/public/favicon.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
|
||||
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
|
||||
<style>
|
||||
path { fill: #000; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
path { fill: #FFF; }
|
||||
}
|
||||
</style>
|
||||
</svg>
|
After Width: | Height: | Size: 749 B |
6
apps/web/src/components/NoHomePage.astro
Normal file
6
apps/web/src/components/NoHomePage.astro
Normal file
@ -0,0 +1,6 @@
|
||||
<div class="grid place-items-center h-full w-full content-center">
|
||||
<div class="text-xl font-bold">No homepage has been setup.</div>
|
||||
<div class="text-lg">
|
||||
Create a new page with slug 'home' in the admin panel.
|
||||
</div>
|
||||
</div>
|
18
apps/web/src/components/RenderLayout.astro
Normal file
18
apps/web/src/components/RenderLayout.astro
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
import type {
|
||||
FormattedElement,
|
||||
FormattedText,
|
||||
Layout,
|
||||
} from "@turbopress/api/types";
|
||||
import RenderBody from "./body/RenderBody.astro";
|
||||
import RenderHeader from "./header/RenderHeader.astro";
|
||||
interface Props {
|
||||
layout: Layout;
|
||||
content?: (FormattedElement | FormattedText)[];
|
||||
}
|
||||
|
||||
const { layout, content } = Astro.props;
|
||||
---
|
||||
|
||||
<RenderHeader blocks={layout.header?.blocks} />
|
||||
<RenderBody blocks={layout.body?.blocks} {content} />
|
30
apps/web/src/components/RenderReusableContent.astro
Normal file
30
apps/web/src/components/RenderReusableContent.astro
Normal file
@ -0,0 +1,30 @@
|
||||
---
|
||||
import type { Content } from "@turbopress/api/types";
|
||||
import { getContentSingle } from "../services/api/content.service";
|
||||
import RenderSiteTitle from "./header/RenderSiteTitle.astro";
|
||||
import RenderMenu from "./menu/RenderMenu.astro";
|
||||
interface Props {
|
||||
content: string | Content;
|
||||
}
|
||||
|
||||
const content: Content | undefined =
|
||||
typeof Astro.props.content == "string"
|
||||
? await getContentSingle(Astro.props.content)
|
||||
: Astro.props.content;
|
||||
|
||||
if (!content?.blocks) return;
|
||||
|
||||
const blocks = content.blocks;
|
||||
---
|
||||
|
||||
{
|
||||
blocks.map((block) => {
|
||||
if (!block.id) return;
|
||||
if (block.blockType == "siteTitle")
|
||||
return <RenderSiteTitle siteTitle={block} />;
|
||||
if (block.blockType == "menu" && block.menus)
|
||||
return <RenderMenu menu={block} />;
|
||||
|
||||
return <div>block = {block.id}</div>;
|
||||
})
|
||||
}
|
36
apps/web/src/components/body/RenderBody.astro
Normal file
36
apps/web/src/components/body/RenderBody.astro
Normal file
@ -0,0 +1,36 @@
|
||||
---
|
||||
import type {
|
||||
FormattedElement,
|
||||
FormattedText,
|
||||
PageContent,
|
||||
PageList,
|
||||
ReusableContent,
|
||||
} from "@turbopress/api/types";
|
||||
import RenderContent from "../RenderReusableContent.astro";
|
||||
import RenderPageContent from "./RenderPageContent.astro";
|
||||
// import RenderPageList from "./page-list/RenderPageList.astro";
|
||||
import RenderPageList from "./page-list/RenderPageList.svelte";
|
||||
interface Props {
|
||||
blocks?: (PageList | ReusableContent | PageContent)[];
|
||||
content?: (FormattedElement | FormattedText)[];
|
||||
}
|
||||
|
||||
const { blocks = [], content } = Astro.props;
|
||||
|
||||
if (blocks.length === 0) return;
|
||||
---
|
||||
|
||||
<main class="p-6 flex flex-wrap">
|
||||
{
|
||||
blocks.map((block) => {
|
||||
if (!block.id) return;
|
||||
if (block.blockType == "reusableContent" && block.reference?.value)
|
||||
return <RenderContent content={block.reference.value} />;
|
||||
if (block.blockType == "pageContent")
|
||||
return <RenderPageContent {content} />;
|
||||
if (block.blockType == "pageList")
|
||||
return <RenderPageList {block} client:only />;
|
||||
return <div>{block.id}</div>;
|
||||
})
|
||||
}
|
||||
</main>
|
17
apps/web/src/components/body/RenderPageContent.astro
Normal file
17
apps/web/src/components/body/RenderPageContent.astro
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
import type { FormattedElement, FormattedText } from "@turbopress/api/types";
|
||||
import RichText from "../rich-text/RichText.astro";
|
||||
interface Props {
|
||||
content?: (FormattedElement | FormattedText)[];
|
||||
title?: string;
|
||||
}
|
||||
const { content } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
content && (
|
||||
<article class="w-full justify-center prose max-w-none prose-headings:m-0 prose-headings:my-3 prose-p:m-0 prose-p:my-2">
|
||||
<RichText richText={content} />
|
||||
</article>
|
||||
)
|
||||
}
|
18
apps/web/src/components/body/page-list/PageListItem.svelte
Normal file
18
apps/web/src/components/body/page-list/PageListItem.svelte
Normal file
@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { Page } from "@turbopress/api/types";
|
||||
import RichText from "../../rich-text/RichText.svelte";
|
||||
|
||||
export let page: Page;
|
||||
</script>
|
||||
|
||||
{#if page.content}
|
||||
<section class="">
|
||||
<header>
|
||||
<h2>{page.title}</h2>
|
||||
</header>
|
||||
<article>
|
||||
<RichText richText={page.content}></RichText>
|
||||
</article>
|
||||
<hr />
|
||||
</section>
|
||||
{/if}
|
65
apps/web/src/components/body/page-list/RenderPageList.svelte
Normal file
65
apps/web/src/components/body/page-list/RenderPageList.svelte
Normal file
@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import type { Page, PageList } from "@turbopress/api/types";
|
||||
import { inview } from "svelte-inview";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { getPageCollection } from "../../../services/api";
|
||||
import type { PayloadCollection } from "../../../types";
|
||||
import PageListItem from "./PageListItem.svelte";
|
||||
|
||||
export let block: PageList;
|
||||
|
||||
const query = {
|
||||
where: {
|
||||
or: [
|
||||
{
|
||||
categories: {
|
||||
in: block.filterByCategories?.map((f) => f.value),
|
||||
},
|
||||
},
|
||||
{
|
||||
tags: {
|
||||
in: block.filterByTags?.map((f) => f.value),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
limit: block.numberOfItems ?? 5,
|
||||
page: 1,
|
||||
sort: block.sortBy,
|
||||
};
|
||||
|
||||
const queryState = writable(query);
|
||||
const collection: Writable<PayloadCollection<Page> | undefined> = writable();
|
||||
|
||||
async function getPages() {
|
||||
const pages = await getPageCollection($queryState);
|
||||
collection.set(pages);
|
||||
}
|
||||
|
||||
queryState.subscribe((s) => {
|
||||
getPages();
|
||||
});
|
||||
|
||||
$: pages = $collection?.docs;
|
||||
|
||||
function handleChange({ detail }: CustomEvent<ObserverEventDetails>) {
|
||||
isInView = detail.inView;
|
||||
if (detail.inView && $collection?.hasNextPage) {
|
||||
queryState.set({ ...$queryState, limit: $queryState.limit + 1 });
|
||||
}
|
||||
}
|
||||
|
||||
let isInView: boolean;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="w-full prose max-w-none prose-headings:m-0 prose-headings:my-3 prose-p:m-0 prose-p:my-2"
|
||||
>
|
||||
{#if pages}
|
||||
{#each pages as page}
|
||||
<PageListItem {page}></PageListItem>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div use:inview on:inview_change={handleChange}></div>
|
||||
</div>
|
29
apps/web/src/components/header/RenderHeader.astro
Normal file
29
apps/web/src/components/header/RenderHeader.astro
Normal file
@ -0,0 +1,29 @@
|
||||
---
|
||||
import type { Menu, ReusableContent, SiteTitle } from "@turbopress/api/types";
|
||||
import RenderContent from "../RenderReusableContent.astro";
|
||||
import RenderMenu from "../menu/RenderMenu.astro";
|
||||
import RenderSiteTitle from "./RenderSiteTitle.astro";
|
||||
// import SiteTitle from "./SiteTitle.astro";
|
||||
interface Props {
|
||||
blocks?: (Menu | ReusableContent | SiteTitle)[];
|
||||
}
|
||||
|
||||
const { blocks = [] } = Astro.props;
|
||||
|
||||
if (blocks.length === 0) return;
|
||||
---
|
||||
|
||||
<header class="shadow p-6 flex flex-wrap">
|
||||
{
|
||||
blocks.map((block) => {
|
||||
if (!block.id) return;
|
||||
if (block.blockType == "siteTitle")
|
||||
return <RenderSiteTitle siteTitle={block} />;
|
||||
if (block.blockType == "reusableContent" && block.reference?.value)
|
||||
return <RenderContent content={block.reference.value} />;
|
||||
if (block.blockType == "menu" && block.menus)
|
||||
return <RenderMenu menu={block} />;
|
||||
return <div>{block.id}</div>;
|
||||
})
|
||||
}
|
||||
</header>
|
16
apps/web/src/components/header/RenderSiteTitle.astro
Normal file
16
apps/web/src/components/header/RenderSiteTitle.astro
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
import type { SiteTitle } from "@turbopress/api/types";
|
||||
import Link from "../link/Link.astro";
|
||||
interface Props {
|
||||
siteTitle: SiteTitle;
|
||||
}
|
||||
|
||||
const { siteTitle } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="flex-grow">
|
||||
<Link link="/">
|
||||
<div class="font-bold text-lg">{siteTitle.siteName}</div>
|
||||
</Link>
|
||||
<div class="w-full"></div>
|
||||
</div>
|
22
apps/web/src/components/link/Link.astro
Normal file
22
apps/web/src/components/link/Link.astro
Normal file
@ -0,0 +1,22 @@
|
||||
---
|
||||
interface Props {
|
||||
link?: string | undefined;
|
||||
target?: "_self" | "_blank" | "_top" | "_parent";
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { link, target, class: className } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
link && (
|
||||
<a
|
||||
href={link}
|
||||
{target}
|
||||
class={"cursor-pointer hover:text-indigo-600 " + className}
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{!link && <slot />}
|
22
apps/web/src/components/link/Link.svelte
Normal file
22
apps/web/src/components/link/Link.svelte
Normal file
@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
export let link: string | undefined = undefined;
|
||||
export let target:
|
||||
| "_self"
|
||||
| "_blank"
|
||||
| "_top"
|
||||
| "_parent"
|
||||
| undefined
|
||||
| null = undefined;
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
{#if link}
|
||||
<a
|
||||
href={link}
|
||||
{target}
|
||||
class="cursor-pointer hover:text-indigo-600 {$$props.class ?? ''}"
|
||||
><slot /></a
|
||||
>
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
18
apps/web/src/components/menu/RenderMenu.astro
Normal file
18
apps/web/src/components/menu/RenderMenu.astro
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
import type { MainMenu, Menu } from "@turbopress/api/types";
|
||||
import DefaultMenu from "./default/DefaultMenu.svelte";
|
||||
interface Props {
|
||||
menu: Menu;
|
||||
}
|
||||
|
||||
const { menu } = Astro.props;
|
||||
const menus = menu.menus ?? [];
|
||||
|
||||
const mainMenus: MainMenu[] = menus.map((menu) => menu.mainMenu);
|
||||
---
|
||||
|
||||
{
|
||||
() => {
|
||||
return <DefaultMenu menus={mainMenus} client:only="svelte" />;
|
||||
}
|
||||
}
|
18
apps/web/src/components/menu/default/DefaultMenu.svelte
Normal file
18
apps/web/src/components/menu/default/DefaultMenu.svelte
Normal file
@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import type { MainMenu } from "@turbopress/api/types";
|
||||
import { useMediaQuery } from "svelte-breakpoints";
|
||||
import DefaultDesktopMenu from "./desktop/DefaultDesktopMenu.svelte";
|
||||
import DefaultMobileMenu from "./mobile/DefaultMobileMenu.svelte";
|
||||
|
||||
export let menus: MainMenu[];
|
||||
|
||||
const isDesktop = useMediaQuery("(min-width: 1024px)");
|
||||
</script>
|
||||
|
||||
{#if !$isDesktop}
|
||||
<DefaultMobileMenu {menus}></DefaultMobileMenu>
|
||||
{/if}
|
||||
|
||||
{#if $isDesktop}
|
||||
<DefaultDesktopMenu {menus}></DefaultDesktopMenu>
|
||||
{/if}
|
11
apps/web/src/components/menu/default/defaultMenu.ts
Normal file
11
apps/web/src/components/menu/default/defaultMenu.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { map } from "nanostores";
|
||||
|
||||
interface MobileMenuState {
|
||||
isOpen: boolean;
|
||||
activeIndex?: number;
|
||||
}
|
||||
|
||||
export const mobileMenuState = map<MobileMenuState>({
|
||||
isOpen: false,
|
||||
activeIndex: undefined,
|
||||
});
|
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import type { MainMenu } from "@turbopress/api/types";
|
||||
import { mobileMenuState } from "../defaultMenu";
|
||||
import MainMenuSvelte from "./_MainMenu.svelte";
|
||||
|
||||
export let menus: MainMenu[];
|
||||
|
||||
function handleClick() {
|
||||
mobileMenuState.setKey("isOpen", !isOpen);
|
||||
}
|
||||
|
||||
$: isOpen = $mobileMenuState.isOpen;
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="flex items-center cursor-pointer text-sm"
|
||||
on:click={handleClick}
|
||||
on:keypress={handleClick}
|
||||
>
|
||||
{#each menus as menu, i}
|
||||
<MainMenuSvelte {menu} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- {#if isOpen}
|
||||
<div class="w-full cursor-pointer lg:hidden">
|
||||
{#each menus as menu, i}
|
||||
<MainMenuSvelte {menu} index={i} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if} -->
|
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { MainMenu } from "@turbopress/api/types";
|
||||
import Link from "../../../link/Link.svelte";
|
||||
|
||||
export let menu: MainMenu;
|
||||
|
||||
const subMenus = menu.subMenu ?? [];
|
||||
</script>
|
||||
|
||||
<div class="group relative inline-block text-left group">
|
||||
<div id="menu-button" aria-expanded="false" aria-haspopup="true">
|
||||
<Link
|
||||
link={menu.url}
|
||||
class="hover:text-black w-full group-hover:bg-slate-100 p-2 "
|
||||
>
|
||||
{menu.label}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-2 absolute right-0 z-10 w-56 origin-top-right rounded-sm bg-white ring-1 ring-slate-200 focus:outline-none hidden group-hover:block"
|
||||
role="menu"
|
||||
aria-orientation="vertical"
|
||||
aria-labelledby="menu-button"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="" role="none">
|
||||
{#each subMenus as subMenu}
|
||||
<Link link={subMenu.link.url}>
|
||||
<div
|
||||
class="px-3 py-1.5 block hover:bg-slate-100 ring-1 ring-inset ring-gray-200 ring-opacity-30"
|
||||
>
|
||||
{subMenu.link.label}
|
||||
</div>
|
||||
</Link>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
17
apps/web/src/components/menu/default/desktop/_SubMenu.svelte
Normal file
17
apps/web/src/components/menu/default/desktop/_SubMenu.svelte
Normal file
@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { Link } from "@turbopress/api/types";
|
||||
import LinkSvelte from "../../../link/Link.svelte";
|
||||
export let subMenu: {
|
||||
link: Link;
|
||||
id?: string;
|
||||
};
|
||||
</script>
|
||||
|
||||
<LinkSvelte
|
||||
link={subMenu.link.url}
|
||||
class="h-7 hover:text-black hover:bg-slate-100 flex items-center"
|
||||
>
|
||||
<div class="w-full px-3">
|
||||
{subMenu.link.label}
|
||||
</div>
|
||||
</LinkSvelte>
|
@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { MainMenu } from "@turbopress/api/types";
|
||||
import { mobileMenuState } from "../defaultMenu";
|
||||
import MainMenuSvelte from "./_MainMenu.svelte";
|
||||
|
||||
export let menus: MainMenu[];
|
||||
|
||||
function handleClick() {
|
||||
mobileMenuState.setKey("isOpen", !isOpen);
|
||||
}
|
||||
|
||||
$: isOpen = $mobileMenuState.isOpen;
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="flex items-center cursor-pointer font-semibold hover:text-indigo-600"
|
||||
on:click={handleClick}
|
||||
on:keypress={handleClick}
|
||||
>
|
||||
{#if isOpen}
|
||||
<Icon icon="ic:round-close" class="h-5 w-5 mr-2" />
|
||||
{:else}
|
||||
<Icon icon="ic:baseline-menu" class="h-5 w-5 mr-2" />
|
||||
{/if}
|
||||
<div>Menu</div>
|
||||
</div>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="w-full cursor-pointer lg:hidden">
|
||||
{#each menus as menu, i}
|
||||
<MainMenuSvelte {menu} index={i} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
44
apps/web/src/components/menu/default/mobile/_MainMenu.svelte
Normal file
44
apps/web/src/components/menu/default/mobile/_MainMenu.svelte
Normal file
@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@iconify/svelte";
|
||||
import type { MainMenu } from "@turbopress/api/types";
|
||||
import Link from "../../../link/Link.svelte";
|
||||
import { mobileMenuState } from "../defaultMenu";
|
||||
import SubMenu from "./_SubMenu.svelte";
|
||||
|
||||
export let menu: MainMenu;
|
||||
export let index: number;
|
||||
|
||||
$: isOpen = $mobileMenuState.activeIndex == index;
|
||||
|
||||
function handleClick() {
|
||||
if (isOpen) mobileMenuState.setKey("activeIndex", undefined);
|
||||
else mobileMenuState.setKey("activeIndex", index);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center h-7">
|
||||
<Link link={menu.url} class="hover:text-black w-full hover:bg-slate-100 p-1 ">
|
||||
<div class="w-full">{menu.label}</div>
|
||||
</Link>
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
class="hover:bg-slate-100 h-8 w-10 {isOpen ? 'bg-indigo-50' : ''}"
|
||||
on:click={handleClick}
|
||||
on:keydown={handleClick}
|
||||
>
|
||||
<Icon
|
||||
icon="ic:sharp-keyboard-arrow-down"
|
||||
class="text-2xl mx-auto mt-1 transition-transform duration-200 {isOpen
|
||||
? 'rotate-180 text-indigo-800 '
|
||||
: ''}"
|
||||
></Icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isOpen}
|
||||
{#if menu.subMenu}
|
||||
{#each menu.subMenu as subMenu}
|
||||
<SubMenu {subMenu} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
17
apps/web/src/components/menu/default/mobile/_SubMenu.svelte
Normal file
17
apps/web/src/components/menu/default/mobile/_SubMenu.svelte
Normal file
@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { Link } from "@turbopress/api/types";
|
||||
import LinkSvelte from "../../../link/Link.svelte";
|
||||
export let subMenu: {
|
||||
link: Link;
|
||||
id?: string;
|
||||
};
|
||||
</script>
|
||||
|
||||
<LinkSvelte
|
||||
link={subMenu.link.url}
|
||||
class="h-7 hover:text-black hover:bg-slate-100 flex items-center"
|
||||
>
|
||||
<div class="w-full px-3">
|
||||
{subMenu.link.label}
|
||||
</div>
|
||||
</LinkSvelte>
|
65
apps/web/src/components/rich-text/RichText.astro
Normal file
65
apps/web/src/components/rich-text/RichText.astro
Normal file
@ -0,0 +1,65 @@
|
||||
---
|
||||
import type { FormattedElement, FormattedText } from "@turbopress/api/types";
|
||||
import escapeHTML from "escape-html";
|
||||
import { Text } from "slate";
|
||||
|
||||
interface Props {
|
||||
richText: (FormattedElement | FormattedText)[];
|
||||
}
|
||||
|
||||
const { richText } = Astro.props;
|
||||
---
|
||||
|
||||
{
|
||||
richText.map((node) => {
|
||||
return Text.isText(node) ? (
|
||||
<Fragment>
|
||||
{node.bold && <strong>{node.text}</strong>}
|
||||
{node.code && <code>{node.text}</code>}
|
||||
{node.italic && <em>{node.text}</em>}
|
||||
{!node.bold && !node.code && !node.italic && (
|
||||
<Fragment>{node.text}</Fragment>
|
||||
)}
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
{node.type === "h1" && (
|
||||
<h1>{<Astro.self richText={node.children} />}</h1>
|
||||
)}
|
||||
{node.type === "h2" && (
|
||||
<h2>{<Astro.self richText={node.children} />}</h2>
|
||||
)}
|
||||
{node.type === "h3" && (
|
||||
<h3>{<Astro.self richText={node.children} />}</h3>
|
||||
)}
|
||||
{node.type === "h4" && (
|
||||
<h4>{<Astro.self richText={node.children} />}</h4>
|
||||
)}
|
||||
{node.type === "h5" && (
|
||||
<h5>{<Astro.self richText={node.children} />}</h5>
|
||||
)}
|
||||
{node.type === "h6" && (
|
||||
<h6>{<Astro.self richText={node.children} />}</h6>
|
||||
)}
|
||||
{node.type === "quote" && (
|
||||
<p>{<Astro.self richText={node.children} />}</p>
|
||||
)}
|
||||
{node.type === "ul" && (
|
||||
<ul>{<Astro.self richText={node.children} />}</ul>
|
||||
)}
|
||||
{node.type === "ol" && (
|
||||
<ol>{<Astro.self richText={node.children} />}</ol>
|
||||
)}
|
||||
{node.type === "li" && (
|
||||
<li>{<Astro.self richText={node.children} />}</li>
|
||||
)}
|
||||
{node.type === "link" && (
|
||||
<a href={escapeHTML(node.url)}>
|
||||
{<Astro.self richText={node.children} />}
|
||||
</a>
|
||||
)}
|
||||
{!node.type && <p>{<Astro.self richText={node.children} />}</p>}
|
||||
</Fragment>
|
||||
);
|
||||
})
|
||||
}
|
45
apps/web/src/components/rich-text/RichText.svelte
Normal file
45
apps/web/src/components/rich-text/RichText.svelte
Normal file
@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import type { FormattedElement, FormattedText } from "@turbopress/api/types";
|
||||
import { Text } from "slate";
|
||||
|
||||
export let richText: (FormattedElement | FormattedText | any)[];
|
||||
</script>
|
||||
|
||||
{#each richText as node}
|
||||
{#if Text.isText(node)}
|
||||
{#if node.bold}
|
||||
<strong>{node.text}</strong>
|
||||
{/if}
|
||||
{#if node.code}
|
||||
<strong>{node.text}</strong>
|
||||
{/if}
|
||||
{#if node.italic}
|
||||
<strong>{node.text}</strong>
|
||||
{/if}
|
||||
{#if !node.bold && !node.code && !node.italic}
|
||||
{node.text}
|
||||
{/if}
|
||||
{:else}
|
||||
{#if node.type === "h1"}
|
||||
<h1><svelte:self richText={node.children}></svelte:self></h1>
|
||||
{/if}
|
||||
{#if node.type === "h2"}
|
||||
<h2><svelte:self richText={node.children}></svelte:self></h2>
|
||||
{/if}
|
||||
{#if node.type === "h3"}
|
||||
<h3><svelte:self richText={node.children}></svelte:self></h3>
|
||||
{/if}
|
||||
{#if node.type === "h4"}
|
||||
<h4><svelte:self richText={node.children}></svelte:self></h4>
|
||||
{/if}
|
||||
{#if node.type === "h5"}
|
||||
<h5><svelte:self richText={node.children}></svelte:self></h5>
|
||||
{/if}
|
||||
{#if node.type === "h6"}
|
||||
<h6><svelte:self richText={node.children}></svelte:self></h6>
|
||||
{/if}
|
||||
{#if !node.type}
|
||||
<p><svelte:self richText={node.children}></svelte:self></p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
5
apps/web/src/env.d.ts
vendored
Normal file
5
apps/web/src/env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/// <reference types="astro/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly ASTRO_PORT: string;
|
||||
}
|
48
apps/web/src/layouts/MainLayout.astro
Normal file
48
apps/web/src/layouts/MainLayout.astro
Normal file
@ -0,0 +1,48 @@
|
||||
---
|
||||
import type {
|
||||
FormattedElement,
|
||||
FormattedText,
|
||||
Layout,
|
||||
Media,
|
||||
} from "@turbopress/api/types";
|
||||
import RenderLayout from "../components/RenderLayout.astro";
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
layout?: string | Layout;
|
||||
content?: (FormattedElement | FormattedText | any)[];
|
||||
image?: string | Media;
|
||||
}
|
||||
const {
|
||||
title = "AstroCMS",
|
||||
description = "Astro, TailwindCSS, and PayloadCMS",
|
||||
layout,
|
||||
content,
|
||||
image,
|
||||
} = Astro.props;
|
||||
|
||||
const metaImage = image
|
||||
? typeof image === "string"
|
||||
? image
|
||||
: image.url
|
||||
: undefined;
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<meta name="description" content={description} />
|
||||
{metaImage && <meta property="og:image" content={metaImage} />}
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="h-screen">
|
||||
{
|
||||
layout && typeof layout != "string" && (
|
||||
<RenderLayout layout={layout} {content} />
|
||||
)
|
||||
}
|
||||
{!layout && <slot />}
|
||||
</body>
|
||||
</html>
|
12
apps/web/src/pages/404.astro
Normal file
12
apps/web/src/pages/404.astro
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
import MainLayout from "../layouts/MainLayout.astro";
|
||||
import { getLayoutSingle } from "../services/api/layout.service";
|
||||
|
||||
const layout = await getLayoutSingle("404");
|
||||
---
|
||||
|
||||
<MainLayout title="Error 404" layout={layout} description="Page not found">
|
||||
Not found, error 404 The page you are looking for no longer exists. Perhaps
|
||||
you can return back to the homepage and see if you can find what you are
|
||||
looking for. Or, you can try finding it by using the search form below.
|
||||
</MainLayout>
|
16
apps/web/src/pages/[...slug].astro
Normal file
16
apps/web/src/pages/[...slug].astro
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
import MainLayout from "../layouts/MainLayout.astro";
|
||||
import { getPageSingle } from "../services/api";
|
||||
const { slug } = Astro.params;
|
||||
|
||||
const page = await getPageSingle(slug!);
|
||||
if (!page) return Astro.redirect("/404");
|
||||
if (page.slug == "home") return Astro.redirect("/");
|
||||
---
|
||||
|
||||
<MainLayout title={page.title} layout={page.layout} description="">
|
||||
{page.title}
|
||||
{page.layout}
|
||||
<!-- {homePage && <RenderPage page={homePage} />}
|
||||
{!homePage && <NoHomePage />} -->
|
||||
</MainLayout>
|
18
apps/web/src/pages/index.astro
Normal file
18
apps/web/src/pages/index.astro
Normal file
@ -0,0 +1,18 @@
|
||||
---
|
||||
import MainLayout from "../layouts/MainLayout.astro";
|
||||
import { getPageSingle } from "../services/api";
|
||||
|
||||
const homePage = await getPageSingle("home");
|
||||
|
||||
const pageTitle = homePage?.meta?.title ?? homePage?.title ?? "TurboPress";
|
||||
|
||||
const layout = homePage?.layout;
|
||||
---
|
||||
|
||||
<MainLayout
|
||||
title={pageTitle}
|
||||
{layout}
|
||||
description={homePage?.meta?.description}
|
||||
content={homePage?.content}
|
||||
image={homePage?.meta?.image}
|
||||
/>
|
31
apps/web/src/services/api/api.service.ts
Normal file
31
apps/web/src/services/api/api.service.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import qs from "qs";
|
||||
import type { PayloadCollection } from "../../types";
|
||||
|
||||
export async function apiFetch<T = any>(
|
||||
url: string | URL,
|
||||
options: RequestInit = {},
|
||||
) {
|
||||
const defaultOptions = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
|
||||
const res = await fetch(url, { ...defaultOptions, ...options });
|
||||
if (res.ok) {
|
||||
return res.json() as T;
|
||||
}
|
||||
throw new Error(`Error fetching data: ${res.statusText} (${res.status})}`);
|
||||
}
|
||||
|
||||
export async function getPayloadCollection<CollectionType>(
|
||||
url: string | URL,
|
||||
query: any = null,
|
||||
) {
|
||||
const stringifiedQuery = qs.stringify(query, { addQueryPrefix: true });
|
||||
return apiFetch<PayloadCollection<CollectionType>>(url + stringifiedQuery);
|
||||
}
|
||||
|
||||
export async function getPayloadDocument<CollectionType>(url: string | URL) {
|
||||
return apiFetch<CollectionType>(url);
|
||||
}
|
16
apps/web/src/services/api/content.service.ts
Normal file
16
apps/web/src/services/api/content.service.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Layout } from "@turbopress/api/types";
|
||||
import { getPayloadCollection } from "./api.service";
|
||||
|
||||
export async function getContentCollection(query: any = null) {
|
||||
const url = ` ${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/layouts`;
|
||||
return getPayloadCollection<Layout>(url, query);
|
||||
}
|
||||
|
||||
export async function getContentSingle(
|
||||
name: string,
|
||||
): Promise<Layout | undefined> {
|
||||
const pages = await getContentCollection({
|
||||
where: { name: { equals: name } },
|
||||
});
|
||||
if (pages.docs[0]) return pages.docs[0];
|
||||
}
|
2
apps/web/src/services/api/index.ts
Normal file
2
apps/web/src/services/api/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./api.service";
|
||||
export * from "./page.service";
|
16
apps/web/src/services/api/layout.service.ts
Normal file
16
apps/web/src/services/api/layout.service.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import type { Layout } from "@turbopress/api/types";
|
||||
import { getPayloadCollection } from "./api.service";
|
||||
|
||||
export async function getLayoutCollection(query: any = null) {
|
||||
const url = ` ${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/layouts`;
|
||||
return getPayloadCollection<Layout>(url, query);
|
||||
}
|
||||
|
||||
export async function getLayoutSingle(
|
||||
name: string,
|
||||
): Promise<Layout | undefined> {
|
||||
const pages = await getLayoutCollection({
|
||||
where: { name: { equals: name } },
|
||||
});
|
||||
if (pages.docs[0]) return pages.docs[0];
|
||||
}
|
19
apps/web/src/services/api/media.service.ts
Normal file
19
apps/web/src/services/api/media.service.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { Media } from "@turbopress/api/types";
|
||||
import { getPayloadCollection, getPayloadDocument } from "./api.service";
|
||||
|
||||
export async function getMediaCollection(query: any = null) {
|
||||
const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/medias`;
|
||||
return getPayloadCollection<Media>(url, query);
|
||||
}
|
||||
|
||||
export async function getMediaSingle(slug: string): Promise<Media | undefined> {
|
||||
const medias = await getMediaCollection({
|
||||
where: { slug: { equals: slug } },
|
||||
});
|
||||
if (medias.docs[0]) return medias.docs[0];
|
||||
}
|
||||
|
||||
export async function getMediaById(id: string): Promise<Media | undefined> {
|
||||
const url = `${process.env.PAYLOAD_PUBLIC_SERVER_URL}/api/media/` + id;
|
||||
return getPayloadDocument<Media>(url);
|
||||
}
|
14
apps/web/src/services/api/page.service.ts
Normal file
14
apps/web/src/services/api/page.service.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { Page } from "@turbopress/api/types";
|
||||
import { getPayloadCollection } from "./api.service";
|
||||
|
||||
export async function getPageCollection(query: any = null) {
|
||||
const url = `${import.meta.env.PAYLOAD_PUBLIC_SERVER_URL}/api/pages`;
|
||||
return getPayloadCollection<Page>(url, query);
|
||||
}
|
||||
|
||||
export async function getPageSingle(slug: string): Promise<Page | undefined> {
|
||||
const pages = await getPageCollection({
|
||||
where: { slug: { equals: slug } },
|
||||
});
|
||||
if (pages.docs[0]) return pages.docs[0];
|
||||
}
|
22
apps/web/src/types.ts
Normal file
22
apps/web/src/types.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { FormattedElement, FormattedText } from "@turbopress/api/types";
|
||||
|
||||
declare module "slate" {
|
||||
interface CustomTypes {
|
||||
Element: FormattedElement;
|
||||
Text: FormattedText;
|
||||
}
|
||||
}
|
||||
|
||||
export type PayloadCollection<CollectionType = any> = {
|
||||
totalDocs?: number;
|
||||
limit?: number;
|
||||
totalPages?: number;
|
||||
page?: number;
|
||||
pagingCounter?: number;
|
||||
hasPrevPage?: boolean;
|
||||
hasNextPage?: boolean;
|
||||
prevPage?: number;
|
||||
nextPage?: number;
|
||||
hasMore?: boolean;
|
||||
docs: CollectionType[];
|
||||
};
|
7
apps/web/tailwind.config.cjs
Normal file
7
apps/web/tailwind.config.cjs
Normal file
@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
};
|
4
apps/web/tsconfig.json
Normal file
4
apps/web/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"exclude": ["node_modules"]
|
||||
}
|
Reference in New Issue
Block a user