template setup 🚀

This commit is contained in:
2025-07-24 01:20:02 +02:00
commit 1b71b472a7
81 changed files with 9586 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Dependencies
node_modules
.pnp
.pnp.js
# Local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Testing
coverage
# Turbo
.turbo
# Vercel
.vercel
# Build Outputs
.next/
out/
build
dist
# Debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Misc
.DS_Store
*.pem

0
.npmrc Normal file
View File

159
README.md Normal file
View File

@@ -0,0 +1,159 @@
# ✨ Lumify Sanity Template
Modern web template for Next.js, Sanity Studio, and Bun, with monorepo structure and reusable UI components. 🚀
---
## 🧩 Features
-**Next.js** (App Router, TypeScript, Tailwind CSS)
- 📝 **Sanity Studio** (custom schemas, live preview, type generation)
- 🏗️ **Monorepo** (TurboRepo, packages for UI and Sanity connection)
- 🎨 **Reusable UI components**
---
## 🗂️ Project Structure
```
apps/
client/ # Next.js frontend app
src/
app/ # App router pages & layouts
components/ # React UI components
lib/ # Utility functions
public/ # Static assets
studio/ # Sanity Studio (CMS)
packages/
sanity-connection/ # Shared Sanity config/utilities
typescript-config/ # Shared tsconfig presets
ui/ # Shared UI components
```
---
## 🏁 Getting Started
### 1⃣ Sanity Project Setup
1. Create a new project at [sanity.io](https://www.sanity.io/)
2. Copy your **Project ID** for later
3. In Sanity, set up the following CORS origins:
| URL | Status |
|-------------------------------|-------------|
| https://example.vercel.app | Not Allowed |
| http://localhost:3000 | Not Allowed |
| https://*.api.sanity.io | Not Allowed |
| wss://*.api.sanity.io | Not Allowed |
| https://example.sanity.studio | Allowed |
| http://localhost:3333 | Allowed |
4. Create a **token** with `Viewer` permissions (for live preview):
| Name | Permissions |
|----------------------------------------|-------------|
| Main Token (Copy it for the next step) | Viewer |
### 2⃣ Configure the Repo
Edit `packages/sanity-connection/index.ts` and fill in:
1. `pageTitle` Name for your Sanity Studio
2. `publicViewerToken` The token from above
3. `studioHost` Lowercase, no special chars (e.g. `myproject`)
4. `studioUrl` `https://<studioHost>.sanity.studio`
5. `projectId` From Sanity project settings
6. `previewUrl` Your website URL (`http://localhost:3000` for local dev)
---
## 💻 Local Development
Install dependencies (from the root):
```sh
bun install
```
Start all apps (Next.js and Sanity Studio) in parallel from the root:
```sh
bun run dev
```
✨ You do not need to `cd` into any subdirectory—TurboRepo will handle running the correct scripts in each package/app.
---
## 🛠️ Useful Scripts
| Script | Description |
|--------------------|------------------------------------|
| bun run dev | Start dev server (client/studio) |
| bun run build | Build app/studio |
| bun run deploy | Deploy Sanity Studio |
| bun run generate | Generate Sanity types |
---
## 🚢 Deployment
To deploy Sanity Studio:
```sh
bun run deploy
```
---
## 🧬 Type Generation
To generate types from your Sanity schemas:
```sh
bun run generate
```
---
on github
## 🚀 Use This Template
Easily kickstart your own project with this template on GitHub:
1. ⭐️ **Go to the repository page on GitHub.**
2. ⤴️ Click the **"Use this template"** button (top right) and select **"Create a new repository"**.
3. 📝 Fill in your new repository details and click **"Create repository from template"**.
4. ⬇️ Clone your new repository:
```sh
git clone https://github.com/your-username/your-repo-name.git
cd your-repo-name
```
5. 📦 Install dependencies and start development:
```sh
bun install
bun run dev
```
6. 🛠️ Follow the [Getting Started](#getting-started) steps above to configure your Sanity project and environment.
Happy building! 🎉

41
apps/client/.gitignore vendored Normal file
View 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
View 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.

View 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"
}

View 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
View 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"
}
}

View File

@@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

View File

@@ -0,0 +1,14 @@
{
"icons": [
{
"src": "/favicon-192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "/favicon-512.png",
"type": "image/png",
"sizes": "512x512"
}
]
}

View 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>
);
}

View 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]);
}

View 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,
}),
});

View 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;
}
}

View 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>
);
}

View 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"
/>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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} />;
}

View 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>
);
}

View 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 }

View 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)));
}

View 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;
}

View 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 };

View 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 };
}

View 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))
}

View 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,
},
});

View 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,
});

View 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;

View 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;
}

View File

@@ -0,0 +1,3 @@
import { config } from "@repo/ui";
export default config;

20
apps/client/tsconfig.json Normal file
View 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
View 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
View 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)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,3 @@
import studio from '@sanity/eslint-config-studio'
export default [...studio]

1
apps/studio/example.env Normal file
View File

@@ -0,0 +1 @@
SANITY_STUDIO_PREVIEW_ORIGIN=http://localhost:3000

View 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
View 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
}
}

View File

@@ -0,0 +1,4 @@
{
"generates": "../client/src/sanity/sanity.types.ts",
"path": "./schemaTypes/*.ts"
}

15
apps/studio/sanity.cli.ts Normal file
View 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,
})

View 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

File diff suppressed because it is too large Load Diff

View 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',
}),
],
})

View 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),
]

View 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',
}
},
},
})

View 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',
}
},
},
})

View 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()
})

View File

@@ -0,0 +1,3 @@
export { default as faq } from './faq'
export { default as imageWithAlt } from './imageWithAlt'
export { default as button } from './button'

View 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'
},
},
})

View 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(),
}),
],
})

View File

@@ -0,0 +1,3 @@
// Page types
export { default as homePage } from './home'
export { default as custom } from "./custom";

View 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' : ''}`,
}
},
},
})

View 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,
}
},
},
})

View 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`,
}
},
},
})

View File

@@ -0,0 +1,3 @@
export { default as ctaSection } from './cta'
export { default as faqSection } from './faq'
export { default as contactSection } from './contact'

View 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',
}),
],
})

View 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
View 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"]
}

3277
bun.lock Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "web",
"private": true,
"scripts": {
"build": "turbo run build",
"dev": "turbo run dev",
"lint": "turbo run lint",
"deploy": "turbo run deploy",
"generate": "turbo run generate",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"check-types": "turbo run check-types"
},
"devDependencies": {
"prettier": "^3.6.2",
"turbo": "^2.5.4",
"typescript": "5.8.3"
},
"engines": {
"bun": ">=1.2.12"
},
"packageManager": "bun@1.2.12",
"workspaces": [
"apps/*",
"packages/*"
]
}

View File

@@ -0,0 +1,9 @@
export const sanityConnection = {
pageTitle: "lumify template",
publicViewerToken: "skX06BHIWzgWfkt52091aJgLmYEwFJ7ufghsXi0wRXRkW2Nom8mzfYnFpSVJH1toNy0e34Hot2yTwjLxHCjhsWLZiC0qjR19WI6b9WEFj04shHMZCiS09LgRuzd9BnEgewAQpJUAbhdSwg3NOg8rOhZXpHyciAoYwLqhaTHP6g0FDpiZFrb5",
studioHost: "vaporvee",
studioUrl: "https://vaporvee.sanity.studio", // normaly https://<studioHost>.sanity.studio
projectId: "ax04yw0e",
previewUrl: "https://vaporvee.vercel.app",
dataset: "production", // leave as "production" for the main dataset
};

View File

@@ -0,0 +1,11 @@
{
"name": "@repo/sanity-connection",
"module": "index.ts",
"type": "module",
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5.8.3"
}
}

View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"incremental": false,
"isolatedModules": true,
"lib": ["es2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleDetection": "force",
"moduleResolution": "Bundler",
"allowJs": true,
"jsx": "preserve",
"noEmit": true,
"noUncheckedIndexedAccess": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"target": "ES2022"
}
}

View File

@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"plugins": [
{
"name": "next"
}
],
"allowJs": true,
"noEmit": true,
"jsx": "preserve"
},
}

View File

@@ -0,0 +1,9 @@
{
"name": "@repo/typescript-config",
"version": "0.0.0",
"private": true,
"license": "MIT",
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"jsx": "react-jsx"
}
}

27
packages/ui/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "@repo/ui",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./components/logo": "./src/components/logo.tsx"
},
"scripts": {
"lint": "eslint . --max-warnings 0",
"generate:component": "turbo gen react-component",
"check-types": "tsc --noEmit"
},
"devDependencies": {
"@repo/typescript-config": "*",
"@types/node": "^22.15.3",
"@types/react": "19.1.0",
"@types/react-dom": "19.1.1",
"eslint": "^9.30.0",
"typescript": "5.8.2"
},
"dependencies": {
"react": "^19.1.0",
"react-dom": "^19.1.0"
}
}

View File

@@ -0,0 +1,16 @@
import primitives from "./primitives";
import variables from "./variables";
const brandColors = {
"bg-primary": variables["light-sage"],
"bg-secondary": variables.terracotta,
white: "#FAFCFE",
gray: "#F5F5F5",
foreground: "#333333",
error: "#C23935",
success: primitives.primary1[400],
warning: primitives.secondary2[100],
};
export default brandColors;

View File

@@ -0,0 +1,7 @@
export default function Logo() {
return (
<div>
<h1>Logo</h1>
</div>
);
}

4
packages/ui/src/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { primitives } from "./primitives";
export { default as variables } from "./variables";
export { default as brandColors } from "./brand-colors";
export { default as config } from "./tailwind.config";

View File

@@ -0,0 +1,57 @@
export const primitives = {
primary1: {
25: "#FDFDFD",
50: "#DEEEE9",
100: "#B0D6C7",
200: "#7FBCA3",
300: "#51A181",
400: "#338E6B",
500: "#1E7C56",
600: "#1A704C",
700: "#156140",
800: "#0E5234",
900: "#04371D",
},
primary2: {
25: "#FFFFFF",
50: "#F5F7E7",
100: "#E5EAC4",
200: "#D4DC9F",
300: "#C2CE79",
400: "#B5C45C",
500: "#A8BA3E",
600: "#97AA37",
700: "#81972D",
},
secondary1: {
25: "#FFFFFF",
50: "#E6EFE2",
100: "#C4D7B9",
},
secondary2: {
25: "#FDFDFD",
50: "#FDE9C9",
100: "#F4C39C",
200: "#D5A078",
300: "#B47E52",
400: "#9C6435",
500: "#834C18",
},
accent1: {
25: "#FDFDFD",
50: "#FAF2DF",
100: "#F3DDAF",
200: "#ECC67B",
300: "#E5B046",
400: "#D1A040",
},
accent2: {
25: "#FDFDFD",
50: "#E4F3F9",
100: "#C8DFE4",
200: "#ABC8D0",
300: "#8CB1BB",
}
};
export default primitives;

View File

@@ -0,0 +1,27 @@
import type { Config } from 'tailwindcss';
import primitives from './primitives';
import variables from './variables';
import brandColors from './brand-colors';
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
...primitives,
...variables,
...brandColors
},
fontFamily: {
sans: ['var(--font-sans)'],
},
},
},
plugins: [],
};
export default config;

View File

@@ -0,0 +1,14 @@
import primitives from "./primitives";
const variables = {
"olive-green": primitives.primary2[500],
"pine-green": primitives.primary1[700],
flora: primitives.secondary1[100],
"soil-brown": primitives.secondary2[500],
golden: primitives.accent1[300],
water: primitives.accent2[300],
"light-sage": primitives.secondary1[50],
terracotta: primitives.secondary2[100],
};
export default variables;

12
packages/ui/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "@repo/typescript-config/react-library.json",
"compilerOptions": {
"outDir": "dist",
"allowJs": true,
"noEmit": true,
"module": "ESNext",
"moduleResolution": "bundler"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}

37
turbo.json Normal file
View File

@@ -0,0 +1,37 @@
{
"$schema": "https://turborepo.com/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": [
"^build",
"^generate"
],
"inputs": [
"$TURBO_DEFAULT$",
".env*"
],
"outputs": [
".next/**",
"!.next/cache/**",
"dist/**"
]
},
"lint": {
"dependsOn": [
"^lint"
]
},
"check-types": {
"dependsOn": [
"^check-types"
]
},
"deploy": {},
"generate": {},
"dev": {
"cache": false,
"persistent": true
}
}
}