template setup 🚀
This commit is contained in:
41
apps/client/.gitignore
vendored
Normal file
41
apps/client/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
apps/client/README.md
Normal file
36
apps/client/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
21
apps/client/components.json
Normal file
21
apps/client/components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
34
apps/client/next.config.ts
Normal file
34
apps/client/next.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
eslint: { ignoreDuringBuilds: true },
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
hostname: "cdn.sanity.io",
|
||||
pathname: "/images/**",
|
||||
protocol: "https",
|
||||
},
|
||||
{
|
||||
hostname: "lh3.googleusercontent.com",
|
||||
pathname: "**",
|
||||
protocol: "https",
|
||||
},
|
||||
],
|
||||
},
|
||||
headers: async () => {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "ALLOWALL",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
45
apps/client/package.json
Normal file
45
apps/client/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"bun": ">=1.2.12"
|
||||
},
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.2.12",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@repo/sanity-connection": "*",
|
||||
"@repo/ui": "*",
|
||||
"@sanity/client": "^7.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"framer-motion": "^12.23.6",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "15.3.5",
|
||||
"next-sanity": "^9.12.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@repo/typescript-config": "*",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.3.5",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
5
apps/client/postcss.config.mjs
Normal file
5
apps/client/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
apps/client/public/manifest.webmanifest
Normal file
14
apps/client/public/manifest.webmanifest
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "/favicon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
]
|
||||
}
|
||||
92
apps/client/src/app/[slug]/page.tsx
Normal file
92
apps/client/src/app/[slug]/page.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { defineQuery } from "next-sanity";
|
||||
import { sanityFetch } from "@/sanity/live";
|
||||
import type { Custom } from "@/sanity/sanity.types";
|
||||
import type { Metadata } from "next";
|
||||
import SanityBlock from "@/components/sanity-block";
|
||||
import { notFound } from "next/navigation";
|
||||
import { fetchSettings } from "@/sanity/settings";
|
||||
|
||||
const CUSTOMS_QUERY = defineQuery(`*[_type == "custom"]{ slug }`);
|
||||
const CUSTOM_QUERY = defineQuery(
|
||||
`*[_type == "custom" && slug.current == $slug][0]`,
|
||||
);
|
||||
|
||||
type PageParams = Promise<{
|
||||
slug: string;
|
||||
}>;
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const customs: Custom[] = (
|
||||
await sanityFetch({
|
||||
query: CUSTOMS_QUERY,
|
||||
stega: false,
|
||||
perspective: "published",
|
||||
})
|
||||
).data;
|
||||
return customs.map((custom) => ({
|
||||
slug: custom.slug?.current ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<PageParams>;
|
||||
}): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
if (!params.slug) {
|
||||
return notFound();
|
||||
}
|
||||
const settings: { title: string } = await fetchSettings("title");
|
||||
const custom: Custom = (
|
||||
await sanityFetch({
|
||||
query: CUSTOM_QUERY,
|
||||
params,
|
||||
stega: false,
|
||||
perspective: "published",
|
||||
})
|
||||
).data;
|
||||
const firstBlock = custom?.body ? custom.body[0] : undefined;
|
||||
let description: string | undefined = undefined;
|
||||
if (firstBlock && firstBlock._type === "block" && firstBlock.children) {
|
||||
description = firstBlock.children.map((child) => child.text).join(" ");
|
||||
}
|
||||
|
||||
const firstWord = custom.title ? custom.title.split(" ")[0] : "";
|
||||
return {
|
||||
title: `${firstWord} | ${settings.title}`,
|
||||
description,
|
||||
openGraph: {
|
||||
title: `${firstWord} | ${settings.title}`,
|
||||
description,
|
||||
type: "article",
|
||||
url: `/${custom.slug}`,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: `${firstWord} | ${settings.title}`,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function CustomPage(props: {
|
||||
params: Promise<PageParams>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const custom: Custom = (await sanityFetch({ query: CUSTOM_QUERY, params }))
|
||||
.data;
|
||||
if (!custom) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container mx-auto min-h-screen max-w-3xl md:max-w-4xl p-8 flex flex-col gap-4">
|
||||
<h2>{custom.title}</h2>
|
||||
<div
|
||||
className="items-start mt-2 mb-8 text-left"
|
||||
style={{ maxWidth: "100%" }}
|
||||
>
|
||||
{custom.body && <SanityBlock body={custom.body} />}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
10
apps/client/src/app/actions.ts
Normal file
10
apps/client/src/app/actions.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
'use server'
|
||||
|
||||
import {draftMode} from 'next/headers'
|
||||
|
||||
export async function disableDraftMode() {
|
||||
const disable = (await draftMode()).disable()
|
||||
const delay = new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
await Promise.allSettled([disable, delay]);
|
||||
}
|
||||
9
apps/client/src/app/api/draft-mode/enable/route.ts
Normal file
9
apps/client/src/app/api/draft-mode/enable/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { client } from "@/sanity/client";
|
||||
import { sanityConnection } from "@repo/sanity-connection";
|
||||
import { defineEnableDraftMode } from "next-sanity/draft-mode";
|
||||
|
||||
export const { GET } = defineEnableDraftMode({
|
||||
client: client.withConfig({
|
||||
token: sanityConnection.publicViewerToken,
|
||||
}),
|
||||
});
|
||||
104
apps/client/src/app/globals.css
Normal file
104
apps/client/src/app/globals.css
Normal file
@@ -0,0 +1,104 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@config "../../tailwind.config.ts";
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--bg-primary);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply font-sans bg-background text-foreground text-lg;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
@apply font-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-4xl md:text-6xl uppercase font-bold;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-3xl font-bold;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl;
|
||||
}
|
||||
h4 {
|
||||
@apply text-lg;
|
||||
}
|
||||
}
|
||||
61
apps/client/src/app/layout.tsx
Normal file
61
apps/client/src/app/layout.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Noto_Sans } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { VisualEditing } from "next-sanity";
|
||||
import { draftMode } from "next/headers";
|
||||
import { SanityLive } from "@/sanity/live";
|
||||
import { fetchSettings } from "@/sanity/settings";
|
||||
import { getImage } from "@/lib/asset-to-url";
|
||||
import Footer from "@/components/footer";
|
||||
|
||||
const sans = Geist({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const settings = await fetchSettings();
|
||||
const logo = await getImage(settings?.logo?.asset?._ref);
|
||||
|
||||
return {
|
||||
title: settings.title,
|
||||
description: settings.description,
|
||||
openGraph: {
|
||||
title: settings.longTitle,
|
||||
description: settings.description
|
||||
},
|
||||
twitter: {
|
||||
title: settings.longTitle,
|
||||
description: settings.description,
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
},
|
||||
icons: {
|
||||
icon: logo.url,
|
||||
apple: logo.url,
|
||||
},
|
||||
manifest: "/manifest.webmanifest",
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const settings = await fetchSettings();
|
||||
return (
|
||||
<html lang="en" className="scroll-smooth">
|
||||
<body
|
||||
className={`${sans.variable} antialiased text-text`}
|
||||
>
|
||||
{children}
|
||||
<Footer settings={settings} />
|
||||
{(await draftMode()).isEnabled && <VisualEditing />}
|
||||
<SanityLive />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
26
apps/client/src/app/page.tsx
Normal file
26
apps/client/src/app/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { sanityFetch } from "@/sanity/live";
|
||||
import CTA from "@/components/section/cta";
|
||||
import { getImage, getImages } from "@/lib/asset-to-url";
|
||||
import { Home } from "@/sanity/sanity.types";
|
||||
|
||||
const HOME_QUERY = `*[_type == "home"][0]`;
|
||||
|
||||
export default async function IndexPage() {
|
||||
const { data: home }: { data: Home } = await sanityFetch({
|
||||
query: HOME_QUERY,
|
||||
});
|
||||
|
||||
const background = await getImage(
|
||||
home.headerSection?.backgroundImage?.asset?._ref
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CTA
|
||||
cta={home.headerSection}
|
||||
background={background}
|
||||
textColor="text-white"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
apps/client/src/components/ColorTest.tsx
Normal file
30
apps/client/src/components/ColorTest.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function ColorTest() {
|
||||
return (
|
||||
<div className="p-8 space-y-4">
|
||||
<h1 className="text-2xl font-bold mb-6">Color Test</h1>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold">Primitives (should work):</h2>
|
||||
<div className="bg-primary1-500 text-white p-2 rounded">bg-primary1-500</div>
|
||||
<div className="bg-primary2-500 text-white p-2 rounded">bg-primary2-500</div>
|
||||
<div className="bg-secondary1-100 p-2 rounded border">bg-secondary1-100</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold">Variables (should work now):</h2>
|
||||
<div className="bg-olive-green text-white p-2 rounded">bg-olive-green</div>
|
||||
<div className="bg-pine-green text-white p-2 rounded">bg-pine-green</div>
|
||||
<div className="bg-light-sage p-2 rounded border">bg-light-sage</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-lg font-semibold">Brand Colors (should work now):</h2>
|
||||
<div className="bg-bg-primary p-2 rounded border">bg-bg-primary</div>
|
||||
<div className="bg-bg-secondary p-2 rounded border">bg-bg-secondary</div>
|
||||
<div className="text-text p-2 rounded border">text-text (blue text)</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
apps/client/src/components/footer.tsx
Normal file
15
apps/client/src/components/footer.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from "react";
|
||||
import { Settings } from "@/sanity/sanity.types";
|
||||
|
||||
export default function Footer({ settings }: { settings: Settings }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<label className="text-sm pb-4 px-8">
|
||||
{settings.footer?.replace(
|
||||
"{YEAR}",
|
||||
new Date().getFullYear().toString()
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
apps/client/src/components/link-button.tsx
Normal file
87
apps/client/src/components/link-button.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowRight, ExternalLink, type LucideIcon } from "lucide-react";
|
||||
import React, { createElement } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function LinkButton({
|
||||
text,
|
||||
linkData,
|
||||
variant = "default",
|
||||
radius = "sm",
|
||||
size = "default",
|
||||
extraIcon,
|
||||
showIcon = true,
|
||||
className,
|
||||
onPress,
|
||||
...props
|
||||
}: {
|
||||
text: string;
|
||||
linkData: { href: string; target: string } | null;
|
||||
variant?:
|
||||
| "ghost"
|
||||
| "default"
|
||||
| "secondary"
|
||||
| "link"
|
||||
| "destructive"
|
||||
| "outline";
|
||||
size?: "default" | "sm" | "lg" | "icon";
|
||||
extraIcon?: LucideIcon;
|
||||
showIcon?: boolean;
|
||||
className?: string;
|
||||
onPress?: () => void;
|
||||
[key: string]: any;
|
||||
}) {
|
||||
const isExternal = linkData?.href?.startsWith("http");
|
||||
|
||||
return (
|
||||
<Button
|
||||
asChild
|
||||
variant={variant}
|
||||
size={size}
|
||||
rel={isExternal ? "noopener noreferrer" : undefined}
|
||||
onClick={onPress}
|
||||
className={cn(
|
||||
className,
|
||||
`
|
||||
group
|
||||
rounded-full
|
||||
font-semibold
|
||||
transition-all
|
||||
duration-300
|
||||
no-underline
|
||||
flex
|
||||
items-center
|
||||
gap-2
|
||||
${showIcon ? "pr-4" : "px-6"}
|
||||
active:transform
|
||||
active:translate-y-[1px]
|
||||
`
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Link
|
||||
href={linkData?.href ?? ""}
|
||||
target={linkData?.target ?? "_self"}
|
||||
>
|
||||
{extraIcon && (
|
||||
<span className="transition-transform duration-300 group-hover:scale-110">
|
||||
{createElement(extraIcon, { size: size === "lg" ? 24 : 20 })}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span>{text}</span>
|
||||
|
||||
{showIcon && (
|
||||
<span className="transition-all duration-300 group-hover:transform group-hover:translate-x-1">
|
||||
{isExternal ? (
|
||||
<ExternalLink size={size === "lg" ? 24 : 20} />
|
||||
) : (
|
||||
<ArrowRight strokeWidth={3} size={size === "lg" ? 24 : 20} />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
243
apps/client/src/components/sanity-block.tsx
Normal file
243
apps/client/src/components/sanity-block.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { PortableText, createDataAttribute, stegaClean } from "next-sanity";
|
||||
import {
|
||||
getFileAsset,
|
||||
getImageAsset,
|
||||
getImageDimensions,
|
||||
SanityFileAsset,
|
||||
} from "@sanity/asset-utils";
|
||||
import type {
|
||||
BlockContent,
|
||||
Button as SanityButton,
|
||||
SanityImageAsset,
|
||||
} from "@/sanity/sanity.types";
|
||||
import { client } from "@/sanity/client";
|
||||
import LinkButton from "./link-button";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import { useDeconstructLink } from "@/lib/link-client";
|
||||
import { dynamicHeight, generateImageUrl } from "@/lib/image-url";
|
||||
import { Button } from "./ui/button";
|
||||
import { sanityConnection } from "@repo/sanity-connection";
|
||||
|
||||
const { projectId, dataset } = client.config();
|
||||
|
||||
export function Callout(props: any) {
|
||||
if (!props) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="bg-secondary-500 text-secondary-50 shadow-lg rounded-md p-6 border my-6 border-gray-200">
|
||||
<PortableText
|
||||
value={props.value.content}
|
||||
components={{
|
||||
block: {
|
||||
h1: ({ children }: any) => <h1>{children}</h1>,
|
||||
h2: ({ children }: any) => <h2>{children}</h2>,
|
||||
h3: ({ children }: any) => <h3>{children}</h3>,
|
||||
h4: ({ children }: any) => <h4>{children}</h4>,
|
||||
h5: ({ children }: any) => <h5>{children}</h5>,
|
||||
h6: ({ children }: any) => <h6>{children}</h6>,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortableButton(value: { value: SanityButton }) {
|
||||
const linkData = useDeconstructLink(value.value.link);
|
||||
return (
|
||||
<div className="flex justify-left">
|
||||
<LinkButton
|
||||
className="px-5"
|
||||
text={value.value.text ?? ""}
|
||||
color={"default"}
|
||||
linkData={linkData}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortableImage({
|
||||
value,
|
||||
isInline,
|
||||
}: {
|
||||
value: SanityImageAsset;
|
||||
isInline: boolean;
|
||||
}) {
|
||||
const { width, height } = getImageDimensions(value);
|
||||
const imageAsset = getImageAsset(value, {
|
||||
projectId: sanityConnection.projectId ?? "",
|
||||
dataset: sanityConnection.dataset ?? "",
|
||||
});
|
||||
const attr = createDataAttribute({
|
||||
id: imageAsset._id,
|
||||
type: imageAsset._type,
|
||||
workspace: "production",
|
||||
});
|
||||
|
||||
return (
|
||||
<Image
|
||||
data-sanity={attr("body")}
|
||||
src={generateImageUrl(value)}
|
||||
width={
|
||||
isInline ? (width >= 100 ? 100 : width) : width >= 1200 ? 1200 : width
|
||||
}
|
||||
height={dynamicHeight(height, width, isInline)}
|
||||
alt={value.altText || " "}
|
||||
loading="lazy"
|
||||
className="border rounded-md shadow-md"
|
||||
style={{
|
||||
display: isInline ? "inline-block" : "block",
|
||||
aspectRatio: width / height,
|
||||
maxHeight: "400px",
|
||||
maxWidth: "100%",
|
||||
width: "auto",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortableFile({ value }: { value: SanityFileAsset }) {
|
||||
const fileAsset = getFileAsset(value, {
|
||||
projectId: projectId ?? "",
|
||||
dataset: dataset ?? "",
|
||||
});
|
||||
const attr = createDataAttribute({
|
||||
id: fileAsset._id,
|
||||
type: fileAsset._type,
|
||||
workspace: "production",
|
||||
});
|
||||
|
||||
const filename: string =
|
||||
fileAsset?.originalFilename ||
|
||||
`${fileAsset.extension.charAt(0).toUpperCase() + fileAsset.extension?.slice(1)} herunterladen`;
|
||||
|
||||
return (
|
||||
<div className="flex justify-left">
|
||||
<LinkButton
|
||||
linkData={{ href: fileAsset.url, target: "" }}
|
||||
data-sanity={attr("body")}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
download
|
||||
showIcon={false}
|
||||
extraIcon={DownloadIcon}
|
||||
text={
|
||||
filename.length > 20
|
||||
? `${filename.slice(0, 10)}...${filename.slice(-10)}`
|
||||
: filename
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomLink(props: { children: React.ReactNode; value?: any }) {
|
||||
const { value, children } = props;
|
||||
|
||||
let linkUrl = null;
|
||||
let target = "_self";
|
||||
|
||||
if (value?.href) {
|
||||
const href = value.href;
|
||||
|
||||
if (href.type === "external" && href.url) {
|
||||
linkUrl = href.url;
|
||||
target = href.blank ? "_blank" : "_self";
|
||||
} else if (href.type === "internal" || href._type === "reference") {
|
||||
const resolved = useDeconstructLink(href);
|
||||
if (resolved) {
|
||||
const ButtonComponent = Button as any;
|
||||
return (
|
||||
<ButtonComponent variant="link" className="p-0 h-auto font-normal" asChild>
|
||||
<Link href={resolved}>
|
||||
{children}
|
||||
</Link>
|
||||
</ButtonComponent>
|
||||
);
|
||||
}
|
||||
} else if (typeof href === "string") {
|
||||
linkUrl = href;
|
||||
}
|
||||
}
|
||||
|
||||
if (linkUrl) {
|
||||
const ButtonComponent = Button as any;
|
||||
return (
|
||||
<ButtonComponent variant="link" className="p-0 h-auto font-normal" asChild>
|
||||
<Link
|
||||
href={linkUrl}
|
||||
target={target}
|
||||
rel={target === "_blank" ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
</ButtonComponent>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const components = {
|
||||
types: {
|
||||
image: PortableImage,
|
||||
button: PortableButton,
|
||||
callout: Callout,
|
||||
file: PortableFile,
|
||||
},
|
||||
block: {
|
||||
h1: ({ children }: any) => <h1>{children}</h1>,
|
||||
h2: ({ children }: any) => <h2>{children}</h2>,
|
||||
h3: ({ children }: any) => <h3>{children}</h3>,
|
||||
h4: ({ children }: any) => <h4>{children}</h4>,
|
||||
h5: ({ children }: any) => <h5>{children}</h5>,
|
||||
h6: ({ children }: any) => <h6>{children}</h6>,
|
||||
},
|
||||
marks: {
|
||||
left: ({ children }: { children: React.ReactNode }) => (
|
||||
<span style={{ textAlign: "left", width: "100%", display: "block" }}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
center: ({ children }: { children: React.ReactNode }) => (
|
||||
<span style={{ textAlign: "center", width: "100%", display: "block" }}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
right: ({ children }: { children: React.ReactNode }) => (
|
||||
<span style={{ textAlign: "right", width: "100%", display: "block" }}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
textColor: ({
|
||||
children,
|
||||
value = { value: "" },
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value?: { value: string };
|
||||
}) => <span style={{ color: stegaClean(value.value) }}>{children}</span>,
|
||||
highlightColor: ({
|
||||
children,
|
||||
value = { value: "" },
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value?: { value: string };
|
||||
}) => (
|
||||
<span style={{ background: stegaClean(value.value) }}>{children}</span>
|
||||
),
|
||||
link: CustomLink,
|
||||
},
|
||||
};
|
||||
|
||||
export default function SanityBlock({ body }: { body: BlockContent }) {
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
return <PortableText value={body} components={components} />;
|
||||
}
|
||||
126
apps/client/src/components/section/cta.tsx
Normal file
126
apps/client/src/components/section/cta.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import LinkButton from "../link-button";
|
||||
import { motion } from "framer-motion";
|
||||
import { CtaSection } from "@/sanity/sanity.types";
|
||||
import { useDeconstructLink } from "@/lib/link-client";
|
||||
import { SimpleImage } from "@/lib/asset-to-url";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { generateImageUrl } from "@/lib/image-url";
|
||||
import SanityBlock from "../sanity-block";
|
||||
|
||||
interface CTAProps {
|
||||
cta?: CtaSection;
|
||||
sectionTitle?: string;
|
||||
background: SimpleImage | any;
|
||||
backgroundColor?: string;
|
||||
textColor?: string;
|
||||
}
|
||||
|
||||
export default function CTA({
|
||||
cta,
|
||||
background,
|
||||
sectionTitle,
|
||||
backgroundColor = "bg-gray/80",
|
||||
textColor,
|
||||
}: CTAProps) {
|
||||
const linkData = useDeconstructLink(cta?.button?.link);
|
||||
|
||||
const backgroundImageUrl = background
|
||||
? (() => {
|
||||
if (background.url) {
|
||||
return generateImageUrl(
|
||||
background,
|
||||
background.dimensions?.width,
|
||||
background.dimensions?.height
|
||||
);
|
||||
} else if (background.asset) {
|
||||
const assetId = background.asset._ref || background.asset;
|
||||
const match = assetId.match(/-(\d+)x(\d+)$/);
|
||||
const width = match ? parseInt(match[1]) : 1920;
|
||||
const height = match ? parseInt(match[2]) : 1080;
|
||||
return generateImageUrl(background, width, height);
|
||||
}
|
||||
return undefined;
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
style={{
|
||||
backgroundImage: backgroundImageUrl
|
||||
? `url(${backgroundImageUrl})`
|
||||
: undefined,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}}
|
||||
aria-label={background?.alt}
|
||||
className={cn(
|
||||
"md:min-h-screen flex flex-col md:flex-row w-full overflow-hidden relative",
|
||||
textColor
|
||||
)}
|
||||
>
|
||||
<div className={cn("absolute inset-0 z-0", backgroundColor)} />
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className="z-10 flex flex-col items-center md:items-start justify-center md:justify-start mt-20 px-6 md:px-20 md:py-32 md:flex-1 space-y-12 min-h-screen md:min-h-0"
|
||||
>
|
||||
<motion.div>
|
||||
<motion.label
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
className="text-sm"
|
||||
>
|
||||
{sectionTitle}
|
||||
</motion.label>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.5 }}
|
||||
className="md:max-w-[60rem] text-6xl md:text-8xl"
|
||||
>
|
||||
{cta?.title}
|
||||
</motion.h1>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4, duration: 0.5 }}
|
||||
className="flex flex-col items-start space-y-20 md:space-y-14 w-full max-w-xl"
|
||||
>
|
||||
{cta?.description && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6, duration: 0.5 }}
|
||||
>
|
||||
<SanityBlock body={cta.description} />
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8, duration: 0.5 }}
|
||||
>
|
||||
<LinkButton
|
||||
className="mb-20"
|
||||
text={cta?.button?.text ?? ""}
|
||||
color="primary"
|
||||
linkData={linkData}
|
||||
size="lg"
|
||||
variant="default"
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
66
apps/client/src/components/ui/button.tsx
Normal file
66
apps/client/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
if (asChild) {
|
||||
return (
|
||||
<Slot
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...(props as any)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
54
apps/client/src/lib/asset-to-url.ts
Normal file
54
apps/client/src/lib/asset-to-url.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { sanityFetch } from "@/sanity/live";
|
||||
import { getImageDimensions, SanityImageDimensions } from "@sanity/asset-utils";
|
||||
import { ImageWithAlt } from "@/sanity/sanity.types";
|
||||
import { generateImageUrl } from "./image-url";
|
||||
|
||||
export type SimpleImage = {
|
||||
url: string;
|
||||
alt: string;
|
||||
dimensions: SanityImageDimensions;
|
||||
};
|
||||
|
||||
// Internal helper to fetch image data from Sanity
|
||||
async function fetchImage(assetRef: string | undefined): Promise<ImageWithAlt | null> {
|
||||
if (!assetRef) return null;
|
||||
|
||||
const { data: image } = await sanityFetch({
|
||||
query: `*[_id == $id][0]`,
|
||||
params: { id: assetRef },
|
||||
});
|
||||
return image;
|
||||
}
|
||||
|
||||
// Internal helper to get dimensions safely
|
||||
function getDimensions(image: ImageWithAlt | any): SanityImageDimensions {
|
||||
try {
|
||||
if (image._type === "imageWithAlt") {
|
||||
const compatibleImage = { ...image, asset: image.asset };
|
||||
return getImageDimensions(compatibleImage as any);
|
||||
}
|
||||
return getImageDimensions(image);
|
||||
} catch {
|
||||
return { width: 1200, height: 800, aspectRatio: 1.5 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Convert a single asset reference to SimpleImage
|
||||
export async function getImage(assetRef: string | undefined): Promise<SimpleImage> {
|
||||
const image = await fetchImage(assetRef);
|
||||
if (!image) return { url: "", alt: "", dimensions: { width: 0, height: 0, aspectRatio: 1 } };
|
||||
|
||||
const dimensions = getDimensions(image);
|
||||
|
||||
return {
|
||||
url: generateImageUrl(image),
|
||||
alt: image.alt || "",
|
||||
dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
// Convert multiple asset references to SimpleImage array
|
||||
export async function getImages(assetRefs: (string | undefined)[]): Promise<SimpleImage[]> {
|
||||
return Promise.all(assetRefs.map(ref => getImage(ref)));
|
||||
}
|
||||
85
apps/client/src/lib/image-url.ts
Normal file
85
apps/client/src/lib/image-url.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { client } from "@/sanity/client";
|
||||
import { ImageWithAlt, SanityImageAsset } from "@/sanity/sanity.types";
|
||||
|
||||
import imageUrlBuilder from "@sanity/image-url";
|
||||
const { projectId, dataset } = client.config();
|
||||
const builder = imageUrlBuilder({ projectId: projectId ?? "", dataset: dataset ?? "" });
|
||||
|
||||
export function generateImageUrl(image: ImageWithAlt | any, width?: number, height?: number): string {
|
||||
if (!image || !projectId || !dataset) return "";
|
||||
if (image.url && image.crop && image.dimensions && width && height) {
|
||||
const imageRef = image.asset?._ref || image.asset;
|
||||
|
||||
if (imageRef) {
|
||||
const crop = image.crop;
|
||||
|
||||
let imageBuilder = builder
|
||||
.image(imageRef)
|
||||
.fit("max")
|
||||
.width(1920)
|
||||
.format("webp")
|
||||
.auto("format");
|
||||
|
||||
if (crop && (crop.top || crop.bottom || crop.left || crop.right)) {
|
||||
const croppedWidth = Math.floor(width * (1 - (crop.right + crop.left)));
|
||||
const croppedHeight = Math.floor(height * (1 - (crop.top + crop.bottom)));
|
||||
|
||||
const left = Math.floor(width * crop.left);
|
||||
const top = Math.floor(height * crop.top);
|
||||
|
||||
imageBuilder = imageBuilder.rect(left, top, croppedWidth, croppedHeight);
|
||||
} else {
|
||||
}
|
||||
|
||||
const url = imageBuilder.url();
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
if (image.url && !image.crop) {
|
||||
return image.url;
|
||||
}
|
||||
|
||||
if (image._type === "imageWithAlt" || image.asset) {
|
||||
const imageRef = image.asset?._ref || image.asset;
|
||||
const crop = image.crop;
|
||||
|
||||
let imageBuilder = builder
|
||||
.image(imageRef)
|
||||
.fit("max")
|
||||
.width(1920)
|
||||
.format("webp")
|
||||
.auto("format");
|
||||
|
||||
if (crop && (crop.top || crop.bottom || crop.left || crop.right) && width && height) {
|
||||
const croppedWidth = Math.floor(width * (1 - (crop.right + crop.left)));
|
||||
const croppedHeight = Math.floor(height * (1 - (crop.top + crop.bottom)));
|
||||
|
||||
const left = Math.floor(width * crop.left);
|
||||
const top = Math.floor(height * crop.top);
|
||||
|
||||
imageBuilder = imageBuilder.rect(left, top, croppedWidth, croppedHeight);
|
||||
}
|
||||
const url = imageBuilder.url();
|
||||
return url;
|
||||
}
|
||||
|
||||
if (image._type === "imageWithAlt" && !image.asset) return "";
|
||||
|
||||
return builder
|
||||
.image(image as any)
|
||||
.fit("max")
|
||||
.width(1920)
|
||||
.format("webp")
|
||||
.auto("format")
|
||||
.url();
|
||||
}
|
||||
|
||||
export function dynamicHeight(
|
||||
originalHeight: number,
|
||||
originalWidth: number,
|
||||
isInline: boolean
|
||||
) {
|
||||
const targetWidth = isInline ? 100 : Math.min(originalWidth, 1200);
|
||||
return (targetWidth * originalHeight) / originalWidth;
|
||||
}
|
||||
51
apps/client/src/lib/link-client.ts
Normal file
51
apps/client/src/lib/link-client.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { deconstructLink, resolveHref } from "./link-helper";
|
||||
import { Link } from "@/sanity/sanity.types";
|
||||
|
||||
interface InternalLink {
|
||||
_ref: string;
|
||||
_type: string;
|
||||
_weak?: boolean;
|
||||
}
|
||||
|
||||
const useResolveHref = (internalLink: InternalLink) => {
|
||||
const [href, setHref] = useState<{ type: string; slug?: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchHref = async () => {
|
||||
const resolvedHref = await resolveHref(internalLink);
|
||||
setHref(resolvedHref);
|
||||
};
|
||||
|
||||
fetchHref();
|
||||
}, [internalLink]);
|
||||
|
||||
return href;
|
||||
};
|
||||
|
||||
|
||||
|
||||
const useDeconstructLink = (link?: Link) => {
|
||||
const [deconstLink, setdeconstLink] = useState<{
|
||||
href: string;
|
||||
target: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!link) return;
|
||||
|
||||
const fetchHref = async () => {
|
||||
const resolvedLink = await deconstructLink(link);
|
||||
setdeconstLink(resolvedLink);
|
||||
};
|
||||
|
||||
fetchHref();
|
||||
}, [link]);
|
||||
|
||||
return deconstLink;
|
||||
}
|
||||
|
||||
|
||||
export { useResolveHref, useDeconstructLink };
|
||||
62
apps/client/src/lib/link-helper.ts
Normal file
62
apps/client/src/lib/link-helper.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Link } from "@/sanity/sanity.types";
|
||||
import { client } from "@/sanity/client";
|
||||
import { defineQuery } from "next-sanity";
|
||||
|
||||
interface InternalLink {
|
||||
_ref: string;
|
||||
_type: string;
|
||||
_weak?: boolean;
|
||||
}
|
||||
|
||||
export const resolveHref = async (internalLink: InternalLink) => {
|
||||
if (!internalLink || !internalLink._ref) return null;
|
||||
|
||||
const QUERY = defineQuery('*[_id == $ref][0]{ _type, slug }');
|
||||
|
||||
try {
|
||||
const result: any = await client.fetch(QUERY, { ref: internalLink._ref });
|
||||
return result ? { type: result._type, slug: result.slug?.current } : null;
|
||||
} catch (error) {
|
||||
console.error("Sanity query failed:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export async function deconstructLink(
|
||||
link?: Link,
|
||||
): Promise<{ href: string; target: string } | null> {
|
||||
if (!link) return null;
|
||||
|
||||
const { type, value, anchor, parameters, blank, url, email, phone, internalLink } = link;
|
||||
const target = blank ? "_blank" : "";
|
||||
let href = "";
|
||||
|
||||
switch (type) {
|
||||
case "static":
|
||||
href = `${value || ""}${anchor || ""}${parameters || ""}`;
|
||||
break;
|
||||
case "external":
|
||||
href = `${url || ""}${anchor || ""}${parameters || ""}`;
|
||||
break;
|
||||
case "email":
|
||||
href = `mailto:${email || ""}`;
|
||||
break;
|
||||
case "phone":
|
||||
href = `tel:${phone || ""}`;
|
||||
break;
|
||||
case "internal":
|
||||
if (internalLink) {
|
||||
const resolved = await resolveHref(internalLink)
|
||||
if (resolved) {
|
||||
href = resolved.type === "custom"
|
||||
? `/${resolved.slug || ""}`
|
||||
: `/${resolved.type}/${resolved.slug || ""}`;
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return { href, target };
|
||||
}
|
||||
6
apps/client/src/lib/utils.ts
Normal file
6
apps/client/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
14
apps/client/src/sanity/client.ts
Normal file
14
apps/client/src/sanity/client.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { sanityConnection } from "@repo/sanity-connection";
|
||||
import { createClient } from "next-sanity";
|
||||
|
||||
export const client = createClient({
|
||||
projectId: sanityConnection.projectId,
|
||||
dataset: sanityConnection.dataset,
|
||||
apiVersion: "2024-12-01",
|
||||
useCdn: false,
|
||||
token: sanityConnection.publicViewerToken,
|
||||
ignoreBrowserTokenWarning: true,
|
||||
stega: {
|
||||
studioUrl: sanityConnection.studioUrl,
|
||||
},
|
||||
});
|
||||
9
apps/client/src/sanity/live.ts
Normal file
9
apps/client/src/sanity/live.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineLive } from "next-sanity";
|
||||
import { client } from "./client";
|
||||
import { sanityConnection } from "@repo/sanity-connection";
|
||||
|
||||
export const { sanityFetch, SanityLive } = defineLive({
|
||||
client,
|
||||
serverToken: sanityConnection.publicViewerToken,
|
||||
browserToken: sanityConnection.publicViewerToken,
|
||||
});
|
||||
512
apps/client/src/sanity/sanity.types.ts
Normal file
512
apps/client/src/sanity/sanity.types.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
/**
|
||||
* ---------------------------------------------------------------------------------
|
||||
* This file has been generated by Sanity TypeGen.
|
||||
* Command: `sanity typegen generate`
|
||||
*
|
||||
* Any modifications made directly to this file will be overwritten the next time
|
||||
* the TypeScript definitions are generated. Please make changes to the Sanity
|
||||
* schema definitions and/or GROQ queries if you need to update these types.
|
||||
*
|
||||
* For more information on how to use Sanity TypeGen, visit the official documentation:
|
||||
* https://www.sanity.io/docs/sanity-typegen
|
||||
* ---------------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// Source: schema.json
|
||||
export type Home = {
|
||||
_id: string;
|
||||
_type: "home";
|
||||
_createdAt: string;
|
||||
_updatedAt: string;
|
||||
_rev: string;
|
||||
title?: string;
|
||||
headerSection?: CtaSection;
|
||||
};
|
||||
|
||||
export type FaqSection = {
|
||||
_type: "faqSection";
|
||||
sectionTitle?: string;
|
||||
title?: string;
|
||||
faqs?: Array<{
|
||||
_key: string;
|
||||
} & Faq>;
|
||||
};
|
||||
|
||||
export type CtaSection = {
|
||||
_type: "ctaSection";
|
||||
backgroundImage?: {
|
||||
asset?: {
|
||||
_ref: string;
|
||||
_type: "reference";
|
||||
_weak?: boolean;
|
||||
[internalGroqTypeReferenceTo]?: "sanity.imageAsset";
|
||||
};
|
||||
media?: unknown;
|
||||
hotspot?: SanityImageHotspot;
|
||||
crop?: SanityImageCrop;
|
||||
alt?: string;
|
||||
_type: "imageWithAlt";
|
||||
};
|
||||
title?: string;
|
||||
description?: Array<{
|
||||
children?: Array<{
|
||||
marks?: Array<string>;
|
||||
text?: string;
|
||||
_type: "span";
|
||||
_key: string;
|
||||
}>;
|
||||
style?: "normal" | "h1" | "h2" | "h3" | "h4" | "blockquote";
|
||||
listItem?: "bullet";
|
||||
markDefs?: Array<{
|
||||
href?: Link;
|
||||
_type: "link";
|
||||
_key: string;
|
||||
} | {
|
||||
_key: string;
|
||||
} & TextColor | {
|
||||
_key: string;
|
||||
} & HighlightColor>;
|
||||
level?: number;
|
||||
_type: "block";
|
||||
_key: string;
|
||||
} | {
|
||||
_key: string;
|
||||
} & Button | {
|
||||
asset?: {
|
||||
_ref: string;
|
||||
_type: "reference";
|
||||
_weak?: boolean;
|
||||
[internalGroqTypeReferenceTo]?: "sanity.imageAsset";
|
||||
};
|
||||
media?: unknown;
|
||||
hotspot?: SanityImageHotspot;
|
||||
crop?: SanityImageCrop;
|
||||
_type: "image";
|
||||
_key: string;
|
||||
} | {
|
||||
asset?: {
|
||||
_ref: string;
|
||||
_type: "reference";
|
||||
_weak?: boolean;
|
||||
[internalGroqTypeReferenceTo]?: "sanity.fileAsset";
|
||||
};
|
||||
media?: unknown;
|
||||
_type: "file";
|
||||
_key: string;
|
||||
}>;
|
||||
button?: Button;
|
||||
};
|
||||
|
||||
export type ContactSection = {
|
||||
_type: "contactSection";
|
||||
title?: string;
|
||||
contactMethods?: Array<{
|
||||
type?: "email" | "phone" | "address" | "social";
|
||||
label?: string;
|
||||
value?: string;
|
||||
link?: Link;
|
||||
_key: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ImageWithAlt = {
|
||||
_type: "imageWithAlt";
|
||||
asset?: {
|
||||
_ref: string;
|
||||
_type: "reference";
|
||||
_weak?: boolean;
|
||||
[internalGroqTypeReferenceTo]?: "sanity.imageAsset";
|
||||
};
|
||||
media?: unknown;
|
||||
hotspot?: SanityImageHotspot;
|
||||
crop?: SanityImageCrop;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export type Faq = {
|
||||
_type: "faq";
|
||||
question?: string;
|
||||
answer?: Array<{
|
||||
children?: Array<{
|
||||
marks?: Array<string>;
|
||||
text?: string;
|
||||
_type: "span";
|
||||
_key: string;
|
||||
}>;
|
||||
style?: "normal" | "h1" | "h2" | "h3" | "h4" | "blockquote";
|
||||
listItem?: "bullet";
|
||||
markDefs?: Array<{
|
||||
href?: Link;
|
||||
_type: "link";
|
||||
_key: string;
|
||||
} | {
|
||||
_key: string;
|
||||
} & TextColor | {
|
||||
_key: string;
|
||||
} & HighlightColor>;
|
||||
level?: number;
|
||||
_type: "block";
|
||||
_key: string;
|
||||
} | {
|
||||
_key: string;
|
||||
} & Button | {
|
||||
asset?: {
|
||||
_ref: string;
|
||||
_type: "reference";
|
||||
_weak?: boolean;
|
||||
[internalGroqTypeReferenceTo]?: "sanity.imageAsset";
|
||||
};
|
||||
media?: unknown;
|
||||
hotspot?: SanityImageHotspot;
|
||||
crop?: SanityImageCrop;
|
||||
_type: "image";
|
||||
_key: string;
|
||||
} | {
|
||||
asset?: {
|
||||
_ref: string;
|
||||
_type: "reference";
|
||||
_weak?: boolean;
|
||||
[internalGroqTypeReferenceTo]?: "sanity.fileAsset";
|
||||
};
|
||||
media?: unknown;
|
||||
_type: "file";
|
||||
_key: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type Button = {
|
||||
_type: "button";
|
||||
text?: string;
|
||||
link?: Link;
|
||||
};
|
||||
|
||||
export type Settings = {
|
||||
_id: string;
|
||||
_type: "settings";
|
||||
_createdAt: string;
|
||||
_updatedAt: string;
|
||||
_rev: string;
|
||||
title?: string;
|
||||
longTitle?: string;
|
||||
description?: string;
|
||||
logo?: {
|
||||
asset?: {
|
||||
_ref: string;
|
||||
_type: "reference";
|
||||
_weak?: boolean;
|
||||
[internalGroqTypeReferenceTo]?: "sanity.imageAsset";
|
||||
};
|
||||
media?: unknown;
|
||||
hotspot?: SanityImageHotspot;
|
||||
crop?: SanityImageCrop;
|
||||
_type: "image";
|
||||
};
|
||||
favicon?: {
|
||||
asset?: {
|
||||
_ref: string;
|
||||
_type: "reference";
|
||||
_weak?: boolean;
|
||||
[internalGroqTypeReferenceTo]?: "sanity.imageAsset";
|
||||
};
|
||||
media?: unknown;
|
||||
hotspot?: SanityImageHotspot;
|
||||
crop?: SanityImageCrop;
|
||||
_type: "image";
|
||||
};
|
||||
footer?: string;
|
||||
};
|
||||
|
||||
export type BlockContent = Array<{
|
||||
children?: Array<{
|
||||
marks?: Array<string>;
|
||||
text?: string;
|
||||
_type: "span";
|
||||
_key: string;
|
||||
}>;
|
||||
style?: "normal" | "h1" | "h2" | "h3" | "h4" | "blockquote";
|
||||
listItem?: "bullet";
|
||||
markDefs?: Array<{
|
||||
href?: Link;
|
||||
_type: "link";
|
||||
_key: string;
|
||||
} | {
|
||||
_key: string;
|
||||
} & TextColor | {
|
||||
_key: string;
|
||||
} & HighlightColor>;
|
||||
level?: number;
|
||||
_type: "block";
|
||||
_key: string;
|
||||
} | {
|
||||
_key: string;
|
||||
} & Button | {
|
||||
asset?: {
|
||||
_ref: string;
|
||||
_type: "reference";
|
||||
_weak?: boolean;
|
||||
[internalGroqTypeReferenceTo]?: "sanity.imageAsset";
|
||||
};
|
||||
media?: unknown;
|
||||
hotspot?: SanityImageHotspot;
|
||||
crop?: SanityImageCrop;
|
||||
_type: "image";
|
||||
_key: string;
|
||||
} | {
|
||||
asset?: {
|
||||
_ref: string;
|
||||
_type: "reference";
|
||||
_weak?: boolean;
|
||||
[internalGroqTypeReferenceTo]?: "sanity.fileAsset";
|
||||
};
|
||||
media?: unknown;
|
||||
_type: "file";
|
||||
_key: string;
|
||||
}>;
|
||||
|
||||
export type HighlightColor = {
|
||||
_type: "highlightColor";
|
||||
label?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export type TextColor = {
|
||||
_type: "textColor";
|
||||
label?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export type SimplerColor = {
|
||||
_type: "simplerColor";
|
||||
label?: string;
|
||||
value?: string;
|
||||
};
|
||||
|
||||
export type MetaTag = {
|
||||
_type: "metaTag";
|
||||
metaAttributes?: Array<{
|
||||
_key: string;
|
||||
} & MetaAttribute>;
|
||||
};
|
||||
|
||||
export type MetaAttribute = {
|
||||
_type: "metaAttribute";
|
||||
attributeKey?: string;
|
||||
attributeType?: "string" | "image";
|
||||
attributeValueImage?: {
|
||||
asset?: {
|
||||
_ref: string;
|
||||
_type: "reference";
|
||||
_weak?: boolean;
|
||||
[internalGroqTypeReferenceTo]?: "sanity.imageAsset";
|
||||
};
|
||||
media?: unknown;
|
||||
hotspot?: SanityImageHotspot;
|
||||
crop?: SanityImageCrop;
|
||||
_type: "image";
|
||||
};
|
||||
attributeValueString?: string;
|
||||
};
|
||||
|
||||
export type SeoMetaFields = {
|
||||
_type: "seoMetaFields";
|
||||
nofollowAttributes?: boolean;
|
||||
metaTitle?: string;
|
||||
metaDescription?: string;
|
||||
metaImage?: {
|
||||
asset?: {
|
||||
_ref: string;
|
||||
_type: "reference";
|
||||
_weak?: boolean;
|
||||
[internalGroqTypeReferenceTo]?: "sanity.imageAsset";
|
||||
};
|
||||
media?: unknown;
|
||||
hotspot?: SanityImageHotspot;
|
||||
crop?: SanityImageCrop;
|
||||
_type: "image";
|
||||
};
|
||||
seoKeywords?: Array<string>;
|
||||
openGraph?: OpenGraph;
|
||||
additionalMetaTags?: Array<{
|
||||
_key: string;
|
||||
} & MetaTag>;
|
||||
twitter?: Twitter;
|
||||
};
|
||||
|
||||
export type Twitter = {
|
||||
_type: "twitter";
|
||||
cardType?: string;
|
||||
creator?: string;
|
||||
site?: string;
|
||||
handle?: string;
|
||||
};
|
||||
|
||||
export type OpenGraph = {
|
||||
_type: "openGraph";
|
||||
url?: string;
|
||||
image?: {
|
||||
asset?: {
|
||||
_ref: string;
|
||||
_type: "reference";
|
||||
_weak?: boolean;
|
||||
[internalGroqTypeReferenceTo]?: "sanity.imageAsset";
|
||||
};
|
||||
media?: unknown;
|
||||
hotspot?: SanityImageHotspot;
|
||||
crop?: SanityImageCrop;
|
||||
_type: "image";
|
||||
};
|
||||
title?: string;
|
||||
description?: string;
|
||||
siteName?: string;
|
||||
};
|
||||
|
||||
export type Custom = {
|
||||
_id: string;
|
||||
_type: "custom";
|
||||
_createdAt: string;
|
||||
_updatedAt: string;
|
||||
_rev: string;
|
||||
title?: string;
|
||||
slug?: Slug;
|
||||
body?: BlockContent;
|
||||
};
|
||||
|
||||
export type Link = {
|
||||
_type: "link";
|
||||
text?: string;
|
||||
type?: string;
|
||||
internalLink?: {
|
||||
_ref: string;
|
||||
_type: "reference";
|
||||
_weak?: boolean;
|
||||
[internalGroqTypeReferenceTo]?: "custom";
|
||||
};
|
||||
url?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
value?: string;
|
||||
blank?: boolean;
|
||||
parameters?: string;
|
||||
anchor?: string;
|
||||
};
|
||||
|
||||
export type SanityImagePaletteSwatch = {
|
||||
_type: "sanity.imagePaletteSwatch";
|
||||
background?: string;
|
||||
foreground?: string;
|
||||
population?: number;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export type SanityImagePalette = {
|
||||
_type: "sanity.imagePalette";
|
||||
darkMuted?: SanityImagePaletteSwatch;
|
||||
lightVibrant?: SanityImagePaletteSwatch;
|
||||
darkVibrant?: SanityImagePaletteSwatch;
|
||||
vibrant?: SanityImagePaletteSwatch;
|
||||
dominant?: SanityImagePaletteSwatch;
|
||||
lightMuted?: SanityImagePaletteSwatch;
|
||||
muted?: SanityImagePaletteSwatch;
|
||||
};
|
||||
|
||||
export type SanityImageDimensions = {
|
||||
_type: "sanity.imageDimensions";
|
||||
height?: number;
|
||||
width?: number;
|
||||
aspectRatio?: number;
|
||||
};
|
||||
|
||||
export type SanityImageHotspot = {
|
||||
_type: "sanity.imageHotspot";
|
||||
x?: number;
|
||||
y?: number;
|
||||
height?: number;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export type SanityImageCrop = {
|
||||
_type: "sanity.imageCrop";
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
};
|
||||
|
||||
export type SanityFileAsset = {
|
||||
_id: string;
|
||||
_type: "sanity.fileAsset";
|
||||
_createdAt: string;
|
||||
_updatedAt: string;
|
||||
_rev: string;
|
||||
originalFilename?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
altText?: string;
|
||||
sha1hash?: string;
|
||||
extension?: string;
|
||||
mimeType?: string;
|
||||
size?: number;
|
||||
assetId?: string;
|
||||
uploadId?: string;
|
||||
path?: string;
|
||||
url?: string;
|
||||
source?: SanityAssetSourceData;
|
||||
};
|
||||
|
||||
export type SanityImageAsset = {
|
||||
_id: string;
|
||||
_type: "sanity.imageAsset";
|
||||
_createdAt: string;
|
||||
_updatedAt: string;
|
||||
_rev: string;
|
||||
originalFilename?: string;
|
||||
label?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
altText?: string;
|
||||
sha1hash?: string;
|
||||
extension?: string;
|
||||
mimeType?: string;
|
||||
size?: number;
|
||||
assetId?: string;
|
||||
uploadId?: string;
|
||||
path?: string;
|
||||
url?: string;
|
||||
metadata?: SanityImageMetadata;
|
||||
source?: SanityAssetSourceData;
|
||||
};
|
||||
|
||||
export type SanityImageMetadata = {
|
||||
_type: "sanity.imageMetadata";
|
||||
location?: Geopoint;
|
||||
dimensions?: SanityImageDimensions;
|
||||
palette?: SanityImagePalette;
|
||||
lqip?: string;
|
||||
blurHash?: string;
|
||||
hasAlpha?: boolean;
|
||||
isOpaque?: boolean;
|
||||
};
|
||||
|
||||
export type Geopoint = {
|
||||
_type: "geopoint";
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
alt?: number;
|
||||
};
|
||||
|
||||
export type Slug = {
|
||||
_type: "slug";
|
||||
current?: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type SanityAssetSourceData = {
|
||||
_type: "sanity.assetSourceData";
|
||||
name?: string;
|
||||
id?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export type AllSanitySchemaTypes = Home | FaqSection | CtaSection | ContactSection | ImageWithAlt | Faq | Button | Settings | BlockContent | HighlightColor | TextColor | SimplerColor | MetaTag | MetaAttribute | SeoMetaFields | Twitter | OpenGraph | Custom | Link | SanityImagePaletteSwatch | SanityImagePalette | SanityImageDimensions | SanityImageHotspot | SanityImageCrop | SanityFileAsset | SanityImageAsset | SanityImageMetadata | Geopoint | Slug | SanityAssetSourceData;
|
||||
export declare const internalGroqTypeReferenceTo: unique symbol;
|
||||
18
apps/client/src/sanity/settings.ts
Normal file
18
apps/client/src/sanity/settings.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { sanityFetch } from "./live";
|
||||
|
||||
export async function fetchSettings(prop?: string | undefined) {
|
||||
const settingsFetch = await sanityFetch({
|
||||
query: `*[_type == "settings"][0]{
|
||||
${
|
||||
prop ||
|
||||
`title,
|
||||
longTitle,
|
||||
footer,
|
||||
description,
|
||||
logo,
|
||||
favicon`
|
||||
}
|
||||
}`,
|
||||
});
|
||||
return settingsFetch.data;
|
||||
}
|
||||
3
apps/client/tailwind.config.ts
Normal file
3
apps/client/tailwind.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { config } from "@repo/ui";
|
||||
|
||||
export default config;
|
||||
20
apps/client/tsconfig.json
Normal file
20
apps/client/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "@repo/typescript-config/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
"../../packages/ui/src/tailwind.config.ts"
|
||||
, "../../packages/ui/src/components/logo.tsx" ],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
29
apps/studio/.gitignore
vendored
Normal file
29
apps/studio/.gitignore
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# Dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Compiled Sanity Studio
|
||||
/dist
|
||||
|
||||
# Temporary Sanity runtime, generated by the CLI on every dev server start
|
||||
/.sanity
|
||||
|
||||
# Logs
|
||||
/logs
|
||||
*.log
|
||||
|
||||
# Coverage directory used by testing tools
|
||||
/coverage
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# Typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Dotenv and similar local-only files
|
||||
*.local
|
||||
11
apps/studio/README.md
Normal file
11
apps/studio/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Sanity Blogging Content Studio
|
||||
|
||||
Congratulations, you have now installed the Sanity Content Studio, an open-source real-time content editing environment connected to the Sanity backend.
|
||||
|
||||
Now you can do the following things:
|
||||
|
||||
- [Read “getting started” in the docs](https://www.sanity.io/docs/introduction/getting-started?utm_source=readme)
|
||||
- Check out the example frontend: [React/Next.js](https://github.com/sanity-io/tutorial-sanity-blog-react-next)
|
||||
- [Read the blog post about this template](https://www.sanity.io/blog/build-your-own-blog-with-sanity-and-next-js?utm_source=readme)
|
||||
- [Join the Sanity community](https://www.sanity.io/community/join?utm_source=readme)
|
||||
- [Extend and build plugins](https://www.sanity.io/docs/content-studio/extending?utm_source=readme)
|
||||
21
apps/studio/components/TextAlignComponent.tsx
Normal file
21
apps/studio/components/TextAlignComponent.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
import {BlockDecoratorProps} from 'sanity'
|
||||
|
||||
type TextAlignValue = 'left' | 'center' | 'right'
|
||||
interface TextAlignBlockDecoratorProps extends BlockDecoratorProps {
|
||||
value: TextAlignValue
|
||||
}
|
||||
|
||||
export const TextAlign = (props: TextAlignBlockDecoratorProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
// props.value exists and is of type TextAlignValue
|
||||
textAlign: props.value ? props.value : 'left',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
apps/studio/components/icons/alignment-center.tsx
Normal file
11
apps/studio/components/icons/alignment-center.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function AlignmentCenterIcon() {
|
||||
return (
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M1.875 1H10.125M3.875 4.625H8.125M1.875 8.25H10.125"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="square"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
11
apps/studio/components/icons/alignment-left.tsx
Normal file
11
apps/studio/components/icons/alignment-left.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function AlignmentLeftIcon() {
|
||||
return (
|
||||
<svg width="13" height="12" viewBox="0 0 13 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2.375 1H10.625M2.375 4.625H6.625M2.375 8.25H10.625"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="square"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
11
apps/studio/components/icons/alignment-right.tsx
Normal file
11
apps/studio/components/icons/alignment-right.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function AlignmentRightIcon() {
|
||||
return (
|
||||
<svg width="13" height="12" viewBox="0 0 13 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2.375 1H10.625M6.375 4.625H10.625M2.375 8.25H10.625"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="square"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
3
apps/studio/eslint.config.mjs
Normal file
3
apps/studio/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import studio from '@sanity/eslint-config-studio'
|
||||
|
||||
export default [...studio]
|
||||
1
apps/studio/example.env
Normal file
1
apps/studio/example.env
Normal file
@@ -0,0 +1 @@
|
||||
SANITY_STUDIO_PREVIEW_ORIGIN=http://localhost:3000
|
||||
18
apps/studio/lib/colorUtils.ts
Normal file
18
apps/studio/lib/colorUtils.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { brandColors, primitives, variables } from '@repo/ui'
|
||||
|
||||
export const createColorList = () => {
|
||||
// Flatten primitives into a single object
|
||||
const flatPrimitives = Object.entries(primitives).reduce((acc, [category, shades]) => {
|
||||
Object.entries(shades).forEach(([shade, value]) => {
|
||||
acc[`${category}-${shade}`] = value;
|
||||
});
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
const allColors = { ...flatPrimitives, ...variables, ...brandColors };
|
||||
|
||||
return Object.entries(allColors).map(([key, value]) => ({
|
||||
label: key.replace(/[-_]/g, ' ').replace(/^\w/, c => c.toUpperCase()),
|
||||
value: value as string
|
||||
}));
|
||||
};
|
||||
45
apps/studio/package.json
Normal file
45
apps/studio/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "website",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"main": "package.json",
|
||||
"license": "UNLICENSED",
|
||||
"scripts": {
|
||||
"dev": "sanity dev",
|
||||
"start": "sanity start",
|
||||
"build": "sanity build",
|
||||
"deploy": "sanity deploy",
|
||||
"generate": "sanity schema extract && sanity typegen generate",
|
||||
"deploy-graphql": "sanity graphql deploy"
|
||||
},
|
||||
"keywords": [
|
||||
"sanity"
|
||||
],
|
||||
"dependencies": {
|
||||
"@repo/ui": "*",
|
||||
"@repo/sanity-connection": "*",
|
||||
"@sanity/document-internationalization": "^3.3.3",
|
||||
"@sanity/vision": "^3.99.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"sanity": "^3.99.0",
|
||||
"sanity-plugin-link-field": "^1.4.0",
|
||||
"sanity-plugin-media": "^3.0.4",
|
||||
"sanity-plugin-seo": "^1.3.0",
|
||||
"sanity-plugin-simpler-color-input": "^3.1.0",
|
||||
"styled-components": "^6.1.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sanity/eslint-config-studio": "^5.0.2",
|
||||
"@types/react": "^19.1.8",
|
||||
"eslint": "^9.30.1",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"prettier": {
|
||||
"semi": false,
|
||||
"printWidth": 100,
|
||||
"bracketSpacing": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
}
|
||||
4
apps/studio/sanity-typegen.json
Normal file
4
apps/studio/sanity-typegen.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"generates": "../client/src/sanity/sanity.types.ts",
|
||||
"path": "./schemaTypes/*.ts"
|
||||
}
|
||||
15
apps/studio/sanity.cli.ts
Normal file
15
apps/studio/sanity.cli.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { sanityConnection } from '@repo/sanity-connection'
|
||||
import {defineCliConfig} from 'sanity/cli'
|
||||
|
||||
export default defineCliConfig({
|
||||
api: {
|
||||
projectId: sanityConnection.projectId,
|
||||
dataset: sanityConnection.dataset
|
||||
},
|
||||
studioHost: sanityConnection.studioHost,
|
||||
/**
|
||||
* Enable auto-updates for studios.
|
||||
* Learn more at https://www.sanity.io/docs/cli#auto-updates
|
||||
*/
|
||||
autoUpdates: true,
|
||||
})
|
||||
65
apps/studio/sanity.config.ts
Normal file
65
apps/studio/sanity.config.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {defineConfig} from 'sanity'
|
||||
import {structureTool} from 'sanity/structure'
|
||||
import {ClipboardIcon, HomeIcon, WrenchIcon} from '@sanity/icons'
|
||||
import {schemaTypes} from './schemaTypes'
|
||||
import {presentationTool} from 'sanity/presentation'
|
||||
import {linkField} from 'sanity-plugin-link-field'
|
||||
import {seoMetaFields} from 'sanity-plugin-seo'
|
||||
import {simplerColorInput} from 'sanity-plugin-simpler-color-input'
|
||||
import {createColorList} from './lib/colorUtils'
|
||||
import { sanityConnection } from '@repo/sanity-connection'
|
||||
import Logo from '@repo/ui/components/logo'
|
||||
|
||||
export default defineConfig({
|
||||
name: 'default',
|
||||
title: sanityConnection.pageTitle,
|
||||
projectId: sanityConnection.projectId,
|
||||
dataset: sanityConnection.dataset,
|
||||
icon: Logo,
|
||||
plugins: [
|
||||
structureTool({
|
||||
title: 'Content',
|
||||
structure: (S) =>
|
||||
S.list()
|
||||
.title('Content')
|
||||
.items([
|
||||
S.listItem()
|
||||
.title('Landing Page')
|
||||
.icon(HomeIcon)
|
||||
.child(S.document().schemaType('home').documentId('home').title('Home')),
|
||||
S.divider(),
|
||||
S.listItem()
|
||||
.title('Custom pages')
|
||||
.icon(ClipboardIcon)
|
||||
.child(S.documentTypeList('custom').title('Content')),
|
||||
S.divider(),
|
||||
S.listItem()
|
||||
.title('Settings')
|
||||
.icon(WrenchIcon)
|
||||
.child(S.document().schemaType('settings').documentId('settings').title('Settings')),
|
||||
]),
|
||||
}),
|
||||
linkField({
|
||||
linkableSchemaTypes: ['custom'],
|
||||
}),
|
||||
presentationTool({
|
||||
previewUrl: {
|
||||
origin: sanityConnection.previewUrl ?? 'http://localhost:3000',
|
||||
preview: '/',
|
||||
previewMode: {
|
||||
enable: '/api/draft-mode/enable',
|
||||
},
|
||||
},
|
||||
}),
|
||||
seoMetaFields(),
|
||||
simplerColorInput({
|
||||
defaultColorFormat: 'hex',
|
||||
defaultColorList: createColorList(),
|
||||
enableSearch: true,
|
||||
showColorValue: true,
|
||||
}),
|
||||
],
|
||||
schema: {
|
||||
types: schemaTypes,
|
||||
},
|
||||
})
|
||||
3133
apps/studio/schema.json
Normal file
3133
apps/studio/schema.json
Normal file
File diff suppressed because it is too large
Load Diff
89
apps/studio/schemaTypes/blockContent.ts
Normal file
89
apps/studio/schemaTypes/blockContent.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { defineType, defineArrayMember } from 'sanity'
|
||||
import AlignmentLeftIcon from '../components/icons/alignment-left'
|
||||
import AlignmentCenterIcon from '../components/icons/alignment-center'
|
||||
import AlignmentRightIcon from '../components/icons/alignment-right'
|
||||
import { TextAlign } from '../components/TextAlignComponent'
|
||||
|
||||
/**
|
||||
* This is the schema definition for the rich text fields used for
|
||||
* for this blog studio. When you import it in schemas.js it can be
|
||||
* reused in other parts of the studio with:
|
||||
* {
|
||||
* name: 'someName',
|
||||
* title: 'Some title',
|
||||
* type: 'blockContent'
|
||||
* }
|
||||
*/
|
||||
export default defineType({
|
||||
title: 'Block Content',
|
||||
name: 'blockContent',
|
||||
type: 'array',
|
||||
of: [
|
||||
defineArrayMember({
|
||||
title: 'Block',
|
||||
type: 'block',
|
||||
// Styles let you set what your user can mark up blocks with. These
|
||||
// correspond with HTML tags, but you can set any title or value
|
||||
// you want and decide how you want to deal with it where you want to
|
||||
// use your content.
|
||||
styles: [
|
||||
{ title: 'Normal', value: 'normal' },
|
||||
{ title: 'H1', value: 'h1' },
|
||||
{ title: 'H2', value: 'h2' },
|
||||
{ title: 'H3', value: 'h3' },
|
||||
{ title: 'H4', value: 'h4' },
|
||||
{ title: 'Quote', value: 'blockquote' },
|
||||
],
|
||||
lists: [{ title: 'Bulletpoint', value: 'bullet' }],
|
||||
// Marks let you mark up inline text in the block editor.
|
||||
marks: {
|
||||
// Decorators usually describe a single property – e.g. a typographic
|
||||
// preference or highlighting by editors.
|
||||
decorators: [
|
||||
{ title: 'Strong', value: 'strong' },
|
||||
{ title: 'Italic', value: 'em' },
|
||||
{ title: 'Left', value: 'left', icon: AlignmentLeftIcon, component: (props) => TextAlign(props) },
|
||||
{ title: 'Center', value: 'center', icon: AlignmentCenterIcon, component: (props) => TextAlign(props) },
|
||||
{ title: 'Right', value: 'right', icon: AlignmentRightIcon, component: (props) => TextAlign(props) },
|
||||
],
|
||||
// Annotations can be any object structure – e.g. a link or a footnote.
|
||||
annotations: [
|
||||
{
|
||||
title: 'Link',
|
||||
name: 'link',
|
||||
type: 'object',
|
||||
fields: [
|
||||
{
|
||||
title: 'Link',
|
||||
name: 'href',
|
||||
type: 'link',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "textColor"
|
||||
},
|
||||
{
|
||||
type: "highlightColor"
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
// You can add additional types here. Note that you can't use
|
||||
// primitive types such as 'string' and 'number' in the same array
|
||||
// as a block type.
|
||||
defineArrayMember({
|
||||
title: 'Button',
|
||||
type: 'button',
|
||||
}),
|
||||
defineArrayMember({
|
||||
title: 'Image',
|
||||
type: 'image',
|
||||
options: { hotspot: true },
|
||||
}),
|
||||
defineArrayMember({
|
||||
title: 'File',
|
||||
type: 'file',
|
||||
}),
|
||||
],
|
||||
})
|
||||
15
apps/studio/schemaTypes/index.ts
Normal file
15
apps/studio/schemaTypes/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import blockContent from "./blockContent";
|
||||
import settings from "./settings";
|
||||
|
||||
import * as objects from './objects'
|
||||
import * as sections from './sections'
|
||||
import * as pages from './pages'
|
||||
|
||||
export const schemaTypes = [
|
||||
blockContent,
|
||||
settings,
|
||||
|
||||
...Object.values(objects),
|
||||
...Object.values(sections),
|
||||
...Object.values(pages),
|
||||
]
|
||||
33
apps/studio/schemaTypes/objects/button.ts
Normal file
33
apps/studio/schemaTypes/objects/button.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ComponentIcon } from '@sanity/icons'
|
||||
import { defineField, defineType } from 'sanity'
|
||||
|
||||
export default defineType({
|
||||
name: 'button',
|
||||
title: 'CTA Button',
|
||||
type: 'object',
|
||||
icon: ComponentIcon,
|
||||
fields: [
|
||||
defineField({
|
||||
name: 'text',
|
||||
title: 'Button Label',
|
||||
type: 'string',
|
||||
validation: (Rule) => Rule.required().min(2).max(50),
|
||||
}),
|
||||
defineField({
|
||||
name: 'link',
|
||||
title: 'Link',
|
||||
type: 'link',
|
||||
validation: (Rule) => Rule.required(),
|
||||
}),
|
||||
],
|
||||
preview: {
|
||||
select: {
|
||||
title: 'text',
|
||||
},
|
||||
prepare({ title }) {
|
||||
return {
|
||||
title: title || 'Untitled Button',
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
34
apps/studio/schemaTypes/objects/faq.ts
Normal file
34
apps/studio/schemaTypes/objects/faq.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { HelpCircleIcon } from '@sanity/icons'
|
||||
import { defineField, defineType } from 'sanity'
|
||||
|
||||
export default defineType({
|
||||
name: 'faq',
|
||||
title: 'FAQ Item',
|
||||
type: 'object',
|
||||
icon: HelpCircleIcon,
|
||||
fields: [
|
||||
defineField({
|
||||
name: 'question',
|
||||
title: 'Question',
|
||||
type: 'string',
|
||||
validation: (Rule) => Rule.required().min(1).max(200),
|
||||
}),
|
||||
defineField({
|
||||
name: 'answer',
|
||||
title: 'Answer',
|
||||
type: 'blockContent',
|
||||
validation: (Rule) => Rule.required(),
|
||||
}),
|
||||
],
|
||||
preview: {
|
||||
select: {
|
||||
title: 'question',
|
||||
subtitle: 'answer',
|
||||
},
|
||||
prepare({ title }) {
|
||||
return {
|
||||
title: title || 'Untitled FAQ',
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
20
apps/studio/schemaTypes/objects/imageWithAlt.ts
Normal file
20
apps/studio/schemaTypes/objects/imageWithAlt.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import {defineField, defineType} from 'sanity'
|
||||
|
||||
export default defineField({
|
||||
name: 'imageWithAlt',
|
||||
title: 'Image',
|
||||
type: 'image',
|
||||
fields: [
|
||||
defineField({
|
||||
name: 'alt',
|
||||
title: 'Alt Text',
|
||||
type: 'string',
|
||||
description: 'Important for SEO and accessibility',
|
||||
validation: (Rule) => Rule.required().min(1).max(100),
|
||||
}),
|
||||
],
|
||||
options: {
|
||||
hotspot: true,
|
||||
},
|
||||
validation: (Rule) => Rule.required()
|
||||
})
|
||||
3
apps/studio/schemaTypes/objects/index.ts
Normal file
3
apps/studio/schemaTypes/objects/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as faq } from './faq'
|
||||
export { default as imageWithAlt } from './imageWithAlt'
|
||||
export { default as button } from './button'
|
||||
36
apps/studio/schemaTypes/pages/custom.ts
Normal file
36
apps/studio/schemaTypes/pages/custom.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { defineField, defineType, type SlugRule, type StringRule } from 'sanity'
|
||||
|
||||
export default defineType({
|
||||
name: 'custom',
|
||||
title: 'Custom page',
|
||||
type: 'document',
|
||||
fields: [
|
||||
defineField({
|
||||
name: 'title',
|
||||
title: 'Title',
|
||||
type: 'string',
|
||||
validation: (Rule: StringRule) => Rule.required().error('Title is required')
|
||||
}),
|
||||
defineField({
|
||||
name: 'slug',
|
||||
title: 'Slug',
|
||||
type: 'slug',
|
||||
options: {
|
||||
source: 'title',
|
||||
maxLength: 96,
|
||||
},
|
||||
validation: (Rule: SlugRule) => Rule.required(),
|
||||
}),
|
||||
defineField({
|
||||
title: 'Content',
|
||||
name: 'body',
|
||||
type: 'blockContent',
|
||||
}),
|
||||
],
|
||||
|
||||
preview: {
|
||||
select: {
|
||||
title: 'title'
|
||||
},
|
||||
},
|
||||
})
|
||||
33
apps/studio/schemaTypes/pages/home.ts
Normal file
33
apps/studio/schemaTypes/pages/home.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {HomeIcon} from '@sanity/icons'
|
||||
import {defineField, defineType} from 'sanity'
|
||||
|
||||
export default defineType({
|
||||
name: 'home',
|
||||
title: 'Landing Page',
|
||||
type: 'document',
|
||||
icon: HomeIcon,
|
||||
groups: [
|
||||
{
|
||||
name: 'header',
|
||||
title: 'Header',
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
defineField({
|
||||
name: 'title',
|
||||
title: 'Page Title',
|
||||
type: 'string',
|
||||
validation: (Rule) => Rule.required(),
|
||||
group: 'header',
|
||||
}),
|
||||
|
||||
// Page sections
|
||||
defineField({
|
||||
name: 'headerSection',
|
||||
title: 'Header Section',
|
||||
type: 'ctaSection',
|
||||
group: 'header',
|
||||
validation: (Rule) => Rule.required(),
|
||||
}),
|
||||
],
|
||||
})
|
||||
3
apps/studio/schemaTypes/pages/index.ts
Normal file
3
apps/studio/schemaTypes/pages/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Page types
|
||||
export { default as homePage } from './home'
|
||||
export { default as custom } from "./custom";
|
||||
71
apps/studio/schemaTypes/sections/contact.ts
Normal file
71
apps/studio/schemaTypes/sections/contact.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { EnvelopeIcon } from '@sanity/icons'
|
||||
import { defineField, defineType } from 'sanity'
|
||||
|
||||
export default defineType({
|
||||
name: 'contactSection',
|
||||
title: 'Contact Section',
|
||||
type: 'object',
|
||||
icon: EnvelopeIcon,
|
||||
fields: [
|
||||
defineField({
|
||||
name: 'title',
|
||||
title: 'Section Title',
|
||||
type: 'string',
|
||||
validation: (Rule) => Rule.required(),
|
||||
}),
|
||||
defineField({
|
||||
name: 'contactMethods',
|
||||
title: 'Contact Methods',
|
||||
type: 'array',
|
||||
of: [
|
||||
{
|
||||
type: 'object',
|
||||
fields: [
|
||||
defineField({
|
||||
name: 'type',
|
||||
title: 'Contact Type',
|
||||
type: 'string',
|
||||
options: {
|
||||
list: [
|
||||
{title: 'Email', value: 'email'},
|
||||
{title: 'Phone', value: 'phone'},
|
||||
{title: 'Address', value: 'address'},
|
||||
{title: 'Social Media', value: 'social'},
|
||||
],
|
||||
},
|
||||
}),
|
||||
defineField({
|
||||
name: 'label',
|
||||
title: 'Label',
|
||||
type: 'string',
|
||||
}),
|
||||
defineField({
|
||||
name: 'value',
|
||||
title: 'Contact Information',
|
||||
type: 'string',
|
||||
}),
|
||||
defineField({
|
||||
name: 'link',
|
||||
title: 'Link',
|
||||
type: 'link',
|
||||
description: 'Optional link (mailto:, tel:, etc.)',
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
preview: {
|
||||
select: {
|
||||
title: 'title',
|
||||
contactMethods: 'contactMethods',
|
||||
},
|
||||
prepare({ title, contactMethods }) {
|
||||
const methodCount = contactMethods?.length || 0
|
||||
return {
|
||||
title: title || 'Contact Section',
|
||||
subtitle: `${methodCount} contact method${methodCount !== 1 ? 's' : ''}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
49
apps/studio/schemaTypes/sections/cta.ts
Normal file
49
apps/studio/schemaTypes/sections/cta.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { RocketIcon } from '@sanity/icons'
|
||||
import { defineField, defineType } from 'sanity'
|
||||
|
||||
export default defineType({
|
||||
name: 'ctaSection',
|
||||
title: 'Call to Action Section',
|
||||
type: 'object',
|
||||
icon: RocketIcon,
|
||||
fields: [
|
||||
defineField({
|
||||
name: 'backgroundImage',
|
||||
title: 'Background Image',
|
||||
type: 'imageWithAlt',
|
||||
validation: (Rule) => Rule.required(),
|
||||
}),
|
||||
defineField({
|
||||
name: 'title',
|
||||
title: 'Title',
|
||||
type: 'string',
|
||||
validation: (Rule) => Rule.required().min(1).max(100),
|
||||
}),
|
||||
defineField({
|
||||
name: 'description',
|
||||
title: 'Description',
|
||||
type: 'blockContent',
|
||||
validation: (Rule) => Rule.required(),
|
||||
}),
|
||||
defineField({
|
||||
name: 'button',
|
||||
title: 'Button',
|
||||
type: 'button',
|
||||
validation: (Rule) => Rule.required(),
|
||||
}),
|
||||
],
|
||||
preview: {
|
||||
select: {
|
||||
title: 'title',
|
||||
media: 'backgroundImage.image',
|
||||
buttonText: 'primaryButton.text',
|
||||
},
|
||||
prepare({ title, media, buttonText }) {
|
||||
return {
|
||||
title: title || 'CTA Section',
|
||||
subtitle: buttonText ? `Primary: ${buttonText}` : 'No button text',
|
||||
media,
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
45
apps/studio/schemaTypes/sections/faq.ts
Normal file
45
apps/studio/schemaTypes/sections/faq.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { BulbOutlineIcon } from '@sanity/icons'
|
||||
import { defineField, defineType } from 'sanity'
|
||||
|
||||
export default defineType({
|
||||
name: 'faqSection',
|
||||
title: 'FAQ Section',
|
||||
type: 'object',
|
||||
icon: BulbOutlineIcon,
|
||||
fields: [
|
||||
defineField({
|
||||
name: 'sectionTitle',
|
||||
title: 'Section Title',
|
||||
type: 'string',
|
||||
description: 'Small title above the main heading',
|
||||
}),
|
||||
defineField({
|
||||
name: 'title',
|
||||
title: 'Title',
|
||||
type: 'string',
|
||||
validation: (Rule) => Rule.required().min(1).max(100),
|
||||
}),
|
||||
defineField({
|
||||
name: 'faqs',
|
||||
title: 'FAQ Items',
|
||||
type: 'array',
|
||||
of: [{ type: 'faq' }],
|
||||
validation: (Rule) => Rule.required().min(1).max(20),
|
||||
options: {
|
||||
sortable: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
preview: {
|
||||
select: {
|
||||
title: 'title',
|
||||
faqCount: 'faqs.length',
|
||||
},
|
||||
prepare({ title, faqCount }) {
|
||||
return {
|
||||
title: title || 'FAQ Section',
|
||||
subtitle: `${faqCount || 0} FAQs`,
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
3
apps/studio/schemaTypes/sections/index.ts
Normal file
3
apps/studio/schemaTypes/sections/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ctaSection } from './cta'
|
||||
export { default as faqSection } from './faq'
|
||||
export { default as contactSection } from './contact'
|
||||
44
apps/studio/schemaTypes/settings.ts
Normal file
44
apps/studio/schemaTypes/settings.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {WrenchIcon} from '@sanity/icons'
|
||||
import {defineField, defineType} from 'sanity'
|
||||
|
||||
export default defineType({
|
||||
name: 'settings',
|
||||
title: 'Einstellungen',
|
||||
type: 'document',
|
||||
icon: WrenchIcon,
|
||||
fields: [
|
||||
defineField({
|
||||
name: 'title',
|
||||
title: 'Title',
|
||||
type: 'string',
|
||||
}),
|
||||
defineField({
|
||||
name: 'longTitle',
|
||||
title: 'Long Title',
|
||||
description: 'Used for SEO and social media',
|
||||
type: 'string',
|
||||
}),
|
||||
defineField({
|
||||
name: 'description',
|
||||
title: 'Description',
|
||||
description: 'Important for SEO and social media',
|
||||
type: 'text',
|
||||
}),
|
||||
defineField({
|
||||
name: 'logo',
|
||||
title: 'Logo',
|
||||
type: 'image',
|
||||
}),
|
||||
defineField({
|
||||
name: 'favicon',
|
||||
title: 'Favicon',
|
||||
type: 'image',
|
||||
}),
|
||||
defineField({
|
||||
name: 'footer',
|
||||
title: 'Copyright',
|
||||
initialValue: '© {YEAR} . All rights reserved.',
|
||||
type: 'string',
|
||||
}),
|
||||
],
|
||||
})
|
||||
14
apps/studio/static/manifest.webmanifest
Normal file
14
apps/studio/static/manifest.webmanifest
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"icons": [
|
||||
{
|
||||
"src": "/favicon-192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "/favicon-512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
]
|
||||
}
|
||||
17
apps/studio/tsconfig.json
Normal file
17
apps/studio/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "Preserve",
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user