feat: ✨ implement simple landing page with image, tagline, short about text and image gallery
- Add ParkUI - Add lucide-react - Add RichText renderer - Add Moderustic fontmain
parent
c3dd300769
commit
34be1c9ad7
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||||
|
"javascript.preferences.importModuleSpecifier": "non-relative"
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://park-ui.com/registry/latest/schema.json",
|
||||||
|
"jsFramework": "react",
|
||||||
|
"outputPath": "./src/components/ui"
|
||||||
|
}
|
Binary file not shown.
@ -0,0 +1,10 @@
|
|||||||
|
import Carousel from "@/components/ui/carousel";
|
||||||
|
import { Media } from "@/payload-types";
|
||||||
|
import { getPayload } from "@/utils/payload";
|
||||||
|
|
||||||
|
export default async function Gallery() {
|
||||||
|
const payload = await getPayload();
|
||||||
|
const { images } = await payload.findGlobal({ slug: "gallery" });
|
||||||
|
|
||||||
|
return <Carousel images={images.map(({ image }) => image as Media)} w="100%" />;
|
||||||
|
}
|
@ -0,0 +1,54 @@
|
|||||||
|
import { Box, Container, Stack } from "@styled-system/jsx";
|
||||||
|
|
||||||
|
import Gallery from "./gallery";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Media } from "@/payload-types";
|
||||||
|
import { Metadata } from "next";
|
||||||
|
import RichText from "@/components/rich-text";
|
||||||
|
import { css } from "@styled-system/css";
|
||||||
|
import { getPayload } from "@/utils/payload";
|
||||||
|
import { styled } from "@styled-system/jsx";
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const payload = await getPayload();
|
||||||
|
const home = await payload.findGlobal({ slug: "home" });
|
||||||
|
const about = await payload.findGlobal({ slug: "about" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={10} align="center">
|
||||||
|
<Box w="100%" h="80vh" position="relative">
|
||||||
|
<Image
|
||||||
|
src={(home.splashImage as Media)!.url!}
|
||||||
|
alt={about.name}
|
||||||
|
className={css({ objectFit: "cover" })}
|
||||||
|
fill
|
||||||
|
/>
|
||||||
|
<styled.div
|
||||||
|
position="absolute"
|
||||||
|
color="white"
|
||||||
|
top="70%"
|
||||||
|
width="100%"
|
||||||
|
textAlign="center"
|
||||||
|
fontSize={24}
|
||||||
|
>
|
||||||
|
{home.tagline}
|
||||||
|
</styled.div>
|
||||||
|
</Box>
|
||||||
|
<Container>
|
||||||
|
<RichText content={home.aboutText} />
|
||||||
|
</Container>
|
||||||
|
<Box maxW={600} w="100%" h="60vh">
|
||||||
|
<Gallery />
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
const payload = await getPayload();
|
||||||
|
const about = await payload.findGlobal({ slug: "about" });
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: about.name,
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
|
||||||
|
export const Holiday: CollectionConfig = {
|
||||||
|
slug: "holiday",
|
||||||
|
access: {
|
||||||
|
read: () => true,
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
useAsTitle: "title",
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
type: "text",
|
||||||
|
localized: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "from",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
date: {
|
||||||
|
pickerAppearance: "dayOnly",
|
||||||
|
displayFormat: "d MMM yyy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "to",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
date: {
|
||||||
|
pickerAppearance: "dayOnly",
|
||||||
|
displayFormat: "d MMM yyy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
@ -0,0 +1,38 @@
|
|||||||
|
import type { CollectionConfig } from "payload";
|
||||||
|
|
||||||
|
export const Vacation: CollectionConfig = {
|
||||||
|
slug: "vacation",
|
||||||
|
access: {
|
||||||
|
read: () => true,
|
||||||
|
},
|
||||||
|
admin: {
|
||||||
|
useAsTitle: "title",
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "title",
|
||||||
|
type: "text",
|
||||||
|
localized: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "from",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
date: {
|
||||||
|
pickerAppearance: "dayOnly",
|
||||||
|
displayFormat: "d MMM yyy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "to",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
date: {
|
||||||
|
pickerAppearance: "dayOnly",
|
||||||
|
displayFormat: "d MMM yyy",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
@ -0,0 +1,67 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Media } from "@/payload-types";
|
||||||
|
import { css } from "@styled-system/css";
|
||||||
|
import { flex } from "@styled-system/patterns";
|
||||||
|
import { getPayload } from "@/utils/payload";
|
||||||
|
import { styled } from "@styled-system/jsx";
|
||||||
|
|
||||||
|
export default async function Navbar() {
|
||||||
|
const payload = await getPayload();
|
||||||
|
const about = await payload.findGlobal({ slug: "about" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<styled.nav
|
||||||
|
className={flex({ gap: 4 })}
|
||||||
|
bg="white"
|
||||||
|
h={20}
|
||||||
|
px={4}
|
||||||
|
boxShadow="lg"
|
||||||
|
alignItems="center"
|
||||||
|
position="fixed"
|
||||||
|
zIndex={500}
|
||||||
|
top={0}
|
||||||
|
left={0}
|
||||||
|
right={0}
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
{about.logo ? (
|
||||||
|
<Link href="/" className={css({ h: 16, w: 200, position: "relative" })}>
|
||||||
|
<Image
|
||||||
|
src={(about.logo as Media).url!}
|
||||||
|
alt={(about.logo as Media).alt ?? about.name}
|
||||||
|
className={css({ objectFit: "contain", objectPosition: "left" })}
|
||||||
|
fill
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link href="/" className={css({ fontSize: 24 })}>
|
||||||
|
{about.name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<styled.div flexGrow={1} />
|
||||||
|
|
||||||
|
<Link href="/about" className={css({ _hover: { color: "gray.800" } })}>
|
||||||
|
Über uns
|
||||||
|
</Link>
|
||||||
|
<Link href="/menu" className={css({ _hover: { color: "gray.800" } })}>
|
||||||
|
Menü
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/contact"
|
||||||
|
className={css({
|
||||||
|
background: "accent.9",
|
||||||
|
color: "white",
|
||||||
|
p: 2,
|
||||||
|
borderRadius: "md",
|
||||||
|
_hover: {
|
||||||
|
background: "accent.11",
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Kontakt
|
||||||
|
</Link>
|
||||||
|
</styled.nav>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
import { css, cx } from "@styled-system/css";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { container } from "@styled-system/patterns";
|
||||||
|
import { serializeLexical } from "./serialize";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
content: Record<string, any>;
|
||||||
|
enableGutter?: boolean;
|
||||||
|
enableProse?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RichText: React.FC<Props> = ({ className, content, enableGutter = true }) => {
|
||||||
|
if (!content) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cx(enableGutter ? container() : css({ maxW: "none" }), className)}>
|
||||||
|
{content &&
|
||||||
|
!Array.isArray(content) &&
|
||||||
|
typeof content === "object" &&
|
||||||
|
"root" in content &&
|
||||||
|
serializeLexical({ nodes: content?.root?.children })}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RichText;
|
@ -0,0 +1,125 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
//This copy-and-pasted from lexical here: https://github.com/facebook/lexical/blob/c2ceee223f46543d12c574e62155e619f9a18a5d/packages/lexical/src/LexicalConstants.ts
|
||||||
|
|
||||||
|
import type { ElementFormatType, TextFormatType } from "lexical";
|
||||||
|
import type { TextDetailType, TextModeType } from "lexical/nodes/LexicalTextNode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
// DOM
|
||||||
|
export const DOM_ELEMENT_TYPE = 1;
|
||||||
|
export const DOM_TEXT_TYPE = 3;
|
||||||
|
|
||||||
|
// Reconciling
|
||||||
|
export const NO_DIRTY_NODES = 0;
|
||||||
|
export const HAS_DIRTY_NODES = 1;
|
||||||
|
export const FULL_RECONCILE = 2;
|
||||||
|
|
||||||
|
// Text node modes
|
||||||
|
export const IS_NORMAL = 0;
|
||||||
|
export const IS_TOKEN = 1;
|
||||||
|
export const IS_SEGMENTED = 2;
|
||||||
|
// IS_INERT = 3
|
||||||
|
|
||||||
|
// Text node formatting
|
||||||
|
export const IS_BOLD = 1;
|
||||||
|
export const IS_ITALIC = 1 << 1;
|
||||||
|
export const IS_STRIKETHROUGH = 1 << 2;
|
||||||
|
export const IS_UNDERLINE = 1 << 3;
|
||||||
|
export const IS_CODE = 1 << 4;
|
||||||
|
export const IS_SUBSCRIPT = 1 << 5;
|
||||||
|
export const IS_SUPERSCRIPT = 1 << 6;
|
||||||
|
export const IS_HIGHLIGHT = 1 << 7;
|
||||||
|
|
||||||
|
export const IS_ALL_FORMATTING =
|
||||||
|
IS_BOLD |
|
||||||
|
IS_ITALIC |
|
||||||
|
IS_STRIKETHROUGH |
|
||||||
|
IS_UNDERLINE |
|
||||||
|
IS_CODE |
|
||||||
|
IS_SUBSCRIPT |
|
||||||
|
IS_SUPERSCRIPT |
|
||||||
|
IS_HIGHLIGHT;
|
||||||
|
|
||||||
|
// Text node details
|
||||||
|
export const IS_DIRECTIONLESS = 1;
|
||||||
|
export const IS_UNMERGEABLE = 1 << 1;
|
||||||
|
|
||||||
|
// Element node formatting
|
||||||
|
export const IS_ALIGN_LEFT = 1;
|
||||||
|
export const IS_ALIGN_CENTER = 2;
|
||||||
|
export const IS_ALIGN_RIGHT = 3;
|
||||||
|
export const IS_ALIGN_JUSTIFY = 4;
|
||||||
|
export const IS_ALIGN_START = 5;
|
||||||
|
export const IS_ALIGN_END = 6;
|
||||||
|
|
||||||
|
// Reconciliation
|
||||||
|
export const NON_BREAKING_SPACE = "\u00A0";
|
||||||
|
const ZERO_WIDTH_SPACE = "\u200b";
|
||||||
|
|
||||||
|
export const DOUBLE_LINE_BREAK = "\n\n";
|
||||||
|
|
||||||
|
// For FF, we need to use a non-breaking space, or it gets composition
|
||||||
|
// in a stuck state.
|
||||||
|
|
||||||
|
const RTL = "\u0591-\u07FF\uFB1D-\uFDFD\uFE70-\uFEFC";
|
||||||
|
const LTR =
|
||||||
|
"A-Za-z\u00C0-\u00D6\u00D8-\u00F6" +
|
||||||
|
"\u00F8-\u02B8\u0300-\u0590\u0800-\u1FFF\u200E\u2C00-\uFB1C" +
|
||||||
|
"\uFE00-\uFE6F\uFEFD-\uFFFF";
|
||||||
|
|
||||||
|
export const RTL_REGEX = new RegExp("^[^" + LTR + "]*[" + RTL + "]");
|
||||||
|
|
||||||
|
export const LTR_REGEX = new RegExp("^[^" + RTL + "]*[" + LTR + "]");
|
||||||
|
|
||||||
|
export const TEXT_TYPE_TO_FORMAT: Record<TextFormatType | string, number> = {
|
||||||
|
bold: IS_BOLD,
|
||||||
|
code: IS_CODE,
|
||||||
|
highlight: IS_HIGHLIGHT,
|
||||||
|
italic: IS_ITALIC,
|
||||||
|
strikethrough: IS_STRIKETHROUGH,
|
||||||
|
subscript: IS_SUBSCRIPT,
|
||||||
|
superscript: IS_SUPERSCRIPT,
|
||||||
|
underline: IS_UNDERLINE,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DETAIL_TYPE_TO_DETAIL: Record<TextDetailType | string, number> = {
|
||||||
|
directionless: IS_DIRECTIONLESS,
|
||||||
|
unmergeable: IS_UNMERGEABLE,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ELEMENT_TYPE_TO_FORMAT: Record<Exclude<ElementFormatType, "">, number> = {
|
||||||
|
center: IS_ALIGN_CENTER,
|
||||||
|
end: IS_ALIGN_END,
|
||||||
|
justify: IS_ALIGN_JUSTIFY,
|
||||||
|
left: IS_ALIGN_LEFT,
|
||||||
|
right: IS_ALIGN_RIGHT,
|
||||||
|
start: IS_ALIGN_START,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ELEMENT_FORMAT_TO_TYPE: Record<number, ElementFormatType> = {
|
||||||
|
[IS_ALIGN_CENTER]: "center",
|
||||||
|
[IS_ALIGN_END]: "end",
|
||||||
|
[IS_ALIGN_JUSTIFY]: "justify",
|
||||||
|
[IS_ALIGN_LEFT]: "left",
|
||||||
|
[IS_ALIGN_RIGHT]: "right",
|
||||||
|
[IS_ALIGN_START]: "start",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEXT_MODE_TO_TYPE: Record<TextModeType, 0 | 1 | 2> = {
|
||||||
|
normal: IS_NORMAL,
|
||||||
|
segmented: IS_SEGMENTED,
|
||||||
|
token: IS_TOKEN,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TEXT_TYPE_TO_MODE: Record<number, TextModeType> = {
|
||||||
|
[IS_NORMAL]: "normal",
|
||||||
|
[IS_SEGMENTED]: "segmented",
|
||||||
|
[IS_TOKEN]: "token",
|
||||||
|
};
|
@ -0,0 +1,159 @@
|
|||||||
|
import { DefaultNodeTypes, SerializedBlockNode } from "@payloadcms/richtext-lexical";
|
||||||
|
import {
|
||||||
|
IS_BOLD,
|
||||||
|
IS_CODE,
|
||||||
|
IS_ITALIC,
|
||||||
|
IS_STRIKETHROUGH,
|
||||||
|
IS_SUBSCRIPT,
|
||||||
|
IS_SUPERSCRIPT,
|
||||||
|
IS_UNDERLINE,
|
||||||
|
} from "./node-format";
|
||||||
|
import React, { Fragment, JSX } from "react";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export type NodeTypes = DefaultNodeTypes;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
nodes: NodeTypes[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function serializeLexical({ nodes }: Props): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{nodes?.map((node, index): JSX.Element | null => {
|
||||||
|
if (node == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "text") {
|
||||||
|
let text = <React.Fragment key={index}>{node.text}</React.Fragment>;
|
||||||
|
if (node.format & IS_BOLD) {
|
||||||
|
text = <strong key={index}>{text}</strong>;
|
||||||
|
}
|
||||||
|
if (node.format & IS_ITALIC) {
|
||||||
|
text = <em key={index}>{text}</em>;
|
||||||
|
}
|
||||||
|
if (node.format & IS_STRIKETHROUGH) {
|
||||||
|
text = (
|
||||||
|
<span key={index} style={{ textDecoration: "line-through" }}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (node.format & IS_UNDERLINE) {
|
||||||
|
text = (
|
||||||
|
<span key={index} style={{ textDecoration: "underline" }}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (node.format & IS_CODE) {
|
||||||
|
text = <code key={index}>{node.text}</code>;
|
||||||
|
}
|
||||||
|
if (node.format & IS_SUBSCRIPT) {
|
||||||
|
text = <sub key={index}>{text}</sub>;
|
||||||
|
}
|
||||||
|
if (node.format & IS_SUPERSCRIPT) {
|
||||||
|
text = <sup key={index}>{text}</sup>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: Hacky fix for
|
||||||
|
// https://github.com/facebook/lexical/blob/d10c4e6e55261b2fdd7d1845aed46151d0f06a8c/packages/lexical-list/src/LexicalListItemNode.ts#L133
|
||||||
|
// which does not return checked: false (only true - i.e. there is no prop for false)
|
||||||
|
const serializedChildrenFn = (node: NodeTypes): JSX.Element | null => {
|
||||||
|
if (node.children == null) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
if (node?.type === "list" && node?.listType === "check") {
|
||||||
|
for (const item of node.children) {
|
||||||
|
if ("checked" in item) {
|
||||||
|
if (!item?.checked) {
|
||||||
|
item.checked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return serializeLexical({ nodes: node.children as NodeTypes[] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializedChildren = "children" in node ? serializedChildrenFn(node) : "";
|
||||||
|
|
||||||
|
switch (node.type) {
|
||||||
|
case "linebreak": {
|
||||||
|
return <br key={index} />;
|
||||||
|
}
|
||||||
|
case "paragraph": {
|
||||||
|
return (
|
||||||
|
<p key={index}>
|
||||||
|
{serializedChildren}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "heading": {
|
||||||
|
const Tag = node?.tag;
|
||||||
|
return (
|
||||||
|
<Tag key={index}>
|
||||||
|
{serializedChildren}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "list": {
|
||||||
|
const Tag = node?.tag;
|
||||||
|
return (
|
||||||
|
<Tag className="list col-start-2" key={index}>
|
||||||
|
{serializedChildren}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "listitem": {
|
||||||
|
if (node?.checked != null) {
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
aria-checked={node.checked ? "true" : "false"}
|
||||||
|
className={` ${node.checked ? "" : ""}`}
|
||||||
|
key={index}
|
||||||
|
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-to-interactive-role
|
||||||
|
role="checkbox"
|
||||||
|
tabIndex={-1}
|
||||||
|
value={node?.value}
|
||||||
|
>
|
||||||
|
{serializedChildren}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<li key={index} value={node?.value}>
|
||||||
|
{serializedChildren}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "quote": {
|
||||||
|
return (
|
||||||
|
<blockquote className="col-start-2" key={index}>
|
||||||
|
{serializedChildren}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "link": {
|
||||||
|
const fields = node.fields;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a key={index} href={fields.url} target={Boolean(fields?.newTab) ? "_blank" : ""}>
|
||||||
|
{serializedChildren}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
import { forwardRef } from 'react'
|
||||||
|
import { Center, styled } from 'styled-system/jsx'
|
||||||
|
import { Spinner } from './spinner'
|
||||||
|
import { Button as StyledButton, type ButtonProps as StyledButtonProps } from './styled/button'
|
||||||
|
|
||||||
|
interface ButtonLoadingProps {
|
||||||
|
loading?: boolean
|
||||||
|
loadingText?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ButtonProps extends StyledButtonProps, ButtonLoadingProps {}
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>((props, ref) => {
|
||||||
|
const { loading, disabled, loadingText, children, ...rest } = props
|
||||||
|
|
||||||
|
const trulyDisabled = loading || disabled
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledButton disabled={trulyDisabled} ref={ref} {...rest}>
|
||||||
|
{loading && !loadingText ? (
|
||||||
|
<>
|
||||||
|
<ButtonSpinner />
|
||||||
|
<styled.span opacity={0}>{children}</styled.span>
|
||||||
|
</>
|
||||||
|
) : loadingText ? (
|
||||||
|
loadingText
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
|
</StyledButton>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
const ButtonSpinner = () => (
|
||||||
|
<Center inline position="absolute" transform="translate(-50%, -50%)" top="50%" insetStart="50%">
|
||||||
|
<Spinner colorPalette="gray" />
|
||||||
|
</Center>
|
||||||
|
)
|
@ -0,0 +1,63 @@
|
|||||||
|
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Control,
|
||||||
|
Indicator,
|
||||||
|
IndicatorGroup,
|
||||||
|
Item,
|
||||||
|
ItemGroup,
|
||||||
|
NextTrigger,
|
||||||
|
PrevTrigger,
|
||||||
|
Root,
|
||||||
|
RootProps,
|
||||||
|
Viewport,
|
||||||
|
} from "./styled/carousel";
|
||||||
|
|
||||||
|
import { IconButton } from "@/components/ui/icon-button";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Media } from "@/payload-types";
|
||||||
|
import { css } from "@styled-system/css";
|
||||||
|
import { styled } from "@styled-system/jsx";
|
||||||
|
|
||||||
|
export type CarouselProps = RootProps & {
|
||||||
|
images: Media[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Carousel({ images, ...props }: CarouselProps) {
|
||||||
|
return (
|
||||||
|
<Root h="100%" {...props}>
|
||||||
|
<Viewport h="100%">
|
||||||
|
<ItemGroup style={{ height: "100%" }}>
|
||||||
|
{images.map((image, index) => (
|
||||||
|
<Item key={index} index={index}>
|
||||||
|
<styled.div h="100%" width="100%" position="relative">
|
||||||
|
<Image
|
||||||
|
src={image.url!}
|
||||||
|
alt={image.alt ?? ""}
|
||||||
|
className={css({ objectFit: "contain" })}
|
||||||
|
fill
|
||||||
|
/>
|
||||||
|
</styled.div>
|
||||||
|
</Item>
|
||||||
|
))}
|
||||||
|
</ItemGroup>
|
||||||
|
<Control>
|
||||||
|
<PrevTrigger asChild>
|
||||||
|
<IconButton size="sm" variant="link" aria-label="Previous Slide">
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</IconButton>
|
||||||
|
</PrevTrigger>
|
||||||
|
<IndicatorGroup>
|
||||||
|
{images.map((_, index) => (
|
||||||
|
<Indicator key={index} index={index} aria-label={`Goto slide ${index + 1}`} />
|
||||||
|
))}
|
||||||
|
</IndicatorGroup>
|
||||||
|
<NextTrigger asChild>
|
||||||
|
<IconButton size="sm" variant="link" aria-label="Next Slide">
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</IconButton>
|
||||||
|
</NextTrigger>
|
||||||
|
</Control>
|
||||||
|
</Viewport>
|
||||||
|
</Root>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
export { IconButton, type IconButtonProps } from './styled/icon-button'
|
@ -0,0 +1,29 @@
|
|||||||
|
import { forwardRef } from 'react'
|
||||||
|
import { styled } from 'styled-system/jsx'
|
||||||
|
import { Spinner as StyledSpinner, type SpinnerProps as StyledSpinnerProps } from './styled/spinner'
|
||||||
|
|
||||||
|
export interface SpinnerProps extends StyledSpinnerProps {
|
||||||
|
/**
|
||||||
|
* For accessibility, it is important to add a fallback loading text.
|
||||||
|
* This text will be visible to screen readers.
|
||||||
|
* @default "Loading..."
|
||||||
|
*/
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Spinner = forwardRef<HTMLDivElement, SpinnerProps>((props, ref) => {
|
||||||
|
const { label = 'Loading...', ...rest } = props
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledSpinner
|
||||||
|
ref={ref}
|
||||||
|
borderBottomColor="transparent"
|
||||||
|
borderLeftColor="transparent"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{label && <styled.span srOnly>{label}</styled.span>}
|
||||||
|
</StyledSpinner>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
Spinner.displayName = 'Spinner'
|
@ -0,0 +1,7 @@
|
|||||||
|
import { ark } from '@ark-ui/react/factory'
|
||||||
|
import { styled } from 'styled-system/jsx'
|
||||||
|
import { button } from 'styled-system/recipes'
|
||||||
|
import type { ComponentProps } from 'styled-system/types'
|
||||||
|
|
||||||
|
export type ButtonProps = ComponentProps<typeof Button>
|
||||||
|
export const Button = styled(ark.button, button)
|
@ -0,0 +1,62 @@
|
|||||||
|
'use client'
|
||||||
|
import type { Assign } from '@ark-ui/react'
|
||||||
|
import { Carousel } from '@ark-ui/react/carousel'
|
||||||
|
import { type CarouselVariantProps, carousel } from 'styled-system/recipes'
|
||||||
|
import type { ComponentProps, HTMLStyledProps } from 'styled-system/types'
|
||||||
|
import { createStyleContext } from './utils/create-style-context'
|
||||||
|
|
||||||
|
const { withProvider, withContext } = createStyleContext(carousel)
|
||||||
|
|
||||||
|
export type RootProviderProps = ComponentProps<typeof RootProvider>
|
||||||
|
export const RootProvider = withProvider<
|
||||||
|
HTMLDivElement,
|
||||||
|
Assign<Assign<HTMLStyledProps<'div'>, Carousel.RootProviderBaseProps>, CarouselVariantProps>
|
||||||
|
>(Carousel.RootProvider, 'root')
|
||||||
|
|
||||||
|
export type RootProps = ComponentProps<typeof Root>
|
||||||
|
export const Root = withProvider<
|
||||||
|
HTMLDivElement,
|
||||||
|
Assign<Assign<HTMLStyledProps<'div'>, Carousel.RootBaseProps>, CarouselVariantProps>
|
||||||
|
>(Carousel.Root, 'root')
|
||||||
|
|
||||||
|
export const Control = withContext<
|
||||||
|
HTMLDivElement,
|
||||||
|
Assign<HTMLStyledProps<'div'>, Carousel.ControlBaseProps>
|
||||||
|
>(Carousel.Control, 'control')
|
||||||
|
|
||||||
|
export const IndicatorGroup = withContext<
|
||||||
|
HTMLDivElement,
|
||||||
|
Assign<HTMLStyledProps<'div'>, Carousel.IndicatorGroupBaseProps>
|
||||||
|
>(Carousel.IndicatorGroup, 'indicatorGroup')
|
||||||
|
|
||||||
|
export const Indicator = withContext<
|
||||||
|
HTMLButtonElement,
|
||||||
|
Assign<HTMLStyledProps<'button'>, Carousel.IndicatorBaseProps>
|
||||||
|
>(Carousel.Indicator, 'indicator')
|
||||||
|
|
||||||
|
export const ItemGroup = withContext<
|
||||||
|
HTMLDivElement,
|
||||||
|
Assign<HTMLStyledProps<'div'>, Carousel.ItemGroupBaseProps>
|
||||||
|
>(Carousel.ItemGroup, 'itemGroup')
|
||||||
|
|
||||||
|
export const Item = withContext<
|
||||||
|
HTMLDivElement,
|
||||||
|
Assign<HTMLStyledProps<'div'>, Carousel.ItemBaseProps>
|
||||||
|
>(Carousel.Item, 'item')
|
||||||
|
|
||||||
|
export const NextTrigger = withContext<
|
||||||
|
HTMLButtonElement,
|
||||||
|
Assign<HTMLStyledProps<'button'>, Carousel.NextTriggerBaseProps>
|
||||||
|
>(Carousel.NextTrigger, 'nextTrigger')
|
||||||
|
|
||||||
|
export const PrevTrigger = withContext<
|
||||||
|
HTMLButtonElement,
|
||||||
|
Assign<HTMLStyledProps<'button'>, Carousel.PrevTriggerBaseProps>
|
||||||
|
>(Carousel.PrevTrigger, 'prevTrigger')
|
||||||
|
|
||||||
|
export const Viewport = withContext<
|
||||||
|
HTMLDivElement,
|
||||||
|
Assign<HTMLStyledProps<'div'>, Carousel.ViewportBaseProps>
|
||||||
|
>(Carousel.Viewport, 'viewport')
|
||||||
|
|
||||||
|
export { CarouselContext as Context } from '@ark-ui/react/carousel'
|
@ -0,0 +1,9 @@
|
|||||||
|
import { ark } from '@ark-ui/react/factory'
|
||||||
|
import { styled } from 'styled-system/jsx'
|
||||||
|
import { type ButtonVariantProps, button } from 'styled-system/recipes'
|
||||||
|
import type { ComponentProps } from 'styled-system/types'
|
||||||
|
|
||||||
|
export type IconButtonProps = ComponentProps<typeof IconButton>
|
||||||
|
export const IconButton = styled(ark.button, button, {
|
||||||
|
defaultProps: { px: '0' } as ButtonVariantProps,
|
||||||
|
})
|
@ -0,0 +1,7 @@
|
|||||||
|
import { ark } from '@ark-ui/react/factory'
|
||||||
|
import { styled } from 'styled-system/jsx'
|
||||||
|
import { spinner } from 'styled-system/recipes'
|
||||||
|
import type { ComponentProps } from 'styled-system/types'
|
||||||
|
|
||||||
|
export type SpinnerProps = ComponentProps<typeof Spinner>
|
||||||
|
export const Spinner = styled(ark.div, spinner)
|
@ -0,0 +1,95 @@
|
|||||||
|
import {
|
||||||
|
type ElementType,
|
||||||
|
type ForwardRefExoticComponent,
|
||||||
|
type PropsWithoutRef,
|
||||||
|
type RefAttributes,
|
||||||
|
createContext,
|
||||||
|
forwardRef,
|
||||||
|
useContext,
|
||||||
|
} from 'react'
|
||||||
|
import { cx } from 'styled-system/css'
|
||||||
|
import { type StyledComponent, isCssProperty, styled } from 'styled-system/jsx'
|
||||||
|
|
||||||
|
type Props = Record<string, unknown>
|
||||||
|
type Recipe = {
|
||||||
|
(props?: Props): Props
|
||||||
|
splitVariantProps: (props: Props) => [Props, Props]
|
||||||
|
}
|
||||||
|
type Slot<R extends Recipe> = keyof ReturnType<R>
|
||||||
|
type Options = { forwardProps?: string[] }
|
||||||
|
|
||||||
|
const shouldForwardProp = (prop: string, variantKeys: string[], options: Options = {}) =>
|
||||||
|
options.forwardProps?.includes(prop) || (!variantKeys.includes(prop) && !isCssProperty(prop))
|
||||||
|
|
||||||
|
export const createStyleContext = <R extends Recipe>(recipe: R) => {
|
||||||
|
const StyleContext = createContext<Record<Slot<R>, string> | null>(null)
|
||||||
|
|
||||||
|
const withRootProvider = <P extends {}>(Component: ElementType) => {
|
||||||
|
const StyledComponent = (props: P) => {
|
||||||
|
const [variantProps, otherProps] = recipe.splitVariantProps(props)
|
||||||
|
const slotStyles = recipe(variantProps) as Record<Slot<R>, string>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyleContext.Provider value={slotStyles}>
|
||||||
|
<Component {...otherProps} />
|
||||||
|
</StyleContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return StyledComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
const withProvider = <T, P extends { className?: string | undefined }>(
|
||||||
|
Component: ElementType,
|
||||||
|
slot: Slot<R>,
|
||||||
|
options?: Options,
|
||||||
|
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>> => {
|
||||||
|
const StyledComponent = styled(
|
||||||
|
Component,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
shouldForwardProp: (prop, variantKeys) => shouldForwardProp(prop, variantKeys, options),
|
||||||
|
},
|
||||||
|
) as StyledComponent<ElementType>
|
||||||
|
const StyledSlotProvider = forwardRef<T, P>((props, ref) => {
|
||||||
|
const [variantProps, otherProps] = recipe.splitVariantProps(props)
|
||||||
|
const slotStyles = recipe(variantProps) as Record<Slot<R>, string>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyleContext.Provider value={slotStyles}>
|
||||||
|
<StyledComponent
|
||||||
|
{...otherProps}
|
||||||
|
ref={ref}
|
||||||
|
className={cx(slotStyles?.[slot], props.className)}
|
||||||
|
/>
|
||||||
|
</StyleContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
// @ts-expect-error
|
||||||
|
StyledSlotProvider.displayName = Component.displayName || Component.name
|
||||||
|
|
||||||
|
return StyledSlotProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
const withContext = <T, P extends { className?: string | undefined }>(
|
||||||
|
Component: ElementType,
|
||||||
|
slot: Slot<R>,
|
||||||
|
): ForwardRefExoticComponent<PropsWithoutRef<P> & RefAttributes<T>> => {
|
||||||
|
const StyledComponent = styled(Component)
|
||||||
|
const StyledSlotComponent = forwardRef<T, P>((props, ref) => {
|
||||||
|
const slotStyles = useContext(StyleContext)
|
||||||
|
return (
|
||||||
|
<StyledComponent {...props} ref={ref} className={cx(slotStyles?.[slot], props.className)} />
|
||||||
|
)
|
||||||
|
})
|
||||||
|
// @ts-expect-error
|
||||||
|
StyledSlotComponent.displayName = Component.displayName || Component.name
|
||||||
|
|
||||||
|
return StyledSlotComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
withRootProvider,
|
||||||
|
withProvider,
|
||||||
|
withContext,
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
import type { GlobalConfig } from "payload";
|
||||||
|
|
||||||
|
export const Gallery: GlobalConfig = {
|
||||||
|
slug: "gallery",
|
||||||
|
access: {
|
||||||
|
read: () => true,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "images",
|
||||||
|
type: "array",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "image",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "media",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
@ -0,0 +1,26 @@
|
|||||||
|
import type { GlobalConfig } from "payload";
|
||||||
|
|
||||||
|
export const Home: GlobalConfig = {
|
||||||
|
slug: "home",
|
||||||
|
access: {
|
||||||
|
read: () => true,
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: "splashImage",
|
||||||
|
type: "relationship",
|
||||||
|
relationTo: "media",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tagline",
|
||||||
|
type: "text",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "aboutText",
|
||||||
|
type: "richText",
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
@ -0,0 +1,4 @@
|
|||||||
|
import config from "@payload-config";
|
||||||
|
import { getPayloadHMR } from "@payloadcms/next/utilities";
|
||||||
|
|
||||||
|
export const getPayload = async () => await getPayloadHMR({ config });
|
Loading…
Reference in New Issue